style: clean up code with prettier and eslint

This commit is contained in:
openhands committed 2026-07-12 20:31:05 +02:00
1 parent 60278b9e71
commit fb8e77bb68
378 files changed
+22407 -20689

No files matched your search

+13 -13
View File
@@ -11,51 +11,51 @@ jobs:
run: |
set -e
echo "--- EPIC WEB CONTROL: Starting Auto-Cleanup & Deploy ---"
# 1. Clean up unused build images safely
docker image prune -f
# 2. Navigate to your website directory
cd /var/www/atom-nexst/
# CRITICAL: Prevent Git permission blocks caused by the www-data ownership change
git config --global --add safe.directory /var/www/atom-nexst
# Point Git directly to the local Gitea folder path
git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
# 3. Fetch and update code
git fetch origin --prune
git reset --hard origin/main
# Preserve .next/cache so Next.js can reuse its incremental build cache.
# next build safely refreshes stale server/static output itself.
rm -rf .output dist
# 4. Configure trusted dependencies globally and install cleanly
export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp"
pnpm install --frozen-lockfile
# 5. Apply versioned CMS migrations and generate the Prisma client safely
pnpm db:migrate
pnpm prisma:generate
# 6. Next.js Build
pnpm build
# 7. Fix ownership: Build first, THEN set permissions for the web server
sudo chown -R www-data:www-data /var/www/atom-nexst/
# 8. Hard restart of the Systemd service to clear memory cache
echo "Hard resetting systemd service..."
sudo systemctl stop atom-nexst.service
sudo systemctl start atom-nexst.service
# Extra health check: Ensure the service is actually running
sleep 2
if ! systemctl is-active --quiet atom-nexst.service; then
echo "ERROR: atom-nexst.service failed to start!" >&2
exit 1
fi
echo "--- Deployment successfully completed ---"
+5872 -3394
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -3,7 +3,9 @@ import { mysqlConnectionUrl } from "./db-url";
describe("mysqlConnectionUrl", () => {
it("removes Prisma-only pool options before passing the URL to MySQL2", () => {
const result = mysqlConnectionUrl("mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4");
const result = mysqlConnectionUrl(
"mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4",
);
expect(result).not.toContain("connection_limit");
expect(result).not.toContain("pool_timeout");
expect(result).toContain("charset=utf8mb4");
+1 -3
View File
@@ -1,7 +1,5 @@
export function splitSqlStatements(sql: string): string[] {
const withoutComments = sql
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*--.*$/gm, "");
const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*--.*$/gm, "");
return withoutComments
.split(";")
+7 -1
View File
@@ -19,7 +19,13 @@ export async function updateBadge({ code, name, desc }: { code: string; name: st
await prisma.websiteBadges.upsert({
where: { badgeKey: code },
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
create: { badgeKey: code, badgeName: name, badgeDescription: desc, createdAt: new Date(), updatedAt: new Date() },
create: {
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: new Date(),
updatedAt: new Date(),
},
});
revalidatePath("/admin/import/badges");
}
+34 -34
View File
@@ -1,70 +1,70 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
const bannerSchema = z.object({
title: z.string().min(1).max(255),
subtitle: z.string().max(500).optional().default(''),
subtitle: z.string().max(500).optional().default(""),
image: z.string().max(500),
link: z.string().max(500).optional().default(''),
color: z.string().max(20).optional().default(''),
link: z.string().max(500).optional().default(""),
color: z.string().max(20).optional().default(""),
isActive: z.coerce.number().int().min(0).max(1).default(1),
sortOrder: z.coerce.number().int().min(0).default(0),
startDate: z.string().max(50).nullable().optional(),
endDate: z.string().max(50).nullable().optional(),
})
});
export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => {
const banner = await prisma.websiteBanner.create({ data: ctx.data })
const banner = await prisma.websiteBanner.create({ data: ctx.data });
logAudit({
userId: ctx.session.user.id,
action: 'banner_create',
target: 'WebsiteBanner',
action: "banner_create",
target: "WebsiteBanner",
targetId: banner.id,
after: { title: banner.title },
})
return actionOk({ id: banner.id })
});
return actionOk({ id: banner.id });
},
)
);
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() })
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websiteBanner.findUnique({ where: { id } })
if (!existing) throw new ActionError('Banner not found')
await prisma.websiteBanner.update({ where: { id }, data })
const { id, ...data } = ctx.data;
const existing = await prisma.websiteBanner.findUnique({ where: { id } });
if (!existing) throw new ActionError("Banner not found");
await prisma.websiteBanner.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: 'banner_update',
target: 'WebsiteBanner',
action: "banner_update",
target: "WebsiteBanner",
targetId: id,
})
return actionOk({ id })
});
return actionOk({ id });
},
)
);
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() })
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => {
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } })
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: 'banner_delete',
target: 'WebsiteBanner',
action: "banner_delete",
target: "WebsiteBanner",
targetId: ctx.data.id,
})
return actionOk()
});
return actionOk();
},
)
);
+34 -4
View File
@@ -7,7 +7,11 @@ import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<ActionResult<{ unbanned: number; total: number }>> {
export async function bulkUnban({
userIds,
}: {
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
await logStaffActivity({
@@ -19,7 +23,15 @@ export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<Act
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
}
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }): Promise<ActionResult<{ banned: number }>> {
export async function bulkBan({
userIds,
reason,
duration,
}: {
userIds: number[];
reason: string;
duration: number;
}): Promise<ActionResult<{ banned: number }>> {
const staff = await requireStaff();
const now = Math.floor(Date.now() / 1000);
let banned = 0;
@@ -53,7 +65,17 @@ export async function bulkBan({ userIds, reason, duration }: { userIds: number[]
return { ok: true as const, data: { banned } };
}
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
export async function bulkGiveCurrency({
userIds,
amount,
type,
}: {
userIds: number[];
amount: number;
type: "credits" | "pixels" | "points";
}): Promise<
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
> {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
@@ -93,7 +115,15 @@ export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: num
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
}
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
export async function bulkGiveBadge({
userIds,
badgeCode,
}: {
userIds: number[];
badgeCode: string;
}): Promise<
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
> {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
+24 -3
View File
@@ -33,7 +33,16 @@ export async function deleteBcItem({ id }: { id: number }) {
revalidatePath("/admin/catalog/builder-club");
}
export async function updateBcItem({ id, ...data }: { id: number; itemIds?: string; catalogName?: string; orderNumber?: number; extradata?: string }) {
export async function updateBcItem({
id,
...data
}: {
id: number;
itemIds?: string;
catalogName?: string;
orderNumber?: number;
extradata?: string;
}) {
const staff = await requireStaff();
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
await logStaffActivity({
@@ -46,7 +55,16 @@ export async function updateBcItem({ id, ...data }: { id: number; itemIds?: stri
revalidatePath("/admin/catalog/builder-club");
}
export async function createBcItem({ pageId, ...data }: { pageId: number; itemIds: string; catalogName: string; orderNumber: number; extradata: string }) {
export async function createBcItem({
pageId,
...data
}: {
pageId: number;
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}) {
const staff = await requireStaff();
const created = await prisma.catalogItemsBc.create({
data: { pageId, ...data },
@@ -63,7 +81,10 @@ export async function createBcItem({ pageId, ...data }: { pageId: number; itemId
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
await requireStaff();
const page = await prisma.catalogPagesBc.findUnique({ where: { id }, select: { enabled: true, visible: true } });
const page = await prisma.catalogPagesBc.findUnique({
where: { id },
select: { enabled: true, visible: true },
});
if (!page) return;
await prisma.catalogPagesBc.update({
where: { id },
+34 -6
View File
@@ -62,7 +62,11 @@ export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; t
return { ok: true as const, data: {} };
}
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
export async function reorderCatalogItems({
orders,
}: {
orders: Array<{ id: number; orderNumber: number }>;
}) {
await requireStaff();
for (const { id, orderNumber } of orders) {
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
@@ -72,7 +76,15 @@ export async function reorderCatalogItems({ orders }: { orders: Array<{ id: numb
return { ok: true as const, data: {} };
}
export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: number; catalogFields: Record<string, unknown>; baseItem?: { id: number; fields: Record<string, unknown> } }) {
export async function updateCatalogItem({
id,
catalogFields,
baseItem,
}: {
id: number;
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
}) {
const staff = await requireStaff();
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
if (baseItem) {
@@ -90,15 +102,22 @@ export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: n
return { ok: true as const, data: {} };
}
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
export async function translateCatalogItems({
items,
}: {
items: Array<{ id: number; publicName: string; description: string }>;
}) {
await requireStaff();
let namesUpdated = 0;
let descriptionsUpdated = 0;
let furniDataUpdated = 0;
let furniDataInserted = 0;
const furniDataInserted = 0;
for (const item of items) {
const existing = await prisma.catalogItems.findUnique({ where: { id: item.id }, select: { catalogName: true } });
const existing = await prisma.catalogItems.findUnique({
where: { id: item.id },
select: { catalogName: true },
});
if (!existing) continue;
if (item.publicName && item.publicName !== existing.catalogName) {
@@ -127,5 +146,14 @@ export async function translateCatalogItems({ items }: { items: Array<{ id: numb
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: { namesUpdated, descriptionsUpdated, furniDataUpdated, furniDataInserted: 0, updated: items.length } };
return {
ok: true as const,
data: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated,
furniDataInserted: 0,
updated: items.length,
},
};
}
+27 -9
View File
@@ -7,7 +7,10 @@ import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>): Promise<ActionResult> {
export async function updateCatalogPage({
id,
...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requireStaff();
await prisma.catalogPages.update({ where: { id }, data: fields as any });
await rcon.updateCatalog();
@@ -37,9 +40,18 @@ export async function deleteCatalogPage({ id }: { id: number }) {
return { ok: true as const, data: {} };
}
export async function toggleCatalogPage({ id, action }: { id: number; action: "toggleEnabled" | "toggleVisible" }) {
export async function toggleCatalogPage({
id,
action,
}: {
id: number;
action: "toggleEnabled" | "toggleVisible";
}) {
await requireStaff();
const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true } });
const page = await prisma.catalogPages.findUnique({
where: { id },
select: { enabled: true, visible: true },
});
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;
@@ -51,7 +63,17 @@ export async function toggleCatalogPage({ id, action }: { id: number; action: "t
return { ok: true as const, data: {} };
}
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout?: string; iconImage?: number; iconColor?: number; enabled?: "0" | "1"; visible?: "0" | "1"; minRank?: number; orderNum?: number }): Promise<ActionResult<{ id: number }>> {
export async function createCatalogPage(input: {
caption: string;
parentId: number;
pageLayout?: string;
iconImage?: number;
iconColor?: number;
enabled?: "0" | "1";
visible?: "0" | "1";
minRank?: number;
orderNum?: number;
}): Promise<ActionResult<{ id: number }>> {
const staff = await requireStaff();
const created = await prisma.catalogPages.create({
data: {
@@ -83,11 +105,7 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
return { ok: true as const, data: { id: created.id } };
}
export async function reorderTreePage(input: {
pageId: number;
newParentId?: number;
newOrderNum: number;
}) {
export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) {
await requireStaff();
await prisma.catalogPages.update({
where: { id: input.pageId },
+17 -17
View File
@@ -1,39 +1,39 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { rcon } from '@/lib/services/rcon'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
})
});
export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => {
const entries = Object.entries(ctx.data.settings)
const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
})
});
}
await rcon.updateConfig()
await rcon.updateConfig();
logAudit({
userId: ctx.session.user.id,
action: 'emulator_settings_update',
target: 'EmulatorSettings',
action: "emulator_settings_update",
target: "EmulatorSettings",
after: ctx.data.settings,
})
});
return actionOk()
return actionOk();
},
)
);
+71 -71
View File
@@ -1,18 +1,18 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createEventSchema,
eventPrizeSchema,
eventTypeSchema,
eventWinnerSchema,
updateEventSchema,
} from '@/lib/validators/event'
} from "@/lib/validators/event";
// ── Event Types ─────────────────────────────────────────────────────
@@ -21,63 +21,63 @@ export const createEventType = adminAction(
async (ctx) => {
const eventType = await prisma.websiteEventType.create({
data: ctx.data,
})
});
logAudit({
userId: ctx.session.user.id,
action: 'event_type_create',
target: 'WebsiteEventType',
action: "event_type_create",
target: "WebsiteEventType",
targetId: eventType.id,
after: { name: eventType.name },
})
return actionOk({ id: eventType.id })
});
return actionOk({ id: eventType.id });
},
)
);
const updateEventTypeInput = eventTypeSchema.partial().extend({
id: z.coerce.number().int().positive(),
})
});
export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websiteEventType.findUnique({ where: { id } })
if (!existing) throw new ActionError('Event type not found')
const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({ where: { id } });
if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.update({ where: { id }, data })
await prisma.websiteEventType.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: 'event_type_update',
target: 'WebsiteEventType',
action: "event_type_update",
target: "WebsiteEventType",
targetId: id,
before: { name: existing.name },
after: data,
})
return actionOk({ id })
});
return actionOk({ id });
},
)
);
const deleteEventTypeInput = z.object({
id: z.coerce.number().int().positive(),
})
});
export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } })
if (!existing) throw new ActionError('Event type not found')
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } });
if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } })
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: 'event_type_delete',
target: 'WebsiteEventType',
action: "event_type_delete",
target: "WebsiteEventType",
targetId: ctx.data.id,
before: { name: existing.name },
})
return actionOk()
});
return actionOk();
},
)
);
// ── Events ──────────────────────────────────────────────────────────
@@ -89,97 +89,97 @@ export const createEvent = adminAction(
...ctx.data,
hostUserId: Number(ctx.session.user.id),
},
})
});
logAudit({
userId: ctx.session.user.id,
action: 'event_create',
target: 'WebsiteEvent',
action: "event_create",
target: "WebsiteEvent",
targetId: event.id,
after: { title: event.title },
})
return actionOk({ id: event.id })
});
return actionOk({ id: event.id });
},
)
);
const updateEventInput = updateEventSchema.extend({
id: z.coerce.number().int().positive(),
})
});
export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websiteEvent.findUnique({ where: { id } })
if (!existing) throw new ActionError('Event not found')
const { id, ...data } = ctx.data;
const existing = await prisma.websiteEvent.findUnique({ where: { id } });
if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.update({ where: { id }, data })
await prisma.websiteEvent.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: 'event_update',
target: 'WebsiteEvent',
action: "event_update",
target: "WebsiteEvent",
targetId: id,
before: { title: existing.title, status: existing.status },
after: data,
})
return actionOk({ id })
});
return actionOk({ id });
},
)
);
const deleteEventInput = z.object({
id: z.coerce.number().int().positive(),
})
});
export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } })
if (!existing) throw new ActionError('Event not found')
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } });
if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } })
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: 'event_delete',
target: 'WebsiteEvent',
action: "event_delete",
target: "WebsiteEvent",
targetId: ctx.data.id,
before: { title: existing.title },
})
return actionOk()
});
return actionOk();
},
)
);
// ── Prizes ──────────────────────────────────────────────────────────
export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => {
const prize = await prisma.websiteEventPrize.create({ data: ctx.data })
return actionOk({ id: prize.id })
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
return actionOk({ id: prize.id });
},
)
);
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() })
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => {
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } })
return actionOk()
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
return actionOk();
},
)
);
// ── Winners ─────────────────────────────────────────────────────────
export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => {
const winner = await prisma.websiteEventWinner.create({ data: ctx.data })
const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
logAudit({
userId: ctx.session.user.id,
action: 'event_winner_add',
target: 'WebsiteEventWinner',
action: "event_winner_add",
target: "WebsiteEventWinner",
targetId: winner.id,
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position },
})
return actionOk({ id: winner.id })
});
return actionOk({ id: winner.id });
},
)
);
+16 -2
View File
@@ -3,13 +3,27 @@
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function importBadgeFromRemote({ code, name, description }: { code: string; name: string; description: string }) {
export async function importBadgeFromRemote({
code,
name,
description,
}: {
code: string;
name: string;
description: string;
}) {
await requireStaff();
try {
await prisma.websiteBadges.upsert({
where: { badgeKey: code },
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
create: { badgeKey: code, badgeName: name, badgeDescription: description, createdAt: new Date(), updatedAt: new Date() },
create: {
badgeKey: code,
badgeName: name,
badgeDescription: description,
createdAt: new Date(),
updatedAt: new Date(),
},
});
return { ok: true as const };
} catch {
+13 -12
View File
@@ -1,19 +1,20 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
import { cleanupConvertedSwfs, deleteImportedItem } from '@/lib/services/furni-import'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import";
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
const result = await cleanupConvertedSwfs()
return actionOk(result as unknown as Record<string, unknown>)
})
const result = await cleanupConvertedSwfs();
return actionOk(result as unknown as Record<string, unknown>);
});
const deleteSchema = z.object({ classname: z.string().trim().min(1) })
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) => actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
)
async (ctx) =>
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
);
+75 -75
View File
@@ -1,67 +1,67 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { rcon } from '@/lib/services/rcon'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
// ── CFH Ticket Actions ──────────────────────────────────────────────
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() })
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
export const assignCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
if (!ticket) throw new ActionError('Ticket not found')
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
if (!ticket) throw new ActionError("Ticket not found");
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
data: { modId: ctx.session.user.id, state: 1 },
})
});
logAudit({
userId: ctx.session.user.id,
action: 'cfh_assign',
target: 'support_tickets',
action: "cfh_assign",
target: "support_tickets",
targetId: ctx.data.ticketId,
})
});
return actionOk()
return actionOk();
},
)
);
const cfhStateSchema = z.object({
ticketId: z.coerce.number().int().positive(),
state: z.coerce.number().int().min(0).max(3),
})
});
export const updateCfhState = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
if (!ticket) throw new ActionError('Ticket not found')
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
if (!ticket) throw new ActionError("Ticket not found");
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
data: { state: ctx.data.state, modId: ctx.session.user.id },
})
});
logAudit({
userId: ctx.session.user.id,
action: 'cfh_state_change',
target: 'support_tickets',
action: "cfh_state_change",
target: "support_tickets",
targetId: ctx.data.ticketId,
before: { state: ticket.state },
after: { state: ctx.data.state },
})
});
return actionOk()
return actionOk();
},
)
);
export const closeCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
@@ -69,138 +69,138 @@ export const closeCfhTicket = adminAction(
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
data: { state: 2, modId: ctx.session.user.id },
})
});
logAudit({
userId: ctx.session.user.id,
action: 'cfh_close',
target: 'support_tickets',
action: "cfh_close",
target: "support_tickets",
targetId: ctx.data.ticketId,
})
});
return actionOk()
return actionOk();
},
)
);
// ── Quick Mod Actions ────────────────────────────────────────────────
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() })
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
async (ctx) => {
await rcon.disconnectUser(ctx.data.userId)
await rcon.disconnectUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: 'mod_kick',
target: 'User',
action: "mod_kick",
target: "User",
targetId: ctx.data.userId,
})
});
return actionOk()
return actionOk();
},
)
);
const muteSchema = z.object({
userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
})
});
export const quickMute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration)
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
logAudit({
userId: ctx.session.user.id,
action: 'mod_mute',
target: 'User',
action: "mod_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
})
});
return actionOk()
return actionOk();
},
)
);
export const quickUnmute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
async (ctx) => {
await rcon.unmuteUser(ctx.data.userId)
await rcon.unmuteUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: 'mod_unmute',
target: 'User',
action: "mod_unmute",
target: "User",
targetId: ctx.data.userId,
})
});
return actionOk()
return actionOk();
},
)
);
const alertSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500),
})
});
export const quickAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message)
await rcon.alertUser(ctx.data.userId, ctx.data.message);
logAudit({
userId: ctx.session.user.id,
action: 'mod_alert',
target: 'User',
action: "mod_alert",
target: "User",
targetId: ctx.data.userId,
after: { message: ctx.data.message },
})
});
return actionOk()
return actionOk();
},
)
);
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() })
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
async (ctx) => {
await rcon.kickAll(ctx.data.roomId)
await rcon.kickAll(ctx.data.roomId);
logAudit({
userId: ctx.session.user.id,
action: 'mod_room_kick',
target: 'Room',
action: "mod_room_kick",
target: "Room",
targetId: ctx.data.roomId,
})
});
return actionOk()
return actionOk();
},
)
);
const broadcastSchema = z.object({
message: z.string().min(1).max(500),
type: z.enum(['hotel', 'staff']),
})
type: z.enum(["hotel", "staff"]),
});
export const broadcastAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
async (ctx) => {
if (ctx.data.type === 'hotel') {
await rcon.hotelAlert(ctx.data.message)
if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message);
} else {
await rcon.staffAlert(ctx.data.message)
await rcon.staffAlert(ctx.data.message);
}
logAudit({
userId: ctx.session.user.id,
action: `mod_broadcast_${ctx.data.type}`,
target: 'broadcast',
target: "broadcast",
after: { message: ctx.data.message },
})
});
return actionOk()
return actionOk();
},
)
);
+90 -52
View File
@@ -1,102 +1,140 @@
'use server'
"use server";
import { revalidateTag } from 'next/cache'
import { z } from 'zod'
import { PERMS } from '@/lib/permission-slugs'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from '@/lib/services/permission-ranks'
import { rcon } from '@/lib/services/rcon'
import { logStaffActivity } from '@/lib/services/staff-activity'
import { revalidateTag } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const createRankSchema = z.object({
rank_name: z.string().trim().min(1).max(25),
level: z.coerce.number().int().min(1),
})
});
export const createRank = adminAction(
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const id = await createEmulatorRank(prisma, ctx.data)
const id = await createEmulatorRank(prisma, ctx.data);
await prisma.aclRole.upsert({
where: { slug: `rank_${id}` },
create: { slug: `rank_${id}`, title: ctx.data.rank_name, description: 'CMS role synchronized from permission_ranks' },
create: {
slug: `rank_${id}`,
title: ctx.data.rank_name,
description: "CMS role synchronized from permission_ranks",
},
update: { title: ctx.data.rank_name },
})
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_create', description: `Created rank #${id}`, targetType: 'rank', targetId: id })
await rcon.send('updatepermissions')
revalidateTag('permissions', { expire: 0 })
return actionOk({ id })
});
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_create",
description: `Created rank #${id}`,
targetType: "rank",
targetId: id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk({ id });
},
)
);
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() })
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const users = await prisma.user.count({ where: { rank: ctx.data.id } })
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`)
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } })
await deleteEmulatorRank(prisma, ctx.data.id)
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`);
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } });
await deleteEmulatorRank(prisma, ctx.data.id);
if (role) {
await prisma.$transaction([
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } }),
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }),
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
prisma.aclRole.delete({ where: { id: role.id } }),
])
]);
}
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_delete', description: `Deleted rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id })
await rcon.send('updatepermissions')
revalidateTag('permissions', { expire: 0 })
return actionOk()
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_delete",
description: `Deleted rank #${ctx.data.id}`,
targetType: "rank",
targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
)
);
const saveRankSchema = z.object({
id: z.coerce.number().int().positive(),
fields: z.record(z.string(), z.union([z.string(), z.number()])),
})
});
export const saveRank = adminAction(
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields)
if (typeof ctx.data.fields.rank_name === 'string') {
await prisma.aclRole.updateMany({ where: { slug: `rank_${ctx.data.id}` }, data: { title: ctx.data.fields.rank_name } })
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
if (typeof ctx.data.fields.rank_name === "string") {
await prisma.aclRole.updateMany({
where: { slug: `rank_${ctx.data.id}` },
data: { title: ctx.data.fields.rank_name },
});
}
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_update', description: `Updated rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id })
await rcon.send('updatepermissions')
revalidateTag('permissions', { expire: 0 })
return actionOk()
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_update",
description: `Updated rank #${ctx.data.id}`,
targetType: "rank",
targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
)
);
const setCmsPermsSchema = z.object({
roleId: z.coerce.number().int().positive(),
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
})
});
export const setCmsPermissions = adminAction(
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const role = await prisma.aclRole.findUnique({ where: { id: ctx.data.roleId }, select: { id: true, slug: true } })
if (!role) throw new ActionError('Role not found')
const role = await prisma.aclRole.findUnique({
where: { id: ctx.data.roleId },
select: { id: true, slug: true },
});
if (!role) throw new ActionError("Role not found");
const permissions = await prisma.aclPermission.findMany({
where: { slug: { in: ctx.data.permissionSlugs } },
select: { id: true },
})
});
await prisma.$transaction(async (tx) => {
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } })
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } });
if (permissions.length) {
await tx.aclModelPermission.createMany({
data: permissions.map((permission) => ({ modelId: role.id, modelType: 'Role', permissionId: permission.id })),
})
data: permissions.map((permission) => ({
modelId: role.id,
modelType: "Role",
permissionId: permission.id,
})),
});
}
})
await logStaffActivity({ staffId: ctx.session.user.id, action: 'acl_role_permissions_update', description: `Updated ${permissions.length} permissions for ${role.slug}`, targetType: 'acl_role', targetId: role.id })
revalidateTag('permissions', { expire: 0 })
return actionOk()
});
await logStaffActivity({
staffId: ctx.session.user.id,
action: "acl_role_permissions_update",
description: `Updated ${permissions.length} permissions for ${role.slug}`,
targetType: "acl_role",
targetId: role.id,
});
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
)
);
+45 -45
View File
@@ -1,107 +1,107 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { createPollSchema, pollQuestionSchema, updatePollSchema } from '@/lib/validators/poll'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll";
// ── Polls ───────────────────────────────────────────────────────────
export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
async (ctx) => {
const poll = await prisma.websitePoll.create({ data: ctx.data })
const poll = await prisma.websitePoll.create({ data: ctx.data });
logAudit({
userId: ctx.session.user.id,
action: 'poll_create',
target: 'WebsitePoll',
action: "poll_create",
target: "WebsitePoll",
targetId: poll.id,
after: { title: poll.title },
})
return actionOk({ id: poll.id })
});
return actionOk({ id: poll.id });
},
)
);
const updatePollInput = updatePollSchema.extend({
id: z.coerce.number().int().positive(),
})
});
export const updatePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websitePoll.findUnique({ where: { id } })
if (!existing) throw new ActionError('Poll not found')
const { id, ...data } = ctx.data;
const existing = await prisma.websitePoll.findUnique({ where: { id } });
if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.update({ where: { id }, data })
await prisma.websitePoll.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: 'poll_update',
target: 'WebsitePoll',
action: "poll_update",
target: "WebsitePoll",
targetId: id,
before: { title: existing.title, status: existing.status },
after: data,
})
return actionOk({ id })
});
return actionOk({ id });
},
)
);
const deletePollInput = z.object({
id: z.coerce.number().int().positive(),
})
});
export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => {
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } })
if (!existing) throw new ActionError('Poll not found')
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } });
if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.delete({ where: { id: ctx.data.id } })
await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: 'poll_delete',
target: 'WebsitePoll',
action: "poll_delete",
target: "WebsitePoll",
targetId: ctx.data.id,
before: { title: existing.title },
})
return actionOk()
});
return actionOk();
},
)
);
// ── Questions ───────────────────────────────────────────────────────
export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => {
const question = await prisma.websitePollQuestion.create({ data: ctx.data })
return actionOk({ id: question.id })
const question = await prisma.websitePollQuestion.create({ data: ctx.data });
return actionOk({ id: question.id });
},
)
);
const updateQuestionInput = pollQuestionSchema.partial().extend({
id: z.coerce.number().int().positive(),
})
});
export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => {
const { id, ...data } = ctx.data
await prisma.websitePollQuestion.update({ where: { id }, data })
return actionOk({ id })
const { id, ...data } = ctx.data;
await prisma.websitePollQuestion.update({ where: { id }, data });
return actionOk({ id });
},
)
);
const deleteQuestionInput = z.object({
id: z.coerce.number().int().positive(),
})
});
export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => {
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } })
return actionOk()
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
return actionOk();
},
)
);
+45 -45
View File
@@ -1,10 +1,10 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
// are not represented in prisma/schema.prisma yet — we use raw queries with
@@ -19,26 +19,26 @@ const createPrefixSchema = z.object({
icon: z.string().optional(),
effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).default(1),
})
});
export const createPrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
async (ctx) => {
const { username, text, color, icon, effect, active } = ctx.data
const { username, text, color, icon, effect, active } = ctx.data;
const users = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM users WHERE username = ${username} LIMIT 1
`
if (users.length === 0) throw new ActionError('User not found')
`;
if (users.length === 0) throw new ActionError("User not found");
await prisma.$executeRaw`
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ''}, ${effect || ''}, ${active})
`
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
`;
return actionOk()
return actionOk();
},
)
);
// ── Update prefix ───────────────────────────────────────────────────
@@ -49,94 +49,94 @@ const updatePrefixSchema = z.object({
icon: z.string().optional(),
effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).optional(),
})
});
export const updatePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
async (ctx) => {
const { id, text, color, icon, effect, active } = ctx.data
const { id, text, color, icon, effect, active } = ctx.data;
await prisma.$executeRaw`
UPDATE custom_prefixes
SET text = ${text}, color = ${color}, icon = ${icon || ''}, effect = ${effect || ''}, active = ${active ?? 1}
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
WHERE id = ${id}
`
`;
return actionOk()
return actionOk();
},
)
);
// ── Delete prefix ───────────────────────────────────────────────────
const deletePrefixSchema = z.object({
id: z.coerce.number().int().positive(),
})
});
export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`
return actionOk()
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`;
return actionOk();
},
)
);
// ── Add blacklist word ──────────────────────────────────────────────
const addBlacklistWordSchema = z.object({
word: z.string().min(1).max(100),
})
});
export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
async (ctx) => {
await prisma.$executeRaw`
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
`
return actionOk()
`;
return actionOk();
},
)
);
// ── Remove blacklist word ───────────────────────────────────────────
const removeBlacklistWordSchema = z.object({
id: z.coerce.number().int().positive(),
})
});
export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`
return actionOk()
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`;
return actionOk();
},
)
);
// ── Update prefix settings ──────────────────────────────────────────
const SETTINGS_WHITELIST = new Set([
'enabled',
'max_length',
'min_rank',
'allow_colors',
'allow_bold',
'allow_italic',
'default_color',
])
"enabled",
"max_length",
"min_rank",
"allow_colors",
"allow_bold",
"allow_italic",
"default_color",
]);
const updatePrefixSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
})
});
export const updatePrefixSettings = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
async (ctx) => {
for (const [key, value] of Object.entries(ctx.data.settings)) {
if (!SETTINGS_WHITELIST.has(key)) continue
if (!SETTINGS_WHITELIST.has(key)) continue;
await prisma.$executeRaw`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${value})
ON DUPLICATE KEY UPDATE \`value\` = ${value}
`
`;
}
return actionOk()
return actionOk();
},
)
);
+10 -1
View File
@@ -71,7 +71,16 @@ export async function deleteRoom({ id }: { id: number }) {
revalidatePath("/admin/rooms");
}
export async function updateRoom({ id, ...data }: { id: number; name?: string; description?: string; state?: string; usersMax?: number }) {
export async function updateRoom({
id,
...data
}: {
id: number;
name?: string;
description?: string;
state?: string;
usersMax?: number;
}) {
const staff = await requireStaff();
await prisma.rooms.update({ where: { id }, data: data as any });
await logStaffActivity({
+13 -1
View File
@@ -10,7 +10,19 @@ export async function deleteSoundtrack({ id }: { id: number }) {
revalidatePath("/admin/sounds");
}
export async function updateSoundtrack({ id, name, author, track, length }: { id: number; name: string; author: string; track: string; length: number }) {
export async function updateSoundtrack({
id,
name,
author,
track,
length,
}: {
id: number;
name: string;
author: string;
track: string;
length: number;
}) {
await requireStaff();
await prisma.soundtracks.update({
where: { id },
+22 -24
View File
@@ -1,47 +1,45 @@
'use server'
"use server";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const templateSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
category: z.string().max(50).optional().default('general'),
category: z.string().max(50).optional().default("general"),
sortOrder: z.coerce.number().int().min(0).default(0),
})
});
export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data })
return actionOk({ id: tpl.id })
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
return actionOk({ id: tpl.id });
},
)
);
const updateTemplateInput = templateSchema
.partial()
.extend({ id: z.coerce.number().int().positive() })
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() });
export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } })
if (!existing) throw new ActionError('Template not found')
await prisma.websiteTicketTemplate.update({ where: { id }, data })
return actionOk({ id })
const { id, ...data } = ctx.data;
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } });
if (!existing) throw new ActionError("Template not found");
await prisma.websiteTicketTemplate.update({ where: { id }, data });
return actionOk({ id });
},
)
);
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() })
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } })
return actionOk()
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
return actionOk();
},
)
);
+73 -73
View File
@@ -1,18 +1,18 @@
'use server'
"use server";
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction, authAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { notify } from '@/lib/services/webhook'
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook";
import {
assignTicketSchema,
createTicketSchema,
replyTicketSchema,
updateTicketPrioritySchema,
updateTicketStatusSchema,
} from '@/lib/validators/ticket'
} from "@/lib/validators/ticket";
// ── User actions (authenticated, no admin perms needed) ──────────────
@@ -23,7 +23,7 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
category: ctx.data.category,
creatorId: ctx.session.user.id,
},
})
});
// Create the first message
await prisma.websiteTicketMessage.create({
@@ -33,26 +33,26 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
message: ctx.data.message,
isStaff: 0,
},
})
});
notify({
action: 'ticket_create',
action: "ticket_create",
actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`,
details: `Category: ${ticket.category}`,
})
});
return actionOk({ id: ticket.id })
})
return actionOk({ id: ticket.id });
});
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
});
if (!ticket) throw new ActionError('Ticket not found')
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
if (ticket.status === 'closed') throw new ActionError('Ticket is closed')
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
await prisma.websiteTicketMessage.create({
data: {
@@ -61,38 +61,38 @@ export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (
message: ctx.data.message,
isStaff: 0,
},
})
});
// If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === 'waiting') {
if (ticket.status === "waiting") {
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: 'open' },
})
data: { status: "open" },
});
}
return actionOk()
})
return actionOk();
});
export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
});
if (!ticket) throw new ActionError('Ticket not found')
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
if (ticket.status === 'closed') throw new ActionError('Ticket is already closed')
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
if (ticket.status === "closed") throw new ActionError("Ticket is already closed");
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: 'closed', closedAt: new Date() },
})
data: { status: "closed", closedAt: new Date() },
});
return actionOk()
return actionOk();
},
)
);
// ── Admin actions ────────────────────────────────────────────────────
@@ -101,9 +101,9 @@ export const adminReplyTicket = adminAction(
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
});
if (!ticket) throw new ActionError('Ticket not found')
if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicketMessage.create({
data: {
@@ -112,118 +112,118 @@ export const adminReplyTicket = adminAction(
message: ctx.data.message,
isStaff: 1,
},
})
});
// Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: 'waiting' }
const updates: Record<string, unknown> = { status: "waiting" };
if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id
updates.assigneeId = ctx.session.user.id;
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: updates,
})
});
logAudit({
userId: ctx.session.user.id,
action: 'ticket_reply',
target: 'WebsiteTicket',
action: "ticket_reply",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
})
});
return actionOk()
return actionOk();
},
)
);
export const updateTicketStatus = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
});
if (!ticket) throw new ActionError('Ticket not found')
if (!ticket) throw new ActionError("Ticket not found");
const data: Record<string, unknown> = { status: ctx.data.status }
if (ctx.data.status === 'closed') {
data.closedAt = new Date()
const data: Record<string, unknown> = { status: ctx.data.status };
if (ctx.data.status === "closed") {
data.closedAt = new Date();
}
if (ctx.data.status === 'in_progress' && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id;
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data,
})
});
logAudit({
userId: ctx.session.user.id,
action: 'ticket_status_change',
target: 'WebsiteTicket',
action: "ticket_status_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { status: ticket.status },
after: { status: ctx.data.status },
})
});
return actionOk()
return actionOk();
},
)
);
export const assignTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
});
if (!ticket) throw new ActionError('Ticket not found')
if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: {
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? 'in_progress' : 'open',
status: ctx.data.assigneeId ? "in_progress" : "open",
},
})
});
logAudit({
userId: ctx.session.user.id,
action: 'ticket_assign',
target: 'WebsiteTicket',
action: "ticket_assign",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId },
})
});
return actionOk()
return actionOk();
},
)
);
export const updateTicketPriority = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
});
if (!ticket) throw new ActionError('Ticket not found')
if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { priority: ctx.data.priority },
})
});
logAudit({
userId: ctx.session.user.id,
action: 'ticket_priority_change',
target: 'WebsiteTicket',
action: "ticket_priority_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { priority: ticket.priority },
after: { priority: ctx.data.priority },
})
});
return actionOk()
return actionOk();
},
)
);
+53 -56
View File
@@ -1,76 +1,73 @@
'use server'
"use server";
import fs from 'node:fs/promises'
import path from 'node:path'
import JSON5 from 'json5'
import { z } from 'zod'
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
import { patchJson5 } from '@/lib/json5-patch'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import fs from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5";
import { z } from "zod";
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
import { patchJson5 } from "@/lib/json5-patch";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({
locale: z.enum(['en', 'it']),
locale: z.enum(["en", "it"]),
data: z.record(z.string(), z.unknown()),
})
});
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const filePath = path.join(process.cwd(), 'messages', `${ctx.data.locale}.json`)
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), 'utf-8')
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
return actionOk()
})
return actionOk();
});
const saveClientTranslationsSchema = z.object({
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
data: z.record(z.string(), z.string()),
})
});
export const saveClientTranslations = adminAction(
{ schema: saveClientTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const file = getClientTranslationFile(ctx.data.fileId)
if (!file) throw new ActionError('Unknown file')
if (file.readOnly) throw new ActionError('File is read-only')
const file = getClientTranslationFile(ctx.data.fileId);
if (!file) throw new ActionError("Unknown file");
if (file.readOnly) throw new ActionError("File is read-only");
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath)
const raw = await fs.readFile(absPath, 'utf-8')
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath);
const raw = await fs.readFile(absPath, "utf-8");
if (file.format === 'json') {
// Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] })
if (file.format === "json") {
// Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8");
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
}
// JSON5: surgical line-level patch keeps headers and section comments
// intact. Falls back to a full re-serialization (which DOES drop comments)
// only when an edited key cannot be located via the patch contract.
const original: Record<string, string> = {};
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
original[k] = v == null ? "" : String(v);
}
}
// JSON5: surgical line-level patch keeps headers and section comments
// intact. Falls back to a full re-serialization (which DOES drop comments)
// only when an edited key cannot be located via the patch contract.
const original: Record<string, string> = {}
const parsed = JSON5.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
original[k] = v == null ? '' : String(v)
}
}
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data)
if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, "utf-8");
return actionOk({ commentsLost: false, unpatchedKeys });
}
if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys })
}
// At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost.
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), 'utf-8')
return actionOk({ commentsLost: true, unpatchedKeys })
},
)
// At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost.
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8");
return actionOk({ commentsLost: true, unpatchedKeys });
});
+192 -213
View File
@@ -1,36 +1,31 @@
'use server'
"use server";
import crypto from 'node:crypto'
import { hash } from 'bcryptjs'
import { z } from 'zod'
import { Prisma } from '@/generated/prisma/client'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { rcon } from '@/lib/services/rcon'
import { notify } from '@/lib/services/webhook'
import {
banUserSchema,
createUserSchema,
giveBadgeSchema,
updateUserSchema,
} from '@/lib/validators/user'
import crypto from "node:crypto";
import { hash } from "bcryptjs";
import { z } from "zod";
import { Prisma } from "@/generated/prisma/client";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
import { notify } from "@/lib/services/webhook";
import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user";
const DEFAULT_LOOK = 'hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64'
const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data
const { username, mail, password, rank, motto } = ctx.data;
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError('Cannot assign rank equal or higher than your own')
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const hashedPassword = await hash(password, 12)
const now = Math.floor(Date.now() / 1000)
const hashedPassword = await hash(password, 12);
const now = Math.floor(Date.now() / 1000);
try {
const user = await prisma.$transaction(async (tx) => {
@@ -45,221 +40,211 @@ export const createUser = adminAction(
credits: 5000,
pixels: 5000,
accountCreated: now,
ipRegister: '0.0.0.0',
ipCurrent: '0.0.0.0',
ipRegister: "0.0.0.0",
ipCurrent: "0.0.0.0",
},
})
});
await tx.usersSettings.create({ data: { userId: created.id } })
await tx.usersSettings.create({ data: { userId: created.id } });
await tx.usersCurrency.createMany({
data: [
{ userId: created.id, type: 0, amount: 5000 },
{ userId: created.id, type: 5, amount: 5000 },
],
})
});
return created
})
return created;
});
logAudit({
userId: ctx.session.user.id,
action: 'user_create',
target: 'User',
action: "user_create",
target: "User",
targetId: user.id,
after: { username, mail, rank },
})
});
notify({
action: 'user_edit',
action: "user_edit",
actor: ctx.session.user.username,
target: username,
targetId: user.id,
details: 'Account created by admin',
})
details: "Account created by admin",
});
return actionOk({ id: user.id, username: user.username })
return actionOk({ id: user.id, username: user.username });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const target = (err.meta?.target as string[]) ?? []
if (target.includes('username')) throw new ActionError('Username already taken')
if (target.includes('mail')) throw new ActionError('Email already registered')
throw new ActionError('Username or email already in use')
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
const target = (err.meta?.target as string[]) ?? [];
if (target.includes("username")) throw new ActionError("Username already taken");
if (target.includes("mail")) throw new ActionError("Email already registered");
throw new ActionError("Username or email already in use");
}
throw err
throw err;
}
},
)
);
const updateUserInput = updateUserSchema.extend({
id: z.coerce.number().int().positive(),
})
});
export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id, diamonds, duckets, ...userData } = ctx.data
const { id, diamonds, duckets, ...userData } = ctx.data;
const targetUser = await guardRank(id, ctx.session.user.rank)
const targetUser = await guardRank(id, ctx.session.user.rank);
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError('Cannot assign rank equal or higher than your own')
if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
await prisma.user.update({ where: { id }, data: userData })
await prisma.user.update({ where: { id }, data: userData });
if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 5 } },
update: { amount: diamonds },
create: { userId: id, type: 5, amount: diamonds },
})
});
}
if (duckets !== undefined) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 0 } },
update: { amount: duckets },
create: { userId: id, type: 0, amount: duckets },
})
});
}
logAudit({
userId: ctx.session.user.id,
action: 'user_edit',
target: 'User',
action: "user_edit",
target: "User",
targetId: id,
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
after: userData,
})
});
notify({
action: 'user_edit',
action: "user_edit",
actor: ctx.session.user.username,
target: targetUser.username,
targetId: id,
})
});
return actionOk()
return actionOk();
},
)
);
const banInput = banUserSchema.extend({})
const banInput = banUserSchema.extend({});
export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banInput },
async (ctx) => {
const { userId, reason, duration, type, ip } = ctx.data
export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => {
const { userId, reason, duration, type, ip } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank)
const targetUser = await guardRank(userId, ctx.session.user.rank);
const now = Math.floor(Date.now() / 1000)
const banExpire = duration > 0 ? now + duration * 3600 : 0
const now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0;
await prisma.ban.create({
data: {
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || 'account',
ip: ip || '',
machineId: '',
},
})
await prisma.ban.create({
data: {
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || "account",
ip: ip || "",
machineId: "",
},
});
await rcon.disconnectUser(userId)
await rcon.disconnectUser(userId);
logAudit({
userId: ctx.session.user.id,
action: 'ban',
target: 'User',
targetId: userId,
after: { reason, type, duration },
})
logAudit({
userId: ctx.session.user.id,
action: "ban",
target: "User",
targetId: userId,
after: { reason, type, duration },
});
notify({
action: 'ban',
actor: ctx.session.user.username,
target: targetUser.username,
details: reason,
})
notify({
action: "ban",
actor: ctx.session.user.username,
target: targetUser.username,
details: reason,
});
return actionOk()
},
)
return actionOk();
});
const unbanInput = z.object({ userId: z.coerce.number().int().positive() })
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: unbanInput },
async (ctx) => {
const { userId } = ctx.data
export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => {
const { userId } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank)
const targetUser = await guardRank(userId, ctx.session.user.rank);
await prisma.ban.deleteMany({ where: { userId } })
await prisma.ban.deleteMany({ where: { userId } });
logAudit({
userId: ctx.session.user.id,
action: 'unban',
target: 'User',
targetId: userId,
})
logAudit({
userId: ctx.session.user.id,
action: "unban",
target: "User",
targetId: userId,
});
notify({
action: 'unban',
actor: ctx.session.user.username,
target: targetUser.username,
})
notify({
action: "unban",
actor: ctx.session.user.username,
target: targetUser.username,
});
return actionOk()
},
)
return actionOk();
});
export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data
const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank)
await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
if (existing) throw new ActionError('Badge already assigned')
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
if (existing) throw new ActionError("Badge already assigned");
await prisma.usersBadges.create({ data: { userId, badgeCode } })
await rcon.giveBadge(userId, badgeCode)
await prisma.usersBadges.create({ data: { userId, badgeCode } });
await rcon.giveBadge(userId, badgeCode);
return actionOk()
return actionOk();
},
)
);
// ── Remove Badge ────────────────────────────────────────────────────
const removeBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badgeCode: z.string().min(1),
})
});
export const removeBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data
const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank)
await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
if (!existing) throw new ActionError('Badge not found')
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
if (!existing) throw new ActionError("Badge not found");
await prisma.usersBadges.delete({ where: { id: existing.id } })
await rcon.removeBadge(userId, badgeCode)
await prisma.usersBadges.delete({ where: { id: existing.id } });
await rcon.removeBadge(userId, badgeCode);
return actionOk()
return actionOk();
},
)
);
// ── Rank guard helper ───────────────────────────────────────────────
@@ -267,169 +252,163 @@ async function guardRank(targetUserId: number, sessionRank: number) {
const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { username: true, rank: true, mail: true },
})
if (!target) throw new ActionError('User not found')
});
if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError('Cannot modify user with equal or higher rank')
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target
return target;
}
// ── Reset Password ──────────────────────────────────────────────────
const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(),
})
});
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const newPassword = crypto.randomBytes(12).toString('base64url').slice(0, 16)
const hashed = await hash(newPassword, 10)
const newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16);
const hashed = await hash(newPassword, 10);
await prisma.user.update({
where: { id: ctx.data.userId },
data: { password: hashed },
})
});
logAudit({
userId: ctx.session.user.id,
action: 'reset_password',
target: 'User',
action: "reset_password",
target: "User",
targetId: ctx.data.userId,
})
});
notify({
action: 'user_edit',
action: "user_edit",
actor: ctx.session.user.username,
target: target.username,
details: 'Password reset',
})
details: "Password reset",
});
return actionOk({ newPassword })
return actionOk({ newPassword });
},
)
);
// ── Disconnect User ─────────────────────────────────────────────────
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
})
});
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.disconnectUser(ctx.data.userId)
if (!success) throw new ActionError('Failed to disconnect. Is the emulator running?')
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.disconnectUser(ctx.data.userId);
if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: 'user_disconnect',
target: 'User',
action: "user_disconnect",
target: "User",
targetId: ctx.data.userId,
})
});
notify({
action: 'disconnect',
action: "disconnect",
actor: ctx.session.user.username,
target: target.username,
})
});
return actionOk()
return actionOk();
},
)
);
// ── Alert User (in-game message) ────────────────────────────────────
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500),
})
});
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message)
if (!success) throw new ActionError('Failed to send alert. Is the emulator running?')
return actionOk()
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success) throw new ActionError("Failed to send alert. Is the emulator running?");
return actionOk();
},
)
);
// ── Mute User ───────────────────────────────────────────────────────
const muteSchema = z.object({
userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).default(0),
})
});
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration)
if (!success) throw new ActionError('Failed to mute. Is the emulator running?')
export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
if (!success) throw new ActionError("Failed to mute. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: 'user_mute',
target: 'User',
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
})
logAudit({
userId: ctx.session.user.id,
action: "user_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
});
return actionOk()
},
)
return actionOk();
});
// ── Unmute User ─────────────────────────────────────────────────────
const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(),
})
});
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.unmuteUser(ctx.data.userId)
if (!success) throw new ActionError('Failed to unmute. Is the emulator running?')
export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.unmuteUser(ctx.data.userId);
if (!success) throw new ActionError("Failed to unmute. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: 'user_unmute',
target: 'User',
targetId: ctx.data.userId,
})
logAudit({
userId: ctx.session.user.id,
action: "user_unmute",
target: "User",
targetId: ctx.data.userId,
});
return actionOk()
},
)
return actionOk();
});
// ── Send Credits via RCON ───────────────────────────────────────────
const sendCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().min(1).max(1000000),
})
});
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount)
if (!success) throw new ActionError('Failed to send credits. Is the emulator running?')
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success) throw new ActionError("Failed to send credits. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: 'user_send_credits',
target: 'User',
action: "user_send_credits",
target: "User",
targetId: ctx.data.userId,
after: { amount: ctx.data.amount },
})
});
return actionOk()
return actionOk();
},
)
);
+19 -19
View File
@@ -1,16 +1,16 @@
'use server'
"use server";
import { revalidateTag } from 'next/cache'
import { z } from 'zod'
import { PERMS } from '@/lib/permission-slugs'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
import { revalidateTag } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(),
})
});
/**
* Toggle a watch entry for the calling staff on the given target user.
@@ -21,23 +21,23 @@ const toggleWatchSchema = z.object({
export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => {
const staffId = ctx.session.user.id
const { targetUserId, reason } = ctx.data
const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data;
const existing = await prisma.userWatch.findUnique({
where: { staffId_targetUserId: { staffId, targetUserId } },
})
});
if (existing) {
await prisma.userWatch.delete({ where: { id: existing.id } })
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: false })
await prisma.userWatch.delete({ where: { id: existing.id } });
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: false });
}
await prisma.userWatch.create({
data: { staffId, targetUserId, reason: reason ?? '' },
})
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: true })
data: { staffId, targetUserId, reason: reason ?? "" },
});
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true });
},
)
);
+42 -41
View File
@@ -1,17 +1,17 @@
import { MessageSquare, Shield, Terminal, Users } from 'lucide-react'
import { redirect } from 'next/navigation'
import { StatsCard } from '@/components/admin/stats-card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
async function getActivityData() {
const now = Math.floor(Date.now() / 1000)
const todayStart = now - (now % 86400)
const weekAgo = now - 7 * 86400
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
const rangeStart = todayStart - 6 * 86400
const rangeStart = todayStart - 6 * 86400;
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
@@ -25,57 +25,61 @@ async function getActivityData() {
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
])
]);
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]))
const chatMap = toMap(chatRows)
const cmdMap = toMap(cmdRows)
const banMap = toMap(banRows)
const regMap = toMap(regRows)
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
const chatMap = toMap(chatRows);
const cmdMap = toMap(cmdRows);
const banMap = toMap(banRows);
const regMap = toMap(regRows);
const dailyData = []
const dailyData = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400
const bucket = Math.floor(dayStart / 86400)
const date = new Date(dayStart * 1000)
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
dailyData.push({
label: date.toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric" }),
chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0,
registrations: regMap.get(bucket) ?? 0,
})
});
}
// Totals
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
])
const weekChats = Number(weekChatRows[0]?.count ?? 0)
const weekCommands = Number(weekCommandRows[0]?.count ?? 0)
]);
const weekChats = Number(weekChatRows[0]?.count ?? 0);
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
// Most active users (by chat messages)
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
SELECT user_from_id AS userId, COUNT(*) AS messages
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
`
`;
const userIds = topChatters.map((c) => c.userId)
const userIds = topChatters.map((c) => c.userId);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
const userMap = new Map(users.map((u) => [u.id, u.username]))
});
const userMap = new Map(users.map((u) => [u.id, u.username]));
const topChattersWithNames = topChatters.map((c) => ({
userId: c.userId,
username: userMap.get(c.userId) ?? `User #${c.userId}`,
messages: Number(c.messages),
}))
}));
return {
dailyData,
@@ -84,16 +88,16 @@ async function getActivityData() {
weekBans,
weekRegistrations,
topChatters: topChattersWithNames,
}
};
}
export default async function ActivityPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const data = await getActivityData()
const data = await getActivityData();
return (
<div className="space-y-6">
@@ -174,10 +178,7 @@ export default async function ActivityPage() {
) : (
<div className="divide-y">
{data.topChatters.map((user, i) => (
<div
key={user.userId}
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
>
<div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
{i + 1}
</span>
@@ -193,5 +194,5 @@ export default async function ActivityPage() {
</Card>
</div>
</div>
)
);
}
+39 -44
View File
@@ -1,47 +1,47 @@
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from 'lucide-react'
import dynamic from 'next/dynamic'
import { redirect } from 'next/navigation'
import { StatsCard } from '@/components/admin/stats-card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
const RevenueChart = dynamic(
() => import('@/components/admin/revenue-chart').then((m) => ({ default: m.RevenueChart })),
() => import("@/components/admin/revenue-chart").then((m) => ({ default: m.RevenueChart })),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
)
);
async function getEconomyData() {
const now = Math.floor(Date.now() / 1000)
const todayStart = now - (now % 86400)
const weekAgo = now - 7 * 86400
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
// Total currency in circulation — parallel instead of sequential
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
prisma.user.aggregate({ _sum: { credits: true } }),
prisma.user.aggregate({ _sum: { pixels: true } }),
prisma.user.aggregate({ _sum: { points: true } }),
])
const totalCredits = creditsAgg._sum.credits ?? 0
const totalPixels = pixelsAgg._sum.pixels ?? 0
const totalPoints = pointsAgg._sum.points ?? 0
]);
const totalCredits = creditsAgg._sum.credits ?? 0;
const totalPixels = pixelsAgg._sum.pixels ?? 0;
const totalPoints = pointsAgg._sum.points ?? 0;
// Shop revenue (7 days)
const shopRevenue = await prisma.logsShopPurchases.aggregate({
where: { timestamp: { gte: weekAgo } },
_sum: { costCredits: true, costPoints: true },
_count: true,
})
});
// Marketplace stats
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } })
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } });
const marketplaceSold = await prisma.marketplaceItems.count({
where: { state: 2, soldTimestamp: { gte: weekAgo } },
})
});
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
const rangeStart = todayStart - 6 * 86400
const rangeStart = todayStart - 6 * 86400;
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits,
@@ -49,60 +49,55 @@ async function getEconomyData() {
FROM logs_shop_purchases
WHERE timestamp >= ${rangeStart}
GROUP BY day_bucket ORDER BY day_bucket
`
`;
const revMap = new Map(
revRows.map((r) => [
Number(r.day_bucket),
{ credits: Number(r.credits), points: Number(r.points) },
]),
)
const revenueData = []
revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]),
);
const revenueData = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400
const bucket = Math.floor(dayStart / 86400)
const date = new Date(dayStart * 1000)
const entry = revMap.get(bucket)
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
const entry = revMap.get(bucket);
revenueData.push({
label: date.toLocaleDateString('en', { weekday: 'short' }),
label: date.toLocaleDateString("en", { weekday: "short" }),
credits: entry?.credits ?? 0,
points: entry?.points ?? 0,
})
});
}
// Trades (7 days)
const weekTrades = await prisma.roomTradeLog.count({
where: { timestamp: { gte: weekAgo } },
})
});
return {
totalCredits,
totalPixels,
totalPoints,
shopPurchases: typeof shopRevenue._count === 'number' ? shopRevenue._count : 0,
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
marketplaceActive,
marketplaceSold,
weekTrades,
revenueData,
}
};
}
export default async function EconomyPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const data = await getEconomyData()
const data = await getEconomyData();
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
<p className="text-muted-foreground">
Currency circulation, shop revenue and marketplace activity
</p>
<p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -196,5 +191,5 @@ export default async function EconomyPage() {
</Card>
</div>
</div>
)
);
}
+40 -39
View File
@@ -1,28 +1,27 @@
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from 'lucide-react'
import dynamic from 'next/dynamic'
import { redirect } from 'next/navigation'
import { StatsCard } from '@/components/admin/stats-card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
const DashboardChart = dynamic(
() => import('@/components/admin/dashboard-chart').then((m) => ({ default: m.DashboardChart })),
() => import("@/components/admin/dashboard-chart").then((m) => ({ default: m.DashboardChart })),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
)
);
const PeakHoursHeatmap = dynamic(
() =>
import('@/components/admin/peak-hours-heatmap').then((m) => ({ default: m.PeakHoursHeatmap })),
() => import("@/components/admin/peak-hours-heatmap").then((m) => ({ default: m.PeakHoursHeatmap })),
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
)
);
async function getAnalyticsData() {
const now = Math.floor(Date.now() / 1000)
const todayStart = now - (now % 86400)
const weekAgo = now - 7 * 86400
const monthAgo = now - 30 * 86400
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400;
const [
totalUsers,
@@ -36,15 +35,17 @@ async function getAnalyticsData() {
weekBans,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { online: '1' } }),
prisma.user.count({ where: { online: "1" } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
prisma.rooms.count(),
prisma.rooms.count({ where: { users: { gt: 0 } } }),
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
])
]);
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
@@ -52,17 +53,17 @@ async function getAnalyticsData() {
FROM users
WHERE account_created >= ${monthAgo}
GROUP BY day_bucket ORDER BY day_bucket
`
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]))
const registrationChart = []
`;
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]));
const registrationChart = [];
for (let i = 29; i >= 0; i--) {
const dayStart = todayStart - i * 86400
const bucket = Math.floor(dayStart / 86400)
const date = new Date(dayStart * 1000)
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
registrationChart.push({
label: date.toLocaleDateString('en', { month: 'short', day: 'numeric' }),
label: date.toLocaleDateString("en", { month: "short", day: "numeric" }),
value: regCountMap.get(bucket) ?? 0,
})
});
}
// Peak hours — single GROUP BY query instead of fetching all user rows
@@ -73,13 +74,13 @@ async function getAnalyticsData() {
FROM users
WHERE last_online >= ${weekAgo}
GROUP BY d, h
`
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
let max = 0
`;
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0;
for (const r of peakRows) {
const count = Number(r.c)
grid[r.d][r.h] = count
if (count > max) max = count
const count = Number(r.c);
grid[r.d][r.h] = count;
if (count > max) max = count;
}
return {
@@ -94,16 +95,16 @@ async function getAnalyticsData() {
weekBans,
registrationChart,
peakHours: { grid, max },
}
};
}
export default async function AnalyticsPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const data = await getAnalyticsData()
const data = await getAnalyticsData();
return (
<div className="space-y-6">
@@ -187,5 +188,5 @@ export default async function AnalyticsPage() {
</Card>
</div>
</div>
)
);
}
+5 -1
View File
@@ -39,7 +39,11 @@ export default async function EditArticle({
</p>
</div>
</div>
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<ArticleForm
action={async (fd) => {
"use server";
+5 -1
View File
@@ -21,7 +21,11 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
</p>
</div>
</div>
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<ArticleForm action={createArticle} />
</main>
);
+5 -1
View File
@@ -35,7 +35,11 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
</Link>
</div>
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
+64 -75
View File
@@ -1,64 +1,64 @@
'use client'
"use client";
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { createBanner, deleteBanner, updateBanner } from '@/actions/banners'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DateTimePicker } from '@/components/ui/date-time-picker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useServerAction } from '@/hooks/use-server-action'
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { createBanner, deleteBanner, updateBanner } from "@/actions/banners";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useServerAction } from "@/hooks/use-server-action";
interface Banner {
id: number
title: string
subtitle: string
image: string
link: string
color: string
isActive: number
sortOrder: number
startDate: string | null
endDate: string | null
id: number;
title: string;
subtitle: string;
image: string;
link: string;
color: string;
isActive: number;
sortOrder: number;
startDate: string | null;
endDate: string | null;
}
interface Props {
banners: Banner[]
banners: Banner[];
}
export function BannersManager({ banners }: Props) {
const { run, isPending } = useServerAction()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({
title: '',
subtitle: '',
image: '',
link: '',
color: '',
title: "",
subtitle: "",
image: "",
link: "",
color: "",
isActive: 1,
sortOrder: 0,
startDate: '' as string | null,
endDate: '' as string | null,
})
startDate: "" as string | null,
endDate: "" as string | null,
});
function resetForm() {
setForm({
title: '',
subtitle: '',
image: '',
link: '',
color: '',
title: "",
subtitle: "",
image: "",
link: "",
color: "",
isActive: 1,
sortOrder: 0,
startDate: '',
endDate: '',
})
setEditingId(null)
setShowForm(false)
startDate: "",
endDate: "",
});
setEditingId(null);
setShowForm(false);
}
function handleEdit(b: Banner) {
@@ -72,20 +72,20 @@ export function BannersManager({ banners }: Props) {
sortOrder: b.sortOrder,
startDate: b.startDate,
endDate: b.endDate,
})
setEditingId(b.id)
setShowForm(true)
});
setEditingId(b.id);
setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null }
e.preventDefault();
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null };
if (editingId) {
run(() => updateBanner({ id: editingId, ...data }), { successMessage: 'Banner updated!' })
run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" });
} else {
run(() => createBanner(data), { successMessage: 'Banner created!' })
run(() => createBanner(data), { successMessage: "Banner created!" });
}
resetForm()
resetForm();
}
return (
@@ -93,8 +93,8 @@ export function BannersManager({ banners }: Props) {
<div className="flex justify-end">
<Button
onClick={() => {
resetForm()
setShowForm(!showForm)
resetForm();
setShowForm(!showForm);
}}
>
<Plus className="h-4 w-4 mr-2" /> New Banner
@@ -104,7 +104,7 @@ export function BannersManager({ banners }: Props) {
{showForm && (
<Card>
<CardHeader>
<CardTitle>{editingId ? 'Edit Banner' : 'New Banner'}</CardTitle>
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -136,10 +136,7 @@ export function BannersManager({ banners }: Props) {
</div>
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={form.link}
onChange={(e) => setForm({ ...form, link: e.target.value })}
/>
<Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -147,7 +144,7 @@ export function BannersManager({ banners }: Props) {
<Label>Color</Label>
<Input
type="color"
value={form.color || '#000000'}
value={form.color || "#000000"}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
</div>
@@ -191,7 +188,7 @@ export function BannersManager({ banners }: Props) {
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{editingId ? 'Update' : 'Create'}
{editingId ? "Update" : "Create"}
</Button>
</div>
</form>
@@ -206,10 +203,7 @@ export function BannersManager({ banners }: Props) {
{banners.map((b) => (
<Card key={b.id} className="overflow-hidden">
{b.image && (
<div
className="h-32 bg-cover bg-center"
style={{ backgroundImage: `url(${b.image})` }}
/>
<div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} />
)}
<CardContent className="p-4">
<div className="flex items-start justify-between">
@@ -217,19 +211,14 @@ export function BannersManager({ banners }: Props) {
<h3 className="font-medium">{b.title}</h3>
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
<div className="flex gap-1 mt-2">
<Badge variant={b.isActive ? 'default' : 'outline'}>
{b.isActive ? 'Active' : 'Inactive'}
<Badge variant={b.isActive ? "default" : "outline"}>
{b.isActive ? "Active" : "Inactive"}
</Badge>
{b.startDate && <Badge variant="secondary">Scheduled</Badge>}
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(b)}
>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
@@ -237,7 +226,7 @@ export function BannersManager({ banners }: Props) {
size="icon"
className="h-7 w-7"
onClick={() =>
run(() => deleteBanner({ id: b.id }), { successMessage: 'Banner deleted' })
run(() => deleteBanner({ id: b.id }), { successMessage: "Banner deleted" })
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
@@ -249,5 +238,5 @@ export function BannersManager({ banners }: Props) {
))}
</div>
</div>
)
);
}
+9 -9
View File
@@ -1,17 +1,17 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { BannersManager } from './banners-manager'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { BannersManager } from "./banners-manager";
export default async function BannersPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const banners = await prisma.websiteBanner.findMany({
orderBy: { sortOrder: 'asc' },
})
orderBy: { sortOrder: "asc" },
});
return (
<div className="space-y-6">
@@ -21,5 +21,5 @@ export default async function BannersPage() {
</div>
<BannersManager banners={banners} />
</div>
)
);
}
@@ -1,54 +1,54 @@
'use client'
"use client";
import { Languages, Package, Settings } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { CatalogItemData } from './catalog-items-table'
import { CatalogItemsTable } from './catalog-items-table'
import { CatalogPageForm } from './catalog-page-form'
import { CatalogTranslateTab } from './catalog-translate-tab'
import { Languages, Package, Settings } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { CatalogItemData } from "./catalog-items-table";
import { CatalogItemsTable } from "./catalog-items-table";
import { CatalogPageForm } from "./catalog-page-form";
import { CatalogTranslateTab } from "./catalog-translate-tab";
interface CatalogPageData {
id: number
caption: string
parentId: number
pageLayout: string
enabled: string
visible: string
minRank: number
clubOnly: string
orderNum: number
iconImage: number
iconColor: number
pageHeadline: string
pageTeaser: string
pageSpecial: string | null
pageText1: string | null
pageText2: string | null
pageTextDetails: string | null
pageTextTeaser: string | null
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
}
interface BaseItemData {
id: number
publicName: string
itemName: string
spriteId: number
type: string
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogDetailTabsProps {
catalogPage: CatalogPageData
items: CatalogItemData[]
baseItems: BaseItemData[]
catalogNameMap: Record<number, string>
childPages: { id: number; caption: string; enabled: string }[]
pageId: number
canEdit: boolean
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap: Record<number, { classname: string; revision: number }>
allPages: { id: number; caption: string }[]
interactionTypes: string[]
catalogPage: CatalogPageData;
items: CatalogItemData[];
baseItems: BaseItemData[];
catalogNameMap: Record<number, string>;
childPages: { id: number; caption: string; enabled: string }[];
pageId: number;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
allPages: { id: number; caption: string }[];
interactionTypes: string[];
}
export function CatalogDetailTabs({
@@ -78,14 +78,15 @@ export function CatalogDetailTabs({
</TabsTrigger>
<TabsTrigger value="translate" className="gap-2">
<Languages className="h-4 w-4" />
Translate ({baseItems.length}){(() => {
const furniSet = new Set(furniDataIdList)
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length
Translate ({baseItems.length})
{(() => {
const furniSet = new Set(furniDataIdList);
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
return missing > 0 ? (
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
{missing}
</span>
) : null
) : null;
})()}
</TabsTrigger>
</TabsList>
@@ -122,5 +123,5 @@ export function CatalogDetailTabs({
/>
</TabsContent>
</Tabs>
)
);
}
@@ -2,6 +2,6 @@
* Re-export barrel for backward compatibility.
* Implementation moved to ./catalog-items-table/ directory.
*/
export { CatalogItemsTable } from './catalog-items-table/catalog-items-table'
export { FurniIcon } from './catalog-items-table/furni-icon'
export type { BaseItemData, CatalogItemData } from './catalog-items-table/types'
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
export { FurniIcon } from "./catalog-items-table/furni-icon";
export type { BaseItemData, CatalogItemData } from "./catalog-items-table/types";
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,8 @@
'use client'
"use client";
import { Check, ChevronsUpDown, Star } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Check, ChevronsUpDown, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
@@ -10,11 +10,11 @@ import {
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
} from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
// ── Toggle field ──────────────────────────────────────────────
@@ -25,21 +25,21 @@ export function ToggleField({
onChange,
disabled,
}: {
label: string
label: string;
/** Boolean checked state */
checked?: boolean
checked?: boolean;
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
value?: number | string
onChange: (v: boolean) => void
disabled?: boolean
value?: number | string;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
const isChecked = checked ?? (value === 1 || value === '1')
const isChecked = checked ?? (value === 1 || value === "1");
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label>
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" />
</div>
)
);
}
// ── Interaction Type Combobox ──────────────────────────────────
@@ -50,19 +50,19 @@ export function InteractionTypeCombobox({
types,
disabled,
}: {
value: string
onChange: (v: string) => void
types: string[]
disabled?: boolean
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const filtered = useMemo(() => {
if (!inputValue.trim()) return types.slice(0, 50)
const q = inputValue.toLowerCase()
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50)
}, [types, inputValue])
if (!inputValue.trim()) return types.slice(0, 50);
const q = inputValue.toLowerCase();
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
}, [types, inputValue]);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -74,7 +74,7 @@ export function InteractionTypeCombobox({
disabled={disabled}
className="w-full justify-between font-normal h-8 text-sm"
>
<span className="truncate">{value || 'Select type...'}</span>
<span className="truncate">{value || "Select type..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -92,15 +92,15 @@ export function InteractionTypeCombobox({
type="button"
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
onClick={() => {
onChange(inputValue.trim())
setOpen(false)
setInputValue('')
onChange(inputValue.trim());
setOpen(false);
setInputValue("");
}}
>
Use &quot;{inputValue.trim()}&quot;
</button>
) : (
'No types found.'
"No types found."
)}
</CommandEmpty>
<CommandGroup>
@@ -109,14 +109,12 @@ export function InteractionTypeCombobox({
key={t}
value={t}
onSelect={() => {
onChange(t)
setOpen(false)
setInputValue('')
onChange(t);
setOpen(false);
setInputValue("");
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === t ? 'opacity-100' : 'opacity-0')}
/>
<Check className={cn("mr-2 h-4 w-4", value === t ? "opacity-100" : "opacity-0")} />
<span className="truncate">{t}</span>
</CommandItem>
))}
@@ -125,7 +123,7 @@ export function InteractionTypeCombobox({
</Command>
</PopoverContent>
</Popover>
)
);
}
// ── Move Page Combobox with smart suggestions ─────────────────
@@ -137,35 +135,32 @@ export function MovePageCombobox({
currentPageId,
suggestedPageIds,
}: {
value: number | null
onChange: (id: number | null) => void
pages: { id: number; caption: string }[]
currentPageId: number
value: number | null;
onChange: (id: number | null) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[]
suggestedPageIds?: number[];
}) {
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(false);
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId])
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId]);
const selectedPage = value ? pages.find((p) => p.id === value) : null
const selectedPage = value ? pages.find((p) => p.id === value) : null;
// Build suggested pages list
const suggested = useMemo(() => {
if (!suggestedPageIds?.length) return []
const pageMap = new Map(options.map((p) => [p.id, p]))
if (!suggestedPageIds?.length) return [];
const pageMap = new Map(options.map((p) => [p.id, p]));
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
id: number
caption: string
}[]
}, [suggestedPageIds, options])
id: number;
caption: string;
}[];
}, [suggestedPageIds, options]);
// Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested])
const remaining = useMemo(
() => options.filter((p) => !suggestedSet.has(p.id)),
[options, suggestedSet],
)
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]);
const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -178,9 +173,7 @@ export function MovePageCombobox({
>
{selectedPage ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{selectedPage.id}
</span>
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{selectedPage.id}</span>
{selectedPage.caption}
</span>
) : (
@@ -203,20 +196,13 @@ export function MovePageCombobox({
key={`s-${page.id}`}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id)
setOpen(false)
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === page.id ? 'opacity-100' : 'opacity-0',
)}
/>
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
@@ -224,19 +210,17 @@ export function MovePageCombobox({
)}
{/* All pages */}
<CommandGroup heading={suggested.length > 0 ? 'All pages' : undefined}>
<CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}>
{remaining.map((page) => (
<CommandItem
key={page.id}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id)
setOpen(false)
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === page.id ? 'opacity-100' : 'opacity-0')}
/>
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
@@ -246,5 +230,5 @@ export function MovePageCombobox({
</Command>
</PopoverContent>
</Popover>
)
);
}
@@ -1,7 +1,7 @@
'use client'
"use client";
import { Image as ImageIcon } from 'lucide-react'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
import { Image as ImageIcon } from "lucide-react";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) {
if (!classname) {
@@ -9,34 +9,34 @@ export function FurniIcon({ classname, revision }: { classname: string; revision
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)
);
}
const localUrl = getLocalIconUrl(classname)
const localUrl = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement
const stage = img.dataset.fallback
const img = e.target as HTMLImageElement;
const stage = img.dataset.fallback;
if (!stage && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.dataset.fallback = 'none'
img.style.opacity = '0'
img.dataset.fallback = "none";
img.style.opacity = "0";
}
}
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
{ }
<img
src={localUrl}
alt={classname}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: 'pixelated' }}
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
)
);
}
@@ -1,4 +1,4 @@
export { CatalogItemsTable } from './catalog-items-table'
export { FurniIcon } from './furni-icon'
export type { BaseItemData, CatalogItemData, InlineEdit } from './types'
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from './types'
export { CatalogItemsTable } from "./catalog-items-table";
export { FurniIcon } from "./furni-icon";
export type { BaseItemData, CatalogItemData, InlineEdit } from "./types";
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from "./types";
@@ -1,59 +1,53 @@
'use client'
"use client";
import { Loader2, Music } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Loader2, Music } from "lucide-react";
import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
getCachedSongPickerOptions,
loadSongPickerOptions,
type SoundtrackOption,
} from '@/lib/client-cache/song-picker-cache'
} from "@/lib/client-cache/song-picker-cache";
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
// callsites keep working. New code should import from the lib module directly.
export { invalidateSongPickerCache } from '@/lib/client-cache/song-picker-cache'
export { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
interface Props {
value: number
onChange: (id: number) => void
disabled?: boolean
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}
function formatLength(seconds: number): string {
if (!seconds || seconds <= 0) return ''
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, '0')}`
if (!seconds || seconds <= 0) return "";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function SongPicker({ value, onChange, disabled }: Props) {
const initial = getCachedSongPickerOptions()
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? [])
const [loading, setLoading] = useState(!initial)
const initial = getCachedSongPickerOptions();
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
const [loading, setLoading] = useState(!initial);
useEffect(() => {
if (getCachedSongPickerOptions()) return
let cancelled = false
if (getCachedSongPickerOptions()) return;
let cancelled = false;
loadSongPickerOptions()
.then((items) => {
if (!cancelled) setOptions(items)
if (!cancelled) setOptions(items);
})
.catch(() => {
if (!cancelled) setOptions([])
if (!cancelled) setOptions([]);
})
.finally(() => {
if (!cancelled) setLoading(false)
})
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true
}
}, [])
cancelled = true;
};
}, []);
return (
<Select
@@ -81,14 +75,12 @@ export function SongPicker({ value, onChange, disabled }: Props) {
<span className="font-medium">{opt.name}</span>
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>}
{opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono">
{formatLength(opt.length)}
</span>
<span className="text-xs text-muted-foreground font-mono">{formatLength(opt.length)}</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
);
}
@@ -1,71 +1,71 @@
export interface BaseItemData {
id: number
spriteId: number
publicName: string
itemName: string
type: string
width: number
length: number
stackHeight: number
allowStack: number
allowSit: number
allowLay: number
allowWalk: number
allowGift: number
allowTrade: number
allowRecycle: number
allowMarketplaceSell: number
allowInventoryStack: number
interactionType: string
interactionModesCount: number
vendingIds: string
customparams: string
effectIdMale: number
effectIdFemale: number
clothingOnWalk: string
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
}
export interface CatalogItemData {
id: number
catalogName: string
itemIds: string
costCredits: number
costPoints: number
pointsType: number
amount: number
limitedSells: number
limitedStack: number
orderNumber: number
offerId: number
songId: number
haveOffer: string
clubOnly: string
extradata: string
baseName: string
baseItemName: string
spriteId: number
baseItem: BaseItemData | null
id: number;
catalogName: string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
orderNumber: number;
offerId: number;
songId: number;
haveOffer: string;
clubOnly: string;
extradata: string;
baseName: string;
baseItemName: string;
spriteId: number;
baseItem: BaseItemData | null;
}
export interface InlineEdit {
costCredits: number
costPoints: number
pointsType: number
amount: number
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
}
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
0: { label: 'Duckets', color: 'text-[var(--admin-info)]' },
5: { label: 'Diamonds', color: 'text-[var(--admin-accent-text)]' },
101: { label: 'Snowflakes', color: 'text-[var(--admin-info)]' },
103: { label: 'Hearts', color: 'text-[var(--admin-error)]' },
104: { label: 'Stars', color: 'text-[var(--admin-warning)]' },
105: { label: 'Shells', color: 'text-[var(--admin-warning)]' },
}
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
};
export const DEFAULT_NEW_ITEM = {
catalogName: '',
itemIds: '0',
catalogName: "",
itemIds: "0",
costCredits: 0,
costPoints: 0,
pointsType: 0,
@@ -75,11 +75,11 @@ export const DEFAULT_NEW_ITEM = {
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
}
haveOffer: "1",
clubOnly: "0",
extradata: "",
};
export function getPointsLabel(type: number) {
return POINTS_TYPES[type] || { label: `Type ${type}`, color: 'theme-text-muted' }
return POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" };
}
+149 -170
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
Check,
@@ -11,17 +11,17 @@ import {
Save,
Settings,
Trash2,
} from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { deleteCatalogPage, updateCatalogPage } from '@/actions/catalog'
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
import { IconPicker } from '@/components/admin/catalog/icon-picker'
import { ImagePreview } from '@/components/admin/catalog-manager/image-preview'
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
@@ -29,120 +29,109 @@ import {
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { useServerAction } from '@/hooks/use-server-action'
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
import { cn } from '@/lib/utils'
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
interface CatalogPageData {
id: number
caption: string
parentId: number
pageLayout: string
enabled: string
visible: string
minRank: number
clubOnly: string
orderNum: number
iconImage: number
iconColor: number
pageHeadline: string
pageTeaser: string
pageSpecial: string | null
pageText1: string | null
pageText2: string | null
pageTextDetails: string | null
pageTextTeaser: string | null
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
}
interface CatalogPageFormProps {
catalogPage: CatalogPageData
childPages: { id: number; caption: string; enabled: string }[]
canEdit: boolean
allPages: { id: number; caption: string }[]
catalogPage: CatalogPageData;
childPages: { id: number; caption: string; enabled: string }[];
canEdit: boolean;
allPages: { id: number; caption: string }[];
}
export function CatalogPageForm({
catalogPage,
childPages,
canEdit,
allPages,
}: CatalogPageFormProps) {
const { isPending, run } = useServerAction()
const formRef = useRef<HTMLFormElement>(null)
export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }: CatalogPageFormProps) {
const { isPending, run } = useServerAction();
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState({
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled as '0' | '1',
visible: catalogPage.visible as '0' | '1',
enabled: catalogPage.enabled as "0" | "1",
visible: catalogPage.visible as "0" | "1",
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly as '0' | '1',
clubOnly: catalogPage.clubOnly as "0" | "1",
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial || '',
pageText1: catalogPage.pageText1 || '',
pageText2: catalogPage.pageText2 || '',
pageTextDetails: catalogPage.pageTextDetails || '',
pageTextTeaser: catalogPage.pageTextTeaser || '',
})
pageSpecial: catalogPage.pageSpecial || "",
pageText1: catalogPage.pageText1 || "",
pageText2: catalogPage.pageText2 || "",
pageTextDetails: catalogPage.pageTextDetails || "",
pageTextTeaser: catalogPage.pageTextTeaser || "",
});
// Ctrl+S / Cmd+S to save
useEffect(() => {
if (!canEdit) return
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
formRef.current?.requestSubmit()
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [canEdit])
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]);
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: 'Page updated and catalog synced.',
errorMessage: 'Failed to update.',
})
successMessage: "Page updated and catalog synced.",
errorMessage: "Failed to update.",
});
}
function handleDelete() {
const childCount = childPages.length
const childCount = childPages.length;
const msg =
childCount > 0
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to root. All items in this page will be deleted.`
: 'Delete this catalog page and all its items?'
if (!confirm(msg)) return
: "Delete this catalog page and all its items?";
if (!confirm(msg)) return;
run(() => deleteCatalogPage({ id: catalogPage.id }), {
successMessage: 'Page deleted.',
redirectTo: '/admin/catalog',
})
successMessage: "Page deleted.",
redirectTo: "/admin/catalog",
});
}
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || 'bg-muted text-muted-foreground'
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
return (
<form ref={formRef} onSubmit={handleSubmit}>
@@ -175,7 +164,7 @@ export function CatalogPageForm({
<Input
id="caption"
value={form.caption}
onChange={(e) => update('caption', e.target.value)}
onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit}
/>
</div>
@@ -183,7 +172,7 @@ export function CatalogPageForm({
<Label>Parent Page</Label>
<ParentCombobox
value={form.parentId}
onChange={(id) => update('parentId', id)}
onChange={(id) => update("parentId", id)}
pages={allPages}
currentPageId={catalogPage.id}
disabled={!canEdit}
@@ -195,11 +184,11 @@ export function CatalogPageForm({
<Label>Layout</Label>
<LayoutSelect
value={form.pageLayout}
onChange={(v) => update('pageLayout', v)}
onChange={(v) => update("pageLayout", v)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? 'Custom layout'}
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? "Custom layout"}
</p>
</div>
@@ -210,7 +199,7 @@ export function CatalogPageForm({
id="orderNum"
type="number"
value={form.orderNum}
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
disabled={!canEdit}
/>
</div>
@@ -222,7 +211,7 @@ export function CatalogPageForm({
min={1}
max={100}
value={form.minRank}
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
onChange={(e) => update("minRank", parseInt(e.target.value, 10) || 1)}
disabled={!canEdit}
/>
</div>
@@ -235,7 +224,7 @@ export function CatalogPageForm({
<Label>Headline</Label>
<CatalogImagePicker
value={form.pageHeadline}
onChange={(v) => update('pageHeadline', v)}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
disabled={!canEdit}
@@ -246,7 +235,7 @@ export function CatalogPageForm({
<Label>Teaser</Label>
<CatalogImagePicker
value={form.pageTeaser}
onChange={(v) => update('pageTeaser', v)}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
disabled={!canEdit}
@@ -258,7 +247,7 @@ export function CatalogPageForm({
id="pageText1"
label="Text 1"
value={form.pageText1}
onChange={(v) => update('pageText1', v)}
onChange={(v) => update("pageText1", v)}
disabled={!canEdit}
rows={4}
max={5000}
@@ -267,7 +256,7 @@ export function CatalogPageForm({
id="pageText2"
label="Text 2"
value={form.pageText2}
onChange={(v) => update('pageText2', v)}
onChange={(v) => update("pageText2", v)}
disabled={!canEdit}
rows={4}
max={5000}
@@ -278,7 +267,7 @@ export function CatalogPageForm({
id="pageTextDetails"
label="Details"
value={form.pageTextDetails}
onChange={(v) => update('pageTextDetails', v)}
onChange={(v) => update("pageTextDetails", v)}
disabled={!canEdit}
rows={3}
max={5000}
@@ -287,7 +276,7 @@ export function CatalogPageForm({
id="pageTextTeaser"
label="Text Teaser"
value={form.pageTextTeaser}
onChange={(v) => update('pageTextTeaser', v)}
onChange={(v) => update("pageTextTeaser", v)}
disabled={!canEdit}
rows={3}
max={5000}
@@ -297,7 +286,7 @@ export function CatalogPageForm({
id="pageSpecial"
label="Special"
value={form.pageSpecial}
onChange={(v) => update('pageSpecial', v)}
onChange={(v) => update("pageSpecial", v)}
disabled={!canEdit}
rows={2}
max={2048}
@@ -311,7 +300,7 @@ export function CatalogPageForm({
<div className="flex items-center gap-3">
<IconPicker
value={form.iconImage}
onChange={(id) => update('iconImage', id)}
onChange={(id) => update("iconImage", id)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
@@ -328,12 +317,11 @@ export function CatalogPageForm({
min={0}
className="w-32"
value={form.iconColor}
onChange={(e) => update('iconColor', parseInt(e.target.value, 10) || 0)}
onChange={(e) => update("iconColor", parseInt(e.target.value, 10) || 0)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
Legacy tint value. Leave at default unless you need to match a specific
theme.
Legacy tint value. Leave at default unless you need to match a specific theme.
</p>
</div>
</div>
@@ -361,12 +349,10 @@ export function CatalogPageForm({
>
<span>
{child.caption}
<span className="ml-2 text-muted-foreground font-mono text-xs">
#{child.id}
</span>
<span className="ml-2 text-muted-foreground font-mono text-xs">#{child.id}</span>
</span>
<Badge variant={child.enabled === '1' ? 'default' : 'outline'}>
{child.enabled === '1' ? 'Enabled' : 'Disabled'}
<Badge variant={child.enabled === "1" ? "default" : "outline"}>
{child.enabled === "1" ? "Enabled" : "Disabled"}
</Badge>
</Link>
))}
@@ -389,13 +375,8 @@ export function CatalogPageForm({
<CatalogIcon iconImage={form.iconImage} size={36} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{form.caption || 'Untitled'}</p>
<Badge
className={cn(
'mt-1 h-5 px-1.5 text-[10px] font-mono border-0',
layoutColorClass,
)}
>
<p className="truncate text-sm font-medium">{form.caption || "Untitled"}</p>
<Badge className={cn("mt-1 h-5 px-1.5 text-[10px] font-mono border-0", layoutColorClass)}>
{form.pageLayout}
</Badge>
</div>
@@ -407,24 +388,24 @@ export function CatalogPageForm({
id="tg-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === '1'}
onChange={(v) => update('enabled', v ? '1' : '0')}
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-visible"
label="Visible"
description="Shown in navigation"
checked={form.visible === '1'}
onChange={(v) => update('visible', v ? '1' : '0')}
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === '1'}
onChange={(v) => update('clubOnly', v ? '1' : '0')}
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
disabled={!canEdit}
/>
</div>
@@ -471,7 +452,7 @@ export function CatalogPageForm({
</div>
</div>
</form>
)
);
}
// ── Toggle row ─────────────────────────────────────────────────
@@ -484,12 +465,12 @@ function ToggleRow({
onChange,
disabled,
}: {
id: string
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3">
@@ -501,7 +482,7 @@ function ToggleRow({
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
</div>
)
);
}
// ── Text field with counter ────────────────────────────────────
@@ -514,12 +495,12 @@ function _TextField({
disabled,
max,
}: {
id: string
label: string
value: string
onChange: (v: string) => void
disabled?: boolean
max?: number
id: string;
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
max?: number;
}) {
return (
<div className="space-y-2">
@@ -539,7 +520,7 @@ function _TextField({
maxLength={max}
/>
</div>
)
);
}
function TextareaField({
@@ -551,13 +532,13 @@ function TextareaField({
rows = 3,
max,
}: {
id: string
label: string
value: string
onChange: (v: string) => void
disabled?: boolean
rows?: number
max?: number
id: string;
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
rows?: number;
max?: number;
}) {
return (
<div className="space-y-2">
@@ -578,7 +559,7 @@ function TextareaField({
maxLength={max}
/>
</div>
)
);
}
// ── Layout select with color swatch ────────────────────────────
@@ -588,9 +569,9 @@ function LayoutSelect({
onChange,
disabled,
}: {
value: string
onChange: (v: string) => void
disabled?: boolean
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
@@ -599,19 +580,19 @@ function LayoutSelect({
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((layout) => {
const colorClass = LAYOUT_COLORS[layout] || 'bg-muted text-muted-foreground'
const colorClass = LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
return (
<SelectItem key={layout} value={layout}>
<span className="flex items-center gap-2">
<span className={cn('inline-block h-2 w-2 rounded-full', colorClass)} />
<span className={cn("inline-block h-2 w-2 rounded-full", colorClass)} />
{layout}
</span>
</SelectItem>
)
);
})}
</SelectContent>
</Select>
)
);
}
// ── Parent page combobox with search ────────────────────────────
@@ -623,16 +604,16 @@ function ParentCombobox({
currentPageId,
disabled,
}: {
value: number
onChange: (id: number) => void
pages: { id: number; caption: string }[]
currentPageId: number
disabled?: boolean
value: number;
onChange: (id: number) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(false);
const options = pages.filter((p) => p.id !== currentPageId)
const selected = value === -1 ? { id: -1, caption: 'Root' } : pages.find((p) => p.id === value)
const options = pages.filter((p) => p.id !== currentPageId);
const selected = value === -1 ? { id: -1, caption: "Root" } : pages.find((p) => p.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -648,7 +629,7 @@ function ParentCombobox({
{selected ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
{selected.id === -1 ? '—' : `#${selected.id}`}
{selected.id === -1 ? "—" : `#${selected.id}`}
</span>
{selected.caption}
</span>
@@ -667,11 +648,11 @@ function ParentCombobox({
<CommandItem
value="root"
onSelect={() => {
onChange(-1)
setOpen(false)
onChange(-1);
setOpen(false);
}}
>
<Check className={cn('mr-2 h-4 w-4', value === -1 ? 'opacity-100' : 'opacity-0')} />
<Check className={cn("mr-2 h-4 w-4", value === -1 ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground font-mono text-xs mr-1.5">—</span>
Root
</CommandItem>
@@ -680,13 +661,11 @@ function ParentCombobox({
key={page.id}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id)
setOpen(false)
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === page.id ? 'opacity-100' : 'opacity-0')}
/>
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
@@ -696,5 +675,5 @@ function ParentCombobox({
</Command>
</PopoverContent>
</Popover>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
AlertTriangle,
@@ -11,46 +11,39 @@ import {
Save,
ShoppingCart,
Sparkles,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { translateCatalogItems } from '@/actions/catalog-items'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useServerAction } from '@/hooks/use-server-action'
import { FurniIcon } from './catalog-items-table'
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { translateCatalogItems } from "@/actions/catalog-items";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useServerAction } from "@/hooks/use-server-action";
import { FurniIcon } from "./catalog-items-table";
interface BaseItemForTranslation {
id: number
publicName: string
itemName: string
spriteId: number
type: string
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogTranslateTabProps {
baseItems: BaseItemForTranslation[]
catalogNameMap: Record<number, string>
canEdit: boolean
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap?: Record<number, { classname: string; revision: number }>
baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string>;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
}
type FilterMode = 'all' | 'missing' | 'modified'
type FilterMode = "all" | "missing" | "modified";
export function CatalogTranslateTab({
baseItems,
@@ -60,164 +53,164 @@ export function CatalogTranslateTab({
furniDescriptionMap,
furniRevisionMap = {},
}: CatalogTranslateTabProps) {
const router = useRouter()
const { run, isPending } = useServerAction()
const [isSuggesting, setIsSuggesting] = useState(false)
const [filterMode, setFilterMode] = useState<FilterMode>('all')
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList])
const [selected, setSelected] = useState<Set<number>>(new Set())
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]);
const [selected, setSelected] = useState<Set<number>>(new Set());
// ── Editable state: names + descriptions ─────────────────────────
const [editedNames, setEditedNames] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
)
);
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])),
)
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
);
// Track which items have name or description changes
const isNameChanged = useCallback(
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
[editedNames],
)
);
const isDescChanged = useCallback(
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ''),
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap],
)
);
const changedItems = useMemo(
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged],
)
);
const missingItems = useMemo(
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
[baseItems, furniDataIdSet],
)
);
const filteredItems = useMemo(() => {
switch (filterMode) {
case 'missing':
return baseItems.filter((item) => !furniDataIdSet.has(item.id))
case 'modified':
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item))
case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case "modified":
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item));
default:
return baseItems
return baseItems;
}
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged])
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)),
)
}, [filteredItems])
);
}, [filteredItems]);
// Items to act on: selected ones if any, otherwise all
const targetItems = useMemo(() => {
if (selected.size === 0) return baseItems
return baseItems.filter((i) => selected.has(i.id))
}, [baseItems, selected])
if (selected.size === 0) return baseItems;
return baseItems.filter((i) => selected.has(i.id));
}, [baseItems, selected]);
function handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value }))
setEditedNames((prev) => ({ ...prev, [id]: value }));
}
function handleDescChange(id: number, value: string) {
setEditedDescs((prev) => ({ ...prev, [id]: value }))
setEditedDescs((prev) => ({ ...prev, [id]: value }));
}
function handleReset() {
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])))
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])));
setEditedDescs(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])),
)
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
);
}
async function handleSuggestFromHabboFurni() {
if (targetItems.length === 0) return
setIsSuggesting(true)
if (targetItems.length === 0) return;
setIsSuggesting(true);
try {
// Collect classnames from selected items (or all if none selected)
const classnames = targetItems.map((item) => item.itemName).filter(Boolean)
const classnames = targetItems.map((item) => item.itemName).filter(Boolean);
if (classnames.length === 0) {
toast.info('No classnames found to look up.')
setIsSuggesting(false)
return
toast.info("No classnames found to look up.");
setIsSuggesting(false);
return;
}
// Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50
const allSuggestions: Record<string, { name: string; description: string }> = {}
const CHUNK_SIZE = 50;
const allSuggestions: Record<string, { name: string; description: string }> = {};
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
const chunk = classnames.slice(i, i + CHUNK_SIZE)
const chunk = classnames.slice(i, i + CHUNK_SIZE);
const res = await fetch(
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(','))}`,
)
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
);
if (res.ok) {
const data = await res.json()
Object.assign(allSuggestions, data.suggestions || {})
const data = await res.json();
Object.assign(allSuggestions, data.suggestions || {});
}
}
// Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0
let descCount = 0
const newNames = { ...editedNames }
const newDescs = { ...editedDescs }
let nameCount = 0;
let descCount = 0;
const newNames = { ...editedNames };
const newDescs = { ...editedDescs };
for (const item of targetItems) {
const suggestion = allSuggestions[item.itemName]
if (!suggestion) continue
const suggestion = allSuggestions[item.itemName];
if (!suggestion) continue;
if (suggestion.name && suggestion.name !== item.publicName) {
newNames[item.id] = suggestion.name
nameCount++
newNames[item.id] = suggestion.name;
nameCount++;
}
const currentDesc = furniDescriptionMap[item.id] ?? ''
const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description
descCount++
newDescs[item.id] = suggestion.description;
descCount++;
}
}
setEditedNames(newNames)
setEditedDescs(newDescs)
setEditedNames(newNames);
setEditedDescs(newDescs);
const total = nameCount + descCount
const total = nameCount + descCount;
if (total > 0) {
const parts = []
if (nameCount > 0) parts.push(`${nameCount} name(s)`)
if (descCount > 0) parts.push(`${descCount} description(s)`)
toast.success(`HabboFurni: ${parts.join(' + ')} suggested from habbo.it. Review and save.`)
const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`);
} else {
toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
)
);
}
} catch (err) {
toast.error(`HabboFurni error: ${(err as Error).message}`)
toast.error(`HabboFurni error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false)
setIsSuggesting(false);
}
}
async function handleSaveAll() {
if (changedItems.length === 0) return
if (changedItems.length === 0) return;
run(
() =>
@@ -229,26 +222,26 @@ export function CatalogTranslateTab({
})),
}),
{
successMessage: 'Translations saved.',
errorMessage: 'Failed to save translations.',
successMessage: "Translations saved.",
errorMessage: "Failed to save translations.",
onSuccess: (data) => {
const d = data as Record<string, number | boolean>
const parts = []
const d = data as Record<string, number | boolean>;
const parts = [];
if (d.namesUpdated && (d.namesUpdated as number) > 0)
parts.push(`${d.namesUpdated} name(s) updated`)
parts.push(`${d.namesUpdated} name(s) updated`);
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`)
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`)
if (d.furniDataUpdated) parts.push('FurnitureData.json synced')
parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`)
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`);
}
parts.push('emulator cache refreshed')
toast.success(`${parts.join(' · ')}.`)
router.refresh()
parts.push("emulator cache refreshed");
toast.success(`${parts.join(" · ")}.`);
router.refresh();
},
},
)
);
}
return (
@@ -274,8 +267,7 @@ export function CatalogTranslateTab({
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{missingItems.length} item(s) missing from FurnitureData.json — will be
auto-inserted on save
{missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save
</span>
</div>
)}
@@ -294,7 +286,7 @@ export function CatalogTranslateTab({
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ''}
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Button
variant="outline"
@@ -305,11 +297,7 @@ export function CatalogTranslateTab({
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
<Button
size="sm"
onClick={handleSaveAll}
disabled={isPending || changedItems.length === 0}
>
<Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -327,28 +315,28 @@ export function CatalogTranslateTab({
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
variant={filterMode === 'all' ? 'default' : 'outline'}
variant={filterMode === "all" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('all')}
onClick={() => setFilterMode("all")}
>
All ({baseItems.length})
</Button>
<Button
variant={filterMode === 'missing' ? 'default' : 'outline'}
variant={filterMode === "missing" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('missing')}
onClick={() => setFilterMode("missing")}
disabled={missingItems.length === 0}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Missing ({missingItems.length})
</Button>
<Button
variant={filterMode === 'modified' ? 'default' : 'outline'}
variant={filterMode === "modified" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('modified')}
onClick={() => setFilterMode("modified")}
disabled={changedItems.length === 0}
>
Modified ({changedItems.length})
@@ -362,9 +350,7 @@ export function CatalogTranslateTab({
No items to translate in this page.
</p>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match the current filter.
</p>
<p className="text-sm text-muted-foreground text-center py-8">No items match the current filter.</p>
) : (
<TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]">
@@ -389,19 +375,19 @@ export function CatalogTranslateTab({
</TableHeader>
<TableBody>
{filteredItems.map((item) => {
const nameChanged = isNameChanged(item)
const descChanged = isDescChanged(item)
const isChanged = nameChanged || descChanged
const isMissing = !furniDataIdSet.has(item.id)
const currentCatalogName = catalogNameMap[item.id]
const nameChanged = isNameChanged(item);
const descChanged = isDescChanged(item);
const isChanged = nameChanged || descChanged;
const isMissing = !furniDataIdSet.has(item.id);
const currentCatalogName = catalogNameMap[item.id];
return (
<TableRow
key={item.id}
className={
isChanged
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]'
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: isMissing
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]'
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: undefined
}
>
@@ -414,24 +400,18 @@ export function CatalogTranslateTab({
</TableCell>
<TableCell className="px-1">
{(() => {
const revInfo = furniRevisionMap[item.id]
const revInfo = furniRevisionMap[item.id];
return revInfo ? (
<FurniIcon
classname={revInfo.classname}
revision={revInfo.revision}
/>
<FurniIcon classname={revInfo.classname} revision={revInfo.revision} />
) : (
<FurniIcon classname={item.itemName} revision={0} />
)
);
})()}
</TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>
<Badge
variant={item.type === 's' ? 'default' : 'secondary'}
className="text-xs"
>
{item.type === 's' ? 'Floor' : item.type === 'i' ? 'Wall' : item.type}
<Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs">
{item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
@@ -439,10 +419,10 @@ export function CatalogTranslateTab({
</TableCell>
<TableCell>
<Input
value={editedNames[item.id] || ''}
value={editedNames[item.id] || ""}
onChange={(e) => handleNameChange(item.id, e.target.value)}
disabled={!canEdit}
className={`h-8 text-sm ${nameChanged ? 'border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]' : ''}`}
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
{currentCatalogName && currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
@@ -452,11 +432,11 @@ export function CatalogTranslateTab({
</TableCell>
<TableCell>
<Input
value={editedDescs[item.id] || ''}
value={editedDescs[item.id] || ""}
onChange={(e) => handleDescChange(item.id, e.target.value)}
disabled={!canEdit}
placeholder="No description"
className={`h-8 text-sm ${descChanged ? 'border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]' : ''}`}
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
</TableCell>
<TableCell>
@@ -464,7 +444,7 @@ export function CatalogTranslateTab({
{isChanged ? (
<Badge
variant="secondary"
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
>
Modified
</Badge>
@@ -494,7 +474,7 @@ export function CatalogTranslateTab({
</div>
</TableCell>
</TableRow>
)
);
})}
</TableBody>
</Table>
@@ -503,5 +483,5 @@ export function CatalogTranslateTab({
)}
</CardContent>
</Card>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogDetailLoading() {
return (
@@ -37,5 +37,5 @@ export default function CatalogDetailLoading() {
</div>
</div>
</div>
)
);
}
+25 -30
View File
@@ -1,42 +1,42 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { Fragment } from 'react'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { loadCatalogItemsData } from '@/lib/services/catalog-items-loader'
import { getAncestors } from '@/lib/services/catalog-tree'
import { CatalogDetailTabs } from './catalog-detail-tabs'
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Fragment } from "react";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from "./catalog-detail-tabs";
export default async function CatalogEditPage({
params,
}: {
params: Promise<{ locale: string; id: string }>
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params
const { session, permissions } = await getAdminContext()
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const pageId = parseInt(id, 10)
if (Number.isNaN(pageId)) notFound()
const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound();
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
})
});
if (!catalogPage) notFound()
if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: 'asc' },
orderBy: { orderNum: "asc" },
select: { id: true, caption: true, enabled: true },
}),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
])
]);
const {
items: catalogItems,
@@ -47,24 +47,21 @@ export default async function CatalogEditPage({
furniRevisionMap,
interactionTypes,
allPages,
} = itemsData
} = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
<Link href={'/admin/catalog'} className="hover:text-foreground transition-colors">
<Link href={"/admin/catalog"} className="hover:text-foreground transition-colors">
Catalog
</Link>
{ancestors.map((bc) => (
<Fragment key={bc.id}>
<ChevronRight className="h-3 w-3 flex-shrink-0" />
<Link
href={`/admin/catalog/${bc.id}`}
className="hover:text-foreground transition-colors"
>
<Link href={`/admin/catalog/${bc.id}`} className="hover:text-foreground transition-colors">
{bc.caption}
</Link>
</Fragment>
@@ -77,9 +74,7 @@ export default async function CatalogEditPage({
<div>
<h1 className="text-3xl font-bold tracking-tight">
{catalogPage.caption}
<span className="ml-2 text-base font-normal text-muted-foreground">
#{catalogPage.id}
</span>
<span className="ml-2 text-base font-normal text-muted-foreground">#{catalogPage.id}</span>
</h1>
<p className="text-muted-foreground">
Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items
@@ -121,5 +116,5 @@ export default async function CatalogEditPage({
interactionTypes={interactionTypes}
/>
</div>
)
);
}
@@ -1,31 +1,31 @@
import { notFound, redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { BcPageDetail } from '../bc-manager'
import { notFound, redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({
params: paramsPromise,
}: {
params: Promise<{ locale: string; id: string }>
params: Promise<{ locale: string; id: string }>;
}) {
const { id: idStr } = await paramsPromise
const id = parseInt(idStr, 10)
if (Number.isNaN(id)) notFound()
const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound();
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } })
if (!bcPage) notFound()
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!bcPage) notFound();
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: 'asc' },
})
orderBy: { orderNumber: "asc" },
});
return (
<BcPageDetail
@@ -41,11 +41,11 @@ export default async function BcPageDetailPage({
enabled: bcPage.enabled,
pageHeadline: bcPage.pageHeadline,
pageTeaser: bcPage.pageTeaser,
pageSpecial: bcPage.pageSpecial ?? '',
pageText1: bcPage.pageText1 ?? '',
pageText2: bcPage.pageText2 ?? '',
pageTextDetails: bcPage.pageTextDetails ?? '',
pageTextTeaser: bcPage.pageTextTeaser ?? '',
pageSpecial: bcPage.pageSpecial ?? "",
pageText1: bcPage.pageText1 ?? "",
pageText2: bcPage.pageText2 ?? "",
pageTextDetails: bcPage.pageTextDetails ?? "",
pageTextTeaser: bcPage.pageTextTeaser ?? "",
}}
items={bcItems.map((i) => ({
id: i.id,
@@ -57,5 +57,5 @@ export default async function BcPageDetailPage({
}))}
canEdit={canEdit}
/>
)
);
}
+105 -132
View File
@@ -1,82 +1,69 @@
'use client'
"use client";
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from '@/actions/catalog-bc'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { useServerAction } from '@/hooks/use-server-action'
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from "@/actions/catalog-bc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
// ── Types ────────────────────────────────────────────────────────────
export interface BcPageData {
id: number
parentId: number
caption: string
pageLayout: string
iconColor: number
iconImage: number
orderNum: number
visible: string
enabled: string
pageHeadline: string
pageTeaser: string
pageSpecial: string
pageText1: string
pageText2: string
pageTextDetails: string
pageTextTeaser: string
id: number;
parentId: number;
caption: string;
pageLayout: string;
iconColor: number;
iconImage: number;
orderNum: number;
visible: string;
enabled: string;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string;
pageText1: string;
pageText2: string;
pageTextDetails: string;
pageTextTeaser: string;
}
export interface BcItemData {
id: number
pageId: number
itemIds: string
catalogName: string
orderNumber: number
extradata: string
id: number;
pageId: number;
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}
interface BcPageDetailProps {
page: BcPageData
items: BcItemData[]
canEdit: boolean
page: BcPageData;
items: BcItemData[];
canEdit: boolean;
}
// ── Main Component ───────────────────────────────────────────────────
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
const { isPending, run } = useServerAction()
const formRef = useRef<HTMLFormElement>(null)
const [editingItem, setEditingItem] = useState<BcItemData | null>(null)
const [addingItem, setAddingItem] = useState(false)
const { isPending, run } = useServerAction();
const formRef = useRef<HTMLFormElement>(null);
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
const [addingItem, setAddingItem] = useState(false);
const [form, setForm] = useState({
caption: page.caption,
orderNum: page.orderNum,
enabled: page.enabled as '0' | '1',
visible: page.visible as '0' | '1',
enabled: page.enabled as "0" | "1",
visible: page.visible as "0" | "1",
pageHeadline: page.pageHeadline,
pageTeaser: page.pageTeaser,
pageSpecial: page.pageSpecial,
@@ -84,31 +71,31 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
})
});
// Ctrl+S / Cmd+S to save
useEffect(() => {
if (!canEdit) return
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
formRef.current?.requestSubmit()
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [canEdit])
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]);
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
e.preventDefault();
run(() => updateBcPage({ id: page.id, ...form }), {
successMessage: 'BC page updated and catalog synced.',
errorMessage: 'Failed to update.',
})
successMessage: "BC page updated and catalog synced.",
errorMessage: "Failed to update.",
});
}
return (
@@ -150,7 +137,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Input
id="caption"
value={form.caption}
onChange={(e) => update('caption', e.target.value)}
onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit}
/>
</div>
@@ -160,7 +147,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
id="orderNum"
type="number"
value={form.orderNum}
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
disabled={!canEdit}
/>
</div>
@@ -170,7 +157,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Headline</Label>
<Input
value={form.pageHeadline}
onChange={(e) => update('pageHeadline', e.target.value)}
onChange={(e) => update("pageHeadline", e.target.value)}
disabled={!canEdit}
placeholder="Headline image path"
/>
@@ -179,7 +166,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Teaser</Label>
<Input
value={form.pageTeaser}
onChange={(e) => update('pageTeaser', e.target.value)}
onChange={(e) => update("pageTeaser", e.target.value)}
disabled={!canEdit}
placeholder="Teaser image path"
/>
@@ -190,7 +177,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Text 1</Label>
<Textarea
value={form.pageText1}
onChange={(e) => update('pageText1', e.target.value)}
onChange={(e) => update("pageText1", e.target.value)}
disabled={!canEdit}
rows={3}
/>
@@ -199,7 +186,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Text 2</Label>
<Textarea
value={form.pageText2}
onChange={(e) => update('pageText2', e.target.value)}
onChange={(e) => update("pageText2", e.target.value)}
disabled={!canEdit}
rows={3}
/>
@@ -211,7 +198,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Details</Label>
<Textarea
value={form.pageTextDetails}
onChange={(e) => update('pageTextDetails', e.target.value)}
onChange={(e) => update("pageTextDetails", e.target.value)}
disabled={!canEdit}
rows={3}
/>
@@ -220,7 +207,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Text Teaser</Label>
<Textarea
value={form.pageTextTeaser}
onChange={(e) => update('pageTextTeaser', e.target.value)}
onChange={(e) => update("pageTextTeaser", e.target.value)}
disabled={!canEdit}
rows={3}
/>
@@ -231,7 +218,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Special</Label>
<Textarea
value={form.pageSpecial}
onChange={(e) => update('pageSpecial', e.target.value)}
onChange={(e) => update("pageSpecial", e.target.value)}
disabled={!canEdit}
rows={2}
/>
@@ -263,9 +250,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No items in this page.
</p>
<p className="text-sm text-muted-foreground text-center py-4">No items in this page.</p>
) : (
<Table>
<TableHeader>
@@ -288,7 +273,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
</TableCell>
<TableCell className="text-xs">{item.orderNumber}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
{item.extradata || '\u2014'}
{item.extradata || "\u2014"}
</TableCell>
{canEdit && (
<TableCell>
@@ -308,10 +293,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
size="sm"
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
onClick={() => {
if (!confirm(`Delete item "${item.catalogName}"?`)) return
if (!confirm(`Delete item "${item.catalogName}"?`)) return;
run(() => deleteBcItem({ id: item.id }), {
successMessage: 'Item deleted.',
})
successMessage: "Item deleted.",
});
}}
>
<Trash2 className="h-3 w-3" />
@@ -340,16 +325,16 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
id="tg-enabled"
label="Enabled"
description="Active in the BC catalog"
checked={form.enabled === '1'}
onChange={(v) => update('enabled', v ? '1' : '0')}
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-visible"
label="Visible"
description="Shown in BC navigation"
checked={form.visible === '1'}
onChange={(v) => update('visible', v ? '1' : '0')}
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit}
/>
</div>
@@ -360,10 +345,8 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
Layout: <span className="font-mono">{page.pageLayout}</span>
</span>
<span>
Parent:{' '}
<span className="font-mono">
{page.parentId <= 0 ? 'Root' : `#${page.parentId}`}
</span>
Parent:{" "}
<span className="font-mono">{page.parentId <= 0 ? "Root" : `#${page.parentId}`}</span>
</span>
</div>
@@ -398,9 +381,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
onClose={() => setEditingItem(null)}
onSubmit={(data) => {
run(() => updateBcItem({ id: editingItem.id, ...data }), {
successMessage: 'Item updated.',
successMessage: "Item updated.",
onSuccess: () => setEditingItem(null),
})
});
}}
/>
)}
@@ -411,24 +394,24 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
item={{
id: 0,
pageId: page.id,
itemIds: '',
catalogName: '',
itemIds: "",
catalogName: "",
orderNumber: 1,
extradata: '',
extradata: "",
}}
mode="add"
isPending={isPending}
onClose={() => setAddingItem(false)}
onSubmit={(data) => {
run(() => createBcItem({ pageId: page.id, ...data }), {
successMessage: 'Item created.',
successMessage: "Item created.",
onSuccess: () => setAddingItem(false),
})
});
}}
/>
)}
</div>
)
);
}
// ── Toggle row ───────────────────────────────────────────────────────
@@ -441,12 +424,12 @@ function ToggleRow({
onChange,
disabled,
}: {
id: string
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3">
@@ -458,7 +441,7 @@ function ToggleRow({
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
</div>
)
);
}
// ── Item Dialog ──────────────────────────────────────────────────────
@@ -470,29 +453,24 @@ function ItemDialog({
onClose,
onSubmit,
}: {
item: BcItemData
mode: 'add' | 'edit'
isPending: boolean
onClose: () => void
onSubmit: (data: {
itemIds: string
catalogName: string
orderNumber: number
extradata: string
}) => void
item: BcItemData;
mode: "add" | "edit";
isPending: boolean;
onClose: () => void;
onSubmit: (data: { itemIds: string; catalogName: string; orderNumber: number; extradata: string }) => void;
}) {
const [form, setForm] = useState({
itemIds: item.itemIds,
catalogName: item.catalogName,
orderNumber: item.orderNumber,
extradata: item.extradata,
})
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'add' ? 'Add BC Item' : `Edit Item #${item.id}`}</DialogTitle>
<DialogTitle>{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
@@ -515,9 +493,7 @@ function ItemDialog({
<Input
type="number"
value={form.orderNumber}
onChange={(e) =>
setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))
}
onChange={(e) => setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))}
/>
</div>
<div className="space-y-2">
@@ -534,15 +510,12 @@ function ItemDialog({
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => onSubmit(form)}
disabled={isPending || !form.catalogName || !form.itemIds}
>
<Button onClick={() => onSubmit(form)} disabled={isPending || !form.catalogName || !form.itemIds}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === 'add' ? 'Add Item' : 'Save'}
{mode === "add" ? "Add Item" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
import { redirect } from "next/navigation";
export default function BuilderClubPage() {
redirect('/admin/catalog?catalog=bc')
redirect("/admin/catalog?catalog=bc");
}
+32 -44
View File
@@ -1,69 +1,57 @@
'use client'
"use client";
import { Loader2, Plus } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { createCatalogPage } from '@/actions/catalog'
import { createBcPage } from '@/actions/catalog-bc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
import { CATALOG_LAYOUTS } from '@/lib/catalog-layouts'
import { Loader2, Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
interface CatalogActionsProps {
catalogType?: 'normal' | 'bc'
catalogType?: "normal" | "bc";
}
export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps) {
const { run, isPending } = useServerAction()
const [open, setOpen] = useState(false)
const [caption, setCaption] = useState('')
const [parentId, setParentId] = useState('-1')
const [pageLayout, setPageLayout] = useState('default_3x3')
const isBc = catalogType === 'bc'
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
function handleCreate() {
if (!caption.trim()) {
toast.error('Caption is required')
return
toast.error("Caption is required");
return;
}
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
}
};
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input)
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
run(fn, {
successMessage: `Catalog page "${caption.trim()}" created.`,
onSuccess: (data) => {
setOpen(false)
setCaption('')
setParentId('-1')
setOpen(false);
setCaption("");
setParentId("-1");
if (data.id) {
window.location.href = isBc
? `/admin/catalog/builder-club/${data.id}`
: `/admin/catalog/${data.id}`
: `/admin/catalog/${data.id}`;
}
},
})
});
}
return (
@@ -76,7 +64,7 @@ export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps)
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{isBc ? 'Create Builder Club Page' : 'Create Catalog Page'}</DialogTitle>
<DialogTitle>{isBc ? "Create Builder Club Page" : "Create Catalog Page"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
@@ -124,5 +112,5 @@ export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps)
</div>
</DialogContent>
</Dialog>
)
);
}
+66 -77
View File
@@ -1,57 +1,56 @@
'use client'
"use client";
import { Pencil } from 'lucide-react'
import Link from 'next/link'
import { toggleCatalogPage } from '@/actions/catalog'
import { toggleBcPage } from '@/actions/catalog-bc'
import { DataTable } from '@/components/admin/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useServerAction } from '@/hooks/use-server-action'
import type { DataTableColumn, PaginatedResult } from '@/types'
import { Pencil } from "lucide-react";
import Link from "next/link";
import { toggleCatalogPage } from "@/actions/catalog";
import { toggleBcPage } from "@/actions/catalog-bc";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface CatalogPageRow {
id: number
caption: string
parentId: number
parentCaption: string
pageLayout: string
enabled: string
visible: string
minRank: number
orderNum: number
itemCount: number
childCount: number
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}
interface CatalogTableProps {
data: PaginatedResult<CatalogPageRow>
canEdit: boolean
catalogType?: 'normal' | 'bc'
data: PaginatedResult<CatalogPageRow>;
canEdit: boolean;
catalogType?: "normal" | "bc";
}
export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogTableProps) {
const { run, isPending } = useServerAction()
const isBc = catalogType === 'bc'
export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogTableProps) {
const { run, isPending } = useServerAction();
const isBc = catalogType === "bc";
// Parent URLs stay in the same catalog; root takes you to the catalog index.
const detailHref = (id: number) =>
isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`
const detailHref = (id: number) => (isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`);
function handleToggle(id: number, action: 'toggleEnabled' | 'toggleVisible') {
function handleToggle(id: number, action: "toggleEnabled" | "toggleVisible") {
if (isBc) {
const field = action === 'toggleEnabled' ? 'enabled' : 'visible'
run(() => toggleBcPage({ id, field }), { successMessage: 'Updated.' })
const field = action === "toggleEnabled" ? "enabled" : "visible";
run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
} else {
run(() => toggleCatalogPage({ id, action }), { successMessage: 'Updated.' })
run(() => toggleCatalogPage({ id, action }), { successMessage: "Updated." });
}
}
const columns: DataTableColumn<CatalogPageRow>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: "id", label: "ID", sortable: true },
{
key: 'caption',
label: 'Caption',
key: "caption",
label: "Caption",
sortable: true,
render: (value, row) => (
<Link href={detailHref(row.id)} className="font-medium text-primary hover:underline">
@@ -60,8 +59,8 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
),
},
{
key: 'parentId',
label: 'Parent',
key: "parentId",
label: "Parent",
sortable: true,
render: (_, row) =>
row.parentId === -1 ? (
@@ -73,86 +72,78 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
),
},
{
key: 'pageLayout',
label: 'Layout',
key: "pageLayout",
label: "Layout",
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
},
{
key: 'enabled',
label: 'Enabled',
key: "enabled",
label: "Enabled",
sortable: true,
filterKey: 'filter_enabled',
filterKey: "filter_enabled",
filterOptions: [
{ label: 'Enabled', value: '1' },
{ label: 'Disabled', value: '0' },
{ label: "Enabled", value: "1" },
{ label: "Disabled", value: "0" },
],
render: (value, row) =>
canEdit ? (
<button
type="button"
onClick={() => handleToggle(row.id, 'toggleEnabled')}
onClick={() => handleToggle(row.id, "toggleEnabled")}
disabled={isPending}
className="cursor-pointer disabled:opacity-50"
>
<Badge variant={value === '1' ? 'default' : 'outline'}>
{value === '1' ? 'Yes' : 'No'}
</Badge>
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
</button>
) : (
<Badge variant={value === '1' ? 'default' : 'outline'}>
{value === '1' ? 'Yes' : 'No'}
</Badge>
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
),
},
{
key: 'visible',
label: 'Visible',
key: "visible",
label: "Visible",
sortable: true,
filterKey: 'filter_visible',
filterKey: "filter_visible",
filterOptions: [
{ label: 'Visible', value: '1' },
{ label: 'Hidden', value: '0' },
{ label: "Visible", value: "1" },
{ label: "Hidden", value: "0" },
],
render: (value, row) =>
canEdit ? (
<button
type="button"
onClick={() => handleToggle(row.id, 'toggleVisible')}
onClick={() => handleToggle(row.id, "toggleVisible")}
disabled={isPending}
className="cursor-pointer disabled:opacity-50"
>
<Badge variant={value === '1' ? 'default' : 'outline'}>
{value === '1' ? 'Yes' : 'No'}
</Badge>
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
</button>
) : (
<Badge variant={value === '1' ? 'default' : 'outline'}>
{value === '1' ? 'Yes' : 'No'}
</Badge>
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
),
},
...(isBc
? []
: [
{
key: 'minRank' as const,
label: 'Min Rank',
key: "minRank" as const,
label: "Min Rank",
sortable: true,
} satisfies DataTableColumn<CatalogPageRow>,
]),
{
key: 'itemCount',
label: 'Items',
key: "itemCount",
label: "Items",
render: (value) => <span>{String(value)}</span>,
},
{
key: 'childCount',
label: 'Children',
key: "childCount",
label: "Children",
render: (value) => <span>{String(value)}</span>,
},
{
key: 'actions',
label: 'Actions',
key: "actions",
label: "Actions",
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={detailHref(row.id)}>
@@ -161,7 +152,7 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
</Button>
),
},
]
];
return (
<DataTable
@@ -169,10 +160,8 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit
? [{ label: 'Delete Selected', value: 'delete', variant: 'destructive' }]
: undefined
canEdit ? [{ label: "Delete Selected", value: "delete", variant: "destructive" }] : undefined
}
/>
)
);
}
+30 -30
View File
@@ -1,38 +1,38 @@
'use client'
"use client";
import { FolderTree, HardHat, List, Package } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { FolderTree, HardHat, List, Package } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
currentView: string
catalogType?: string
currentView: string;
catalogType?: string;
}
export function CatalogViewToggle({ currentView, catalogType = 'normal' }: CatalogViewToggleProps) {
const router = useRouter()
const searchParams = useSearchParams()
export function CatalogViewToggle({ currentView, catalogType = "normal" }: CatalogViewToggleProps) {
const router = useRouter();
const searchParams = useSearchParams();
function setView(view: string) {
const params = new URLSearchParams(searchParams.toString())
if (view === 'tree') {
params.delete('view')
const params = new URLSearchParams(searchParams.toString());
if (view === "tree") {
params.delete("view");
} else {
params.set('view', view)
params.set("view", view);
}
router.push(`?${params.toString()}`)
router.push(`?${params.toString()}`);
}
function setCatalog(type: string) {
const params = new URLSearchParams(searchParams.toString())
if (type === 'normal') {
params.delete('catalog')
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set('catalog', type)
params.set("catalog", type);
}
params.delete('view') // reset to tree when switching
router.push(`?${params.toString()}`)
params.delete("view"); // reset to tree when switching
router.push(`?${params.toString()}`);
}
return (
@@ -42,8 +42,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button
variant="ghost"
size="sm"
className={cn('h-8 rounded-r-none', catalogType === 'normal' && 'bg-accent')}
onClick={() => setCatalog('normal')}
className={cn("h-8 rounded-r-none", catalogType === "normal" && "bg-accent")}
onClick={() => setCatalog("normal")}
>
<Package className="mr-1.5 h-4 w-4" />
Catalog
@@ -51,8 +51,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button
variant="ghost"
size="sm"
className={cn('h-8 rounded-l-none', catalogType === 'bc' && 'bg-accent')}
onClick={() => setCatalog('bc')}
className={cn("h-8 rounded-l-none", catalogType === "bc" && "bg-accent")}
onClick={() => setCatalog("bc")}
>
<HardHat className="mr-1.5 h-4 w-4" />
Builder Club
@@ -64,8 +64,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button
variant="ghost"
size="sm"
className={cn('h-8 rounded-r-none', currentView === 'tree' && 'bg-accent')}
onClick={() => setView('tree')}
className={cn("h-8 rounded-r-none", currentView === "tree" && "bg-accent")}
onClick={() => setView("tree")}
>
<FolderTree className="mr-1.5 h-4 w-4" />
Tree
@@ -73,13 +73,13 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button
variant="ghost"
size="sm"
className={cn('h-8 rounded-l-none', currentView === 'table' && 'bg-accent')}
onClick={() => setView('table')}
className={cn("h-8 rounded-l-none", currentView === "table" && "bg-accent")}
onClick={() => setView("table")}
>
<List className="mr-1.5 h-4 w-4" />
Table
</Button>
</div>
</div>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogLoading() {
return (
@@ -40,5 +40,5 @@ export default function CatalogLoading() {
</div>
</div>
</div>
)
);
}
+141 -146
View File
@@ -1,87 +1,87 @@
import dynamic from 'next/dynamic'
import { redirect } from 'next/navigation'
import { CatalogTree } from '@/components/admin/catalog-tree'
import { Prisma } from '@/generated/prisma/client'
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { CatalogActions } from './catalog-actions'
import { CatalogTable } from './catalog-table'
import { CatalogViewToggle } from './catalog-view-toggle'
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { CatalogTree } from "@/components/admin/catalog-tree";
import { Prisma } from "@/generated/prisma/client";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { CatalogActions } from "./catalog-actions";
import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
const CatalogManagerDialog = dynamic(() =>
import('@/components/admin/catalog-manager/catalog-manager-dialog').then((m) => ({
import("@/components/admin/catalog-manager/catalog-manager-dialog").then((m) => ({
default: m.CatalogManagerDialog,
})),
)
);
export default async function CatalogPagesPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise
const { session, permissions } = await getAdminContext()
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const params = await searchParams
const sp = new URLSearchParams(params)
const { search, perPage, page, sort, order } = parseListParams(sp)
const viewMode = sp.get('view') || 'tree'
const catalogType = sp.get('catalog') || 'normal'
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
// ── Builder Club mode ──────────────────────────────────────────
type TreeNodeShape = {
id: number
caption: string
parentId: number
enabled: string
visible: string
iconImage: number
iconColor: number
pageLayout: string
childCount: number
itemCount: number
}
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
};
if (catalogType === 'bc') {
if (catalogType === "bc") {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: '1' } }),
])
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
]);
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = []
if (viewMode === 'tree') {
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: 'asc' },
})
const bcRootIds = bcRootRaw.map((p) => p.id)
orderBy: { orderNum: "asc" },
});
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({
by: ['parentId'],
by: ["parentId"],
where: { parentId: { in: bcRootIds } },
_count: true,
})
: [],
bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ['pageId'],
by: ["pageId"],
where: { pageId: { in: bcRootIds } },
_count: true,
})
: [],
])
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]))
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]))
]);
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
bcRootPages = bcRootRaw.map((p) => ({
id: p.id,
caption: p.caption,
@@ -93,45 +93,45 @@ export default async function CatalogPagesPage({
pageLayout: p.pageLayout,
childCount: bcChildMap[p.id] ?? 0,
itemCount: bcItemMap[p.id] ?? 0,
}))
}));
}
// ── BC table view ─────────────────────────────────────────────
let bcRows: {
id: number
caption: string
parentId: number
parentCaption: string
pageLayout: string
enabled: string
visible: string
minRank: number
orderNum: number
itemCount: number
childCount: number
}[] = []
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 }
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}[] = [];
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === 'table') {
const parentFilter = sp.get('parent')
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {}
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const pattern = { contains: search }
where.OR = [{ caption: pattern }]
const pattern = { contains: search };
where.OR = [{ caption: pattern }];
}
if (hasParent) where.parentId = parentId
if (hasParent) where.parentId = parentId;
// Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = {
parentId: 'parentId',
pageLayout: 'pageLayout',
orderNum: 'orderNum',
}
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'orderNum'
const orderBy = { [sortKey]: sort ? order : 'asc' }
parentId: "parentId",
pageLayout: "pageLayout",
orderNum: "orderNum",
};
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
const orderBy = { [sortKey]: sort ? order : "asc" };
const [pages, total] = await Promise.all([
prisma.catalogPagesBc.findMany({
@@ -141,23 +141,23 @@ export default async function CatalogPagesPage({
take: perPage,
}),
prisma.catalogPagesBc.count({ where }),
])
]);
bcPagination = calcPagination(total, page, perPage)
bcPagination = calcPagination(total, page, perPage);
const pageIds = pages.map((p) => p.id)
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
const pageIds = pages.map((p) => p.id);
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0
? prisma.catalogPagesBc.groupBy({
by: ['parentId'],
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ['pageId'],
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
@@ -168,17 +168,16 @@ export default async function CatalogPagesPage({
select: { id: true, caption: true },
})
: [],
])
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
bcRows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
parentCaption:
p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: String(p.pageLayout),
enabled: String(p.enabled),
visible: String(p.visible),
@@ -186,7 +185,7 @@ export default async function CatalogPagesPage({
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}))
}));
}
return (
@@ -203,17 +202,13 @@ export default async function CatalogPagesPage({
{canEdit && <CatalogActions catalogType="bc" />}
</div>
</div>
{viewMode === 'tree' ? (
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable
data={{ rows: bcRows, ...bcPagination }}
canEdit={canEdit}
catalogType="bc"
/>
<CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
)}
</div>
)
);
}
// ── Normal catalog mode ────────────────────────────────────────
@@ -222,35 +217,35 @@ export default async function CatalogPagesPage({
const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPages.count(),
prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: '1' } }),
])
prisma.catalogPages.count({ where: { enabled: "1" } }),
]);
// ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = []
if (viewMode === 'tree') {
let rootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 },
orderBy: { orderNum: 'asc' },
})
const rootIds = rootPagesRaw.map((p) => p.id)
orderBy: { orderNum: "asc" },
});
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0
? prisma.catalogPages.groupBy({
by: ['parentId'],
by: ["parentId"],
where: { parentId: { in: rootIds } },
_count: true,
})
: [],
rootIds.length > 0
? prisma.catalogItems.groupBy({
by: ['pageId'],
by: ["pageId"],
where: { pageId: { in: rootIds } },
_count: true,
})
: [],
])
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]))
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]))
]);
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
rootPages = rootPagesRaw.map((p) => ({
id: p.id,
caption: p.caption,
@@ -262,34 +257,34 @@ export default async function CatalogPagesPage({
pageLayout: p.pageLayout,
childCount: rootChildMap[p.id] ?? 0,
itemCount: rootItemMap[p.id] ?? 0,
}))
}));
}
// ── Table view data (skip if tree) ────────────────────────────
let rows: {
id: number
caption: string
parentId: number
parentCaption: string
pageLayout: string
enabled: string
visible: string
minRank: number
orderNum: number
itemCount: number
childCount: number
}[] = []
let pagination = { total: 0, page: 1, perPage, lastPage: 0 }
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}[] = [];
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === 'table') {
const parentFilter = sp.get('parent')
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {}
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty
const searchPattern = `%${search}%`;
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
SELECT DISTINCT cp.id FROM catalog_pages cp
@@ -301,13 +296,13 @@ export default async function CatalogPagesPage({
OR ib.public_name LIKE ${searchPattern}
OR ib.item_name LIKE ${searchPattern})
${parentClause}
`)
where.id = { in: matchedIds.map((r) => r.id) }
`);
where.id = { in: matchedIds.map((r) => r.id) };
} else if (hasParent) {
where.parentId = parentId
where.parentId = parentId;
}
const orderBy = sort ? { [sort]: order } : { orderNum: 'asc' as const }
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
const [pages, total] = await Promise.all([
prisma.catalogPages.findMany({
@@ -317,24 +312,24 @@ export default async function CatalogPagesPage({
take: perPage,
}),
prisma.catalogPages.count({ where }),
])
]);
pagination = calcPagination(total, page, perPage)
pagination = calcPagination(total, page, perPage);
// Count children and items per page, and resolve parent names in parallel
const pageIds = pages.map((p) => p.id)
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
const pageIds = pages.map((p) => p.id);
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0
? prisma.catalogPages.groupBy({
by: ['parentId'],
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItems.groupBy({
by: ['pageId'],
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
@@ -345,16 +340,16 @@ export default async function CatalogPagesPage({
select: { id: true, caption: true },
})
: [],
])
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
rows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
parentCaption: p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: p.pageLayout,
enabled: p.enabled,
visible: p.visible,
@@ -362,7 +357,7 @@ export default async function CatalogPagesPage({
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}))
}));
}
return (
@@ -381,11 +376,11 @@ export default async function CatalogPagesPage({
</div>
</div>
{viewMode === 'tree' ? (
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
)
);
}
+1 -1
View File
@@ -616,7 +616,7 @@ export default async function CommandoCentrum() {
{e.version}
</td>
<td>
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
{snippet}
</pre>
</td>
+15 -15
View File
@@ -1,32 +1,32 @@
import { redirect } from 'next/navigation'
import { Card, CardContent } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { redirect } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function EmulatorErrorsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const params = await searchParams
const page = Math.max(1, Number(params.page) || 1)
const perPage = 50
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const perPage = 50;
const [errors, total] = await Promise.all([
prisma.emulatorErrors.findMany({
orderBy: { id: 'desc' },
orderBy: { id: "desc" },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorErrors.count(),
])
]);
const lastPage = Math.ceil(total / perPage)
const lastPage = Math.ceil(total / perPage);
return (
<div className="space-y-6">
@@ -52,7 +52,7 @@ export default async function EmulatorErrorsPage({
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
{Buffer.from(error.stacktrace).toString('utf-8')}
{Buffer.from(error.stacktrace).toString("utf-8")}
</pre>
</div>
))}
@@ -79,5 +79,5 @@ export default async function EmulatorErrorsPage({
</div>
)}
</div>
)
);
}
+22 -32
View File
@@ -1,55 +1,45 @@
'use client'
"use client";
import { RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export function HealthCheckClient() {
const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking')
const [checking, setChecking] = useState(false)
const [status, setStatus] = useState<"checking" | "online" | "offline">("checking");
const [checking, setChecking] = useState(false);
async function checkEmulator() {
setChecking(true)
setStatus('checking')
setChecking(true);
setStatus("checking");
try {
const res = await fetch('/api/admin/devops/health')
const res = await fetch("/api/admin/devops/health");
if (res.ok) {
const data = await res.json()
setStatus(data.emulator ? 'online' : 'offline')
const data = await res.json();
setStatus(data.emulator ? "online" : "offline");
} else {
setStatus('offline')
setStatus("offline");
}
} catch {
setStatus('offline')
setStatus("offline");
} finally {
setChecking(false)
setChecking(false);
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
checkEmulator()
}, [])
checkEmulator();
}, []);
return (
<div className="flex items-center gap-2">
<Badge
variant={
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
}
>
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
<Badge variant={status === "online" ? "default" : status === "offline" ? "destructive" : "secondary"}>
{status === "checking" ? "Checking..." : status === "online" ? "Online" : "Offline"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={checkEmulator}
disabled={checking}
>
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={checkEmulator} disabled={checking}>
<RefreshCw className={`h-3 w-3 ${checking ? "animate-spin" : ""}`} />
</Button>
</div>
)
);
}
+30 -30
View File
@@ -1,61 +1,61 @@
import { AlertTriangle, Database, HeartPulse, Server } from 'lucide-react'
import { redirect } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { HealthCheckClient } from './health-check-client'
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { HealthCheckClient } from "./health-check-client";
async function getDevOpsData() {
const now = Math.floor(Date.now() / 1000)
const dayAgo = now - 86400
const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400;
// DB health — simple count query
let dbStatus: 'healthy' | 'error' = 'healthy'
let dbLatency = 0
let dbStatus: "healthy" | "error" = "healthy";
let dbLatency = 0;
try {
const start = Date.now()
await prisma.user.count({ take: 1 })
dbLatency = Date.now() - start
const start = Date.now();
await prisma.user.count({ take: 1 });
dbLatency = Date.now() - start;
} catch {
dbStatus = 'error'
dbStatus = "error";
}
// Recent emulator errors
const recentErrors = await prisma.emulatorErrors.findMany({
orderBy: { id: 'desc' },
orderBy: { id: "desc" },
take: 20,
})
});
const errorsToday = await prisma.emulatorErrors.count({
where: { timestamp: { gte: dayAgo } },
})
});
// Emulator settings (version info)
const emuVersion = await prisma.emulatorSettings.findFirst({
where: { key: 'version' },
})
where: { key: "version" },
});
// Online user count as proxy for emulator health
const onlineUsers = await prisma.user.count({ where: { online: '1' } })
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
return {
dbStatus,
dbLatency,
recentErrors,
errorsToday,
emuVersion: emuVersion?.value ?? 'Unknown',
emuVersion: emuVersion?.value ?? "Unknown",
onlineUsers,
}
};
}
export default async function DevOpsPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const data = await getDevOpsData()
const data = await getDevOpsData();
return (
<div className="space-y-6">
@@ -72,8 +72,8 @@ export default async function DevOpsPage() {
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant={data.dbStatus === 'healthy' ? 'default' : 'destructive'}>
{data.dbStatus === 'healthy' ? 'Healthy' : 'Error'}
<Badge variant={data.dbStatus === "healthy" ? "default" : "destructive"}>
{data.dbStatus === "healthy" ? "Healthy" : "Error"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p>
</CardContent>
@@ -134,7 +134,7 @@ export default async function DevOpsPage() {
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
{Buffer.from(error.stacktrace).toString('utf-8')}
{Buffer.from(error.stacktrace).toString("utf-8")}
</pre>
</div>
))}
@@ -143,5 +143,5 @@ export default async function DevOpsPage() {
</CardContent>
</Card>
</div>
)
);
}
+39 -55
View File
@@ -1,62 +1,56 @@
'use client'
"use client";
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { addEventPrize, deleteEventPrize } from '@/actions/events'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { addEventPrize, deleteEventPrize } from "@/actions/events";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
interface Prize {
id: number
position: number
prizeType: string
badgeCode: string | null
credits: number
pixels: number
points: number
itemId: number | null
description: string
id: number;
position: number;
prizeType: string;
badgeCode: string | null;
credits: number;
pixels: number;
points: number;
itemId: number | null;
description: string;
}
interface Props {
eventId: number
prizes: Prize[]
eventId: number;
prizes: Prize[];
}
export function EventPrizesManager({ eventId, prizes }: Props) {
const { run, isPending } = useServerAction()
const [showForm, setShowForm] = useState(false)
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
position: prizes.length + 1,
prizeType: 'badge' as string,
badgeCode: '',
prizeType: "badge" as string,
badgeCode: "",
credits: 0,
pixels: 0,
points: 0,
description: '',
})
description: "",
});
function handleAdd(e: React.FormEvent) {
e.preventDefault()
e.preventDefault();
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => addEventPrize({ eventId, ...form } as any), {
successMessage: 'Prize added!',
successMessage: "Prize added!",
onSuccess: () => {
setShowForm(false)
setForm({ ...form, position: form.position + 1, badgeCode: '', description: '' })
setShowForm(false);
setForm({ ...form, position: form.position + 1, badgeCode: "", description: "" });
},
})
});
}
return (
@@ -81,10 +75,7 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
</div>
<div className="space-y-1">
<Label>Type</Label>
<Select
value={form.prizeType}
onValueChange={(v) => setForm({ ...form, prizeType: v })}
>
<Select value={form.prizeType} onValueChange={(v) => setForm({ ...form, prizeType: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -133,16 +124,11 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
)}
{prizes.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">
No prizes configured yet.
</p>
<p className="text-sm text-muted-foreground text-center py-4">No prizes configured yet.</p>
)}
{prizes.map((prize) => (
<div
key={prize.id}
className="flex items-center justify-between border rounded-lg px-4 py-3"
>
<div key={prize.id} className="flex items-center justify-between border rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-accent font-bold text-sm">
#{prize.position}
@@ -155,16 +141,14 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
{prize.pixels > 0 && <span className="text-sm">{prize.pixels} pixels</span>}
{prize.points > 0 && <span className="text-sm">{prize.points} points</span>}
</div>
{prize.description && (
<p className="text-xs text-muted-foreground">{prize.description}</p>
)}
{prize.description && <p className="text-xs text-muted-foreground">{prize.description}</p>}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() =>
run(() => deleteEventPrize({ id: prize.id }), { successMessage: 'Prize removed' })
run(() => deleteEventPrize({ id: prize.id }), { successMessage: "Prize removed" })
}
>
<Trash2 className="h-4 w-4 text-destructive" />
@@ -173,5 +157,5 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
))}
</CardContent>
</Card>
)
);
}
@@ -1,16 +1,16 @@
'use client'
"use client";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Registration {
id: number
userId: number
username: string
createdAt: Date
id: number;
userId: number;
username: string;
createdAt: Date;
}
interface Props {
registrations: Registration[]
registrations: Registration[];
}
export function EventRegistrations({ registrations }: Props) {
@@ -36,5 +36,5 @@ export function EventRegistrations({ registrations }: Props) {
)}
</CardContent>
</Card>
)
);
}
+27 -27
View File
@@ -1,43 +1,43 @@
'use client'
"use client";
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { addEventWinner } from '@/actions/events'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useServerAction } from '@/hooks/use-server-action'
import { Plus } from "lucide-react";
import { useState } from "react";
import { addEventWinner } from "@/actions/events";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface Winner {
id: number
userId: number
position: number
username: string
createdAt: Date
id: number;
userId: number;
position: number;
username: string;
createdAt: Date;
}
interface Props {
eventId: number
winners: Winner[]
eventId: number;
winners: Winner[];
}
export function EventWinnersManager({ eventId, winners }: Props) {
const { run, isPending } = useServerAction()
const [showForm, setShowForm] = useState(false)
const [userId, setUserId] = useState('')
const [position, setPosition] = useState(winners.length + 1)
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [userId, setUserId] = useState("");
const [position, setPosition] = useState(winners.length + 1);
function handleAdd(e: React.FormEvent) {
e.preventDefault()
e.preventDefault();
run(() => addEventWinner({ eventId, userId: Number(userId), position }), {
successMessage: 'Winner added!',
successMessage: "Winner added!",
onSuccess: () => {
setShowForm(false)
setUserId('')
setPosition(position + 1)
setShowForm(false);
setUserId("");
setPosition(position + 1);
},
})
});
}
return (
@@ -95,5 +95,5 @@ export function EventWinnersManager({ eventId, winners }: Props) {
</div>
</CardContent>
</Card>
)
);
}
+38 -47
View File
@@ -1,75 +1,70 @@
import { CalendarDays, Gift, Trophy, Users } from 'lucide-react'
import { notFound, redirect } from 'next/navigation'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { EventForm } from '../event-form'
import { EventPrizesManager } from './event-prizes'
import { EventRegistrations } from './event-registrations'
import { EventWinnersManager } from './event-winners'
import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EventForm } from "../event-form";
import { EventPrizesManager } from "./event-prizes";
import { EventRegistrations } from "./event-registrations";
import { EventWinnersManager } from "./event-winners";
export default async function EventDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params
const { session, permissions } = await getAdminContext()
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect('/admin/events')
redirect("/admin/events");
}
const eventId = parseInt(id, 10)
if (Number.isNaN(eventId)) notFound()
const eventId = parseInt(id, 10);
if (Number.isNaN(eventId)) notFound();
const event = await prisma.websiteEvent.findUnique({
where: { id: eventId },
include: {
type: true,
prizes: { orderBy: { position: 'asc' } },
winners: { orderBy: { position: 'asc' } },
registrations: { orderBy: { createdAt: 'desc' } },
prizes: { orderBy: { position: "asc" } },
winners: { orderBy: { position: "asc" } },
registrations: { orderBy: { createdAt: "desc" } },
},
})
});
if (!event) notFound()
if (!event) notFound();
const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 },
orderBy: { name: 'asc' },
})
orderBy: { name: "asc" },
});
// Fetch usernames for registrations and winners
const userIds = [
...event.registrations.map((r) => r.userId),
...event.winners.map((w) => w.userId),
]
const userIds = [...event.registrations.map((r) => r.userId), ...event.winners.map((w) => w.userId)];
const users =
userIds.length > 0
? await prisma.user.findMany({
where: { id: { in: [...new Set(userIds)] } },
select: { id: true, username: true },
})
: []
const userMap = new Map(users.map((u) => [u.id, u.username]))
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb
items={[{ label: 'Events', href: '/admin/events' }, { label: event.title }]}
/>
<AdminBreadcrumb items={[{ label: "Events", href: "/admin/events" }, { label: event.title }]} />
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
<Badge
variant={
event.status === 'published'
? 'default'
: event.status === 'cancelled'
? 'destructive'
: 'secondary'
event.status === "published"
? "default"
: event.status === "cancelled"
? "destructive"
: "secondary"
}
>
{event.status}
@@ -89,9 +84,7 @@ export default async function EventDetailPage({
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{event.registrations.length}</div>
{event.maxPlayers && (
<p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>
)}
{event.maxPlayers && <p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>}
</CardContent>
</Card>
<Card>
@@ -119,12 +112,10 @@ export default async function EventDetailPage({
</CardHeader>
<CardContent>
<div className="text-sm font-medium">
{new Date(event.startsAt) > new Date() ? 'Upcoming' : 'Past'}
{new Date(event.startsAt) > new Date() ? "Upcoming" : "Past"}
</div>
{event.endsAt && (
<p className="text-xs text-muted-foreground">
Ends: {new Date(event.endsAt).toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">Ends: {new Date(event.endsAt).toLocaleString()}</p>
)}
</CardContent>
</Card>
@@ -144,7 +135,7 @@ export default async function EventDetailPage({
maxPlayers: event.maxPlayers,
isRecurring: event.isRecurring,
recurrenceRule: event.recurrenceRule,
status: event.status as 'draft' | 'published' | 'cancelled' | 'completed',
status: event.status as "draft" | "published" | "cancelled" | "completed",
image: event.image,
}}
/>
@@ -169,5 +160,5 @@ export default async function EventDetailPage({
}))}
/>
</div>
)
);
}
+9 -9
View File
@@ -1,18 +1,18 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { EventForm } from '../event-form'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EventForm } from "../event-form";
export default async function CreateEventPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect('/admin/events')
redirect("/admin/events");
}
const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 },
orderBy: { name: 'asc' },
})
orderBy: { name: "asc" },
});
return (
<div className="space-y-6">
@@ -23,5 +23,5 @@ export default async function CreateEventPage() {
<EventForm eventTypes={eventTypes} />
</div>
)
);
}
+46 -62
View File
@@ -1,66 +1,60 @@
'use client'
"use client";
import { useForm } from 'react-hook-form'
import { createEvent, updateEvent } from '@/actions/events'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DateTimePicker } from '@/components/ui/date-time-picker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useServerAction } from '@/hooks/use-server-action'
import type { CreateEventInput } from '@/lib/validators/event'
import { useForm } from "react-hook-form";
import { createEvent, updateEvent } from "@/actions/events";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import type { CreateEventInput } from "@/lib/validators/event";
interface EventType {
id: number
name: string
slug: string
color: string
id: number;
name: string;
slug: string;
color: string;
}
interface EventFormProps {
eventTypes: EventType[]
defaultValues?: Partial<CreateEventInput> & { id?: number }
eventTypes: EventType[];
defaultValues?: Partial<CreateEventInput> & { id?: number };
}
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
const { run, isPending } = useServerAction()
const isEditing = !!defaultValues?.id
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const form = useForm({
defaultValues: {
title: '',
description: '',
title: "",
description: "",
typeId: eventTypes[0]?.id,
status: 'draft',
status: "draft",
isRecurring: 0,
...defaultValues,
},
})
});
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
function onSubmit(data: any) {
if (isEditing && defaultValues?.id) {
run(() => updateEvent({ id: defaultValues.id!, ...data }), {
successMessage: 'Event updated!',
})
successMessage: "Event updated!",
});
} else {
run(() => createEvent(data), {
successMessage: 'Event created!',
successMessage: "Event created!",
onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined
const data = result.data as Record<string, unknown> | undefined;
if (data?.id) {
window.location.href = `/admin/events/${data.id}`
window.location.href = `/admin/events/${data.id}`;
}
},
})
});
}
}
@@ -68,13 +62,13 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>{isEditing ? 'Edit Event' : 'Event Details'}</CardTitle>
<CardTitle>{isEditing ? "Edit Event" : "Event Details"}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" {...form.register('title')} />
<Input id="title" {...form.register("title")} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p>
)}
@@ -83,8 +77,8 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<div className="space-y-2">
<Label htmlFor="typeId">Event Type</Label>
<Select
value={String(form.watch('typeId'))}
onValueChange={(v) => form.setValue('typeId', Number(v))}
value={String(form.watch("typeId"))}
onValueChange={(v) => form.setValue("typeId", Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
@@ -102,54 +96,44 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={4} {...form.register('description')} />
<Textarea id="description" rows={4} {...form.register("description")} />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Starts At</Label>
<DateTimePicker
value={form.watch('startsAt')}
value={form.watch("startsAt")}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) => form.setValue('startsAt', d as any, { shouldDirty: true })}
onChange={(d) => form.setValue("startsAt", d as any, { shouldDirty: true })}
placeholder="Select start date"
/>
</div>
<div className="space-y-2">
<Label>Ends At</Label>
<DateTimePicker
value={form.watch('endsAt')}
value={form.watch("endsAt")}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) => form.setValue('endsAt', d as any, { shouldDirty: true })}
onChange={(d) => form.setValue("endsAt", d as any, { shouldDirty: true })}
placeholder="Select end date"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxPlayers">Max Players</Label>
<Input
id="maxPlayers"
type="number"
{...form.register('maxPlayers')}
placeholder="Unlimited"
/>
<Input id="maxPlayers" type="number" {...form.register("maxPlayers")} placeholder="Unlimited" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roomId">Room ID</Label>
<Input
id="roomId"
type="number"
{...form.register('roomId')}
placeholder="Optional"
/>
<Input id="roomId" type="number" {...form.register("roomId")} placeholder="Optional" />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={form.watch('status')}
onValueChange={(v) => form.setValue('status', v as CreateEventInput['status'])}
value={form.watch("status")}
onValueChange={(v) => form.setValue("status", v as CreateEventInput["status"])}
>
<SelectTrigger>
<SelectValue />
@@ -166,16 +150,16 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<div className="space-y-2">
<Label htmlFor="image">Image URL</Label>
<Input id="image" {...form.register('image')} placeholder="https://..." />
<Input id="image" {...form.register("image")} placeholder="https://..." />
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : isEditing ? 'Update Event' : 'Create Event'}
{isPending ? "Saving..." : isEditing ? "Update Event" : "Create Event"}
</Button>
</div>
</CardContent>
</Card>
</form>
)
);
}
+37 -37
View File
@@ -1,66 +1,66 @@
'use client'
"use client";
import { Pencil } from 'lucide-react'
import Link from 'next/link'
import { DataTable } from '@/components/admin/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { DataTableColumn, PaginatedResult } from '@/types'
import { Pencil } from "lucide-react";
import Link from "next/link";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface EventRow {
id: number
title: string
typeName: string
typeColor: string
startsAt: Date
endsAt: Date | null
status: string
hostUserId: number
id: number;
title: string;
typeName: string;
typeColor: string;
startsAt: Date;
endsAt: Date | null;
status: string;
hostUserId: number;
}
const statusVariants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
published: 'default',
draft: 'secondary',
cancelled: 'destructive',
completed: 'outline',
}
const statusVariants: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
published: "default",
draft: "secondary",
cancelled: "destructive",
completed: "outline",
};
function getColumns(): DataTableColumn<EventRow>[] {
return [
{ key: 'id', label: 'ID', sortable: true },
{ key: "id", label: "ID", sortable: true },
{
key: 'title',
label: 'Title',
key: "title",
label: "Title",
sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>,
},
{
key: 'typeName',
label: 'Type',
key: "typeName",
label: "Type",
sortable: false,
render: (_, row) => (
<Badge style={{ backgroundColor: row.typeColor, color: '#fff' }}>{row.typeName}</Badge>
<Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>{row.typeName}</Badge>
),
},
{
key: 'startsAt',
label: 'Starts',
key: "startsAt",
label: "Starts",
sortable: true,
render: (value) => (
<span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span>
),
},
{
key: 'status',
label: 'Status',
key: "status",
label: "Status",
sortable: true,
render: (value) => (
<Badge variant={statusVariants[String(value)] ?? 'secondary'}>{String(value)}</Badge>
<Badge variant={statusVariants[String(value)] ?? "secondary"}>{String(value)}</Badge>
),
},
{
key: 'actions',
label: 'Actions',
key: "actions",
label: "Actions",
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}>
@@ -69,13 +69,13 @@ function getColumns(): DataTableColumn<EventRow>[] {
</Button>
),
},
]
];
}
interface EventsTableProps {
data: PaginatedResult<EventRow>
data: PaginatedResult<EventRow>;
}
export function EventsTable({ data }: EventsTableProps) {
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />;
}
+20 -20
View File
@@ -1,32 +1,32 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { fetchAdminList } from '@/lib/admin-list'
import { PERMS } from '@/lib/permissions'
import { EventsTable } from './events-table'
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { fetchAdminList } from "@/lib/admin-list";
import { PERMS } from "@/lib/permissions";
import { EventsTable } from "./events-table";
export default async function EventsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.EVENTS_VIEW,
model: 'websiteEvent',
searchFields: ['title'],
defaultSort: { startsAt: 'desc' },
model: "websiteEvent",
searchFields: ["title"],
defaultSort: { startsAt: "desc" },
include: { type: { select: { name: true, color: true } } },
mapRow: (e: {
id: number
title: string
type: { name: string; color: string }
startsAt: Date
endsAt: Date | null
status: string
hostUserId: number
_count?: { registrations: number }
id: number;
title: string;
type: { name: string; color: string };
startsAt: Date;
endsAt: Date | null;
status: string;
hostUserId: number;
_count?: { registrations: number };
}) => ({
id: e.id,
title: e.title,
@@ -40,7 +40,7 @@ export default async function EventsPage({
},
paramsPromise,
searchParams,
)
);
return (
<div className="space-y-6">
@@ -64,5 +64,5 @@ export default async function EventsPage({
}}
/>
</div>
)
);
}
@@ -1,47 +1,47 @@
'use client'
"use client";
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { createEventType, deleteEventType, updateEventType } from '@/actions/events'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useServerAction } from '@/hooks/use-server-action'
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { createEventType, deleteEventType, updateEventType } from "@/actions/events";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface EventType {
id: number
name: string
slug: string
description: string
color: string
icon: string
isActive: number
minRank: number
id: number;
name: string;
slug: string;
description: string;
color: string;
icon: string;
isActive: number;
minRank: number;
}
interface Props {
eventTypes: EventType[]
eventTypes: EventType[];
}
export function EventTypesManager({ eventTypes }: Props) {
const { run, isPending } = useServerAction()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({
name: '',
slug: '',
description: '',
color: '#3b82f6',
icon: 'calendar',
name: "",
slug: "",
description: "",
color: "#3b82f6",
icon: "calendar",
minRank: 0,
})
});
function resetForm() {
setForm({ name: '', slug: '', description: '', color: '#3b82f6', icon: 'calendar', minRank: 0 })
setEditingId(null)
setShowForm(false)
setForm({ name: "", slug: "", description: "", color: "#3b82f6", icon: "calendar", minRank: 0 });
setEditingId(null);
setShowForm(false);
}
function handleEdit(et: EventType) {
@@ -52,30 +52,30 @@ export function EventTypesManager({ eventTypes }: Props) {
color: et.color,
icon: et.icon,
minRank: et.minRank,
})
setEditingId(et.id)
setShowForm(true)
});
setEditingId(et.id);
setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
e.preventDefault();
if (editingId) {
run(() => updateEventType({ id: editingId, ...form }), {
successMessage: 'Event type updated!',
successMessage: "Event type updated!",
onSuccess: resetForm,
})
});
} else {
run(() => createEventType(form), {
successMessage: 'Event type created!',
successMessage: "Event type created!",
onSuccess: resetForm,
})
});
}
}
function handleDelete(id: number) {
run(() => deleteEventType({ id }), {
successMessage: 'Event type deleted!',
})
successMessage: "Event type deleted!",
});
}
return (
@@ -90,7 +90,7 @@ export function EventTypesManager({ eventTypes }: Props) {
{showForm && (
<Card>
<CardHeader>
<CardTitle>{editingId ? 'Edit Event Type' : 'New Event Type'}</CardTitle>
<CardTitle>{editingId ? "Edit Event Type" : "New Event Type"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -136,13 +136,9 @@ export function EventTypesManager({ eventTypes }: Props) {
min={0}
max={7}
value={form.minRank}
onChange={(e) =>
setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })
}
onChange={(e) => setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })}
/>
<p className="text-[10px] text-muted-foreground">
0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+
</p>
<p className="text-[10px] text-muted-foreground">0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+</p>
</div>
</div>
<div className="flex gap-2 justify-end">
@@ -150,7 +146,7 @@ export function EventTypesManager({ eventTypes }: Props) {
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : editingId ? 'Update' : 'Create'}
{isPending ? "Saving..." : editingId ? "Update" : "Create"}
</Button>
</div>
</form>
@@ -174,8 +170,8 @@ export function EventTypesManager({ eventTypes }: Props) {
<span className="font-medium">{et.name}</span>
<span className="ml-2 text-xs text-muted-foreground">{et.slug}</span>
</div>
<Badge variant={et.isActive ? 'default' : 'outline'}>
{et.isActive ? 'Active' : 'Inactive'}
<Badge variant={et.isActive ? "default" : "outline"}>
{et.isActive ? "Active" : "Inactive"}
</Badge>
{et.minRank > 0 && (
<Badge variant="secondary" className="text-[10px]">
@@ -197,5 +193,5 @@ export function EventTypesManager({ eventTypes }: Props) {
</CardContent>
</Card>
</div>
)
);
}
+9 -9
View File
@@ -1,17 +1,17 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { EventTypesManager } from './event-types-manager'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EventTypesManager } from "./event-types-manager";
export default async function EventTypesPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect('/admin/events')
redirect("/admin/events");
}
const eventTypes = await prisma.websiteEventType.findMany({
orderBy: { name: 'asc' },
})
orderBy: { name: "asc" },
});
return (
<div className="space-y-6">
@@ -22,5 +22,5 @@ export default async function EventTypesPage() {
<EventTypesManager eventTypes={eventTypes} />
</div>
)
);
}
+3 -1
View File
@@ -60,7 +60,9 @@ export default async function AdminHelpQuestions() {
<tbody>
{entries.map((e) => (
<tr key={String(e.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">{e.position}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{e.position}
</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td>
@@ -1,62 +1,56 @@
'use client'
"use client";
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { getBadgeData, updateBadge } from '@/actions/badges'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useServerAction } from '@/hooks/use-server-action'
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBadgeData, updateBadge } from "@/actions/badges";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface BadgeEditorDialogProps {
code: string
open: boolean
onOpenChange: (open: boolean) => void
code: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful save so the parent can refresh state */
onSaved?: () => void
onSaved?: () => void;
}
export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) {
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [loading, setLoading] = useState(false)
const { run, isPending: saving } = useServerAction()
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [loading, setLoading] = useState(false);
const { run, isPending: saving } = useServerAction();
// Fetch current values when dialog opens
useEffect(() => {
if (!open || !code) return
setLoading(true)
if (!open || !code) return;
setLoading(true);
getBadgeData({ code })
.then((result) => {
if (result.ok) {
const data = result.data as { name: string; desc: string }
setName(data.name ?? '')
setDesc(data.desc ?? '')
const data = result.data as { name: string; desc: string };
setName(data.name ?? "");
setDesc(data.desc ?? "");
}
})
.catch(() => {
toast.error('Failed to load badge data')
toast.error("Failed to load badge data");
})
.finally(() => setLoading(false))
}, [open, code])
.finally(() => setLoading(false));
}, [open, code]);
function handleSave() {
run(() => updateBadge({ code, name, desc }), {
successMessage: `Badge ${code} updated`,
errorMessage: 'Save failed',
errorMessage: "Save failed",
onSuccess: () => {
onSaved?.()
onOpenChange(false)
onSaved?.();
onOpenChange(false);
},
})
});
}
return (
@@ -104,5 +98,5 @@ export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEd
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
AlertCircle,
@@ -12,183 +12,179 @@ import {
Pencil,
Search,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { importBadgeFromRemote } from '@/actions/import-badges'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { BadgeEditorDialog } from './badge-editor-dialog'
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { importBadgeFromRemote } from "@/actions/import-badges";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { BadgeEditorDialog } from "./badge-editor-dialog";
interface BadgeItem {
id: number
code: string
name: string
description: string
hotel: string
url_habbo: string
url_habboassets: string
alreadyImported: boolean
id: number;
code: string;
name: string;
description: string;
hotel: string;
url_habbo: string;
url_habboassets: string;
alreadyImported: boolean;
}
const PAGE_SIZE = 100
const PAGE_SIZE = 100;
export function ImportBadgesClient() {
const [searchTerm, setSearchTerm] = useState('')
const [activeSearch, setActiveSearch] = useState('')
const [badges, setBadges] = useState<BadgeItem[]>([])
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [importingCode, setImportingCode] = useState<string | null>(null)
const [error, setError] = useState('')
const [allHotels, setAllHotels] = useState(true)
const [editingCode, setEditingCode] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchImporting, setBatchImporting] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [badges, setBadges] = useState<BadgeItem[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [importingCode, setImportingCode] = useState<string | null>(null);
const [error, setError] = useState("");
const [allHotels, setAllHotels] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchImporting, setBatchImporting] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const fetchBadges = useCallback(async (search: string, off: number, all: boolean) => {
setLoading(true)
setError('')
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ offset: String(off) })
if (search) params.set('search', search)
if (all) params.set('allHotels', '1')
const res = await fetch(`/api/admin/import/badges?${params}`)
const data = await res.json()
const params = new URLSearchParams({ offset: String(off) });
if (search) params.set("search", search);
if (all) params.set("allHotels", "1");
const res = await fetch(`/api/admin/import/badges?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load')
return
setError(data.error || "Failed to load");
return;
}
const list = data.badges || []
setBadges(list)
setHasMore(list.length >= PAGE_SIZE)
setSelected(new Set())
const list = data.badges || [];
setBadges(list);
setHasMore(list.length >= PAGE_SIZE);
setSelected(new Set());
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
// Auto-load on mount and when allHotels changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
setOffset(0)
fetchBadges(activeSearch, 0, allHotels)
}, [fetchBadges, allHotels])
setOffset(0);
fetchBadges(activeSearch, 0, allHotels);
}, [fetchBadges, allHotels]);
// "/" keyboard shortcut to focus search
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
e.preventDefault()
searchRef.current?.focus()
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
searchRef.current?.focus();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function doSearch() {
setActiveSearch(searchTerm)
setOffset(0)
fetchBadges(searchTerm, 0, allHotels)
setActiveSearch(searchTerm);
setOffset(0);
fetchBadges(searchTerm, 0, allHotels);
}
function goPage(direction: 'prev' | 'next') {
const newOffset = direction === 'next' ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE)
setOffset(newOffset)
fetchBadges(activeSearch, newOffset, allHotels)
function goPage(direction: "prev" | "next") {
const newOffset = direction === "next" ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE);
setOffset(newOffset);
fetchBadges(activeSearch, newOffset, allHotels);
}
async function doImport(badge: BadgeItem) {
setImportingCode(badge.code)
setImportingCode(badge.code);
try {
const result = await importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
})
});
if (result.ok) {
toast.success(`${badge.code} imported`)
setBadges((prev) =>
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
)
toast.success(`${badge.code} imported`);
setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
setSelected((prev) => {
const n = new Set(prev)
n.delete(badge.code)
return n
})
const n = new Set(prev);
n.delete(badge.code);
return n;
});
} else {
toast.error(result.error || `Failed to import ${badge.code}`)
toast.error(result.error || `Failed to import ${badge.code}`);
}
} catch {
toast.error(`Network error importing ${badge.code}`)
toast.error(`Network error importing ${badge.code}`);
} finally {
setImportingCode(null)
setImportingCode(null);
}
}
async function doBatchImport() {
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported)
if (toImport.length === 0) return
setBatchImporting(true)
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported);
if (toImport.length === 0) return;
setBatchImporting(true);
let ok = 0,
fail = 0
fail = 0;
for (const badge of toImport) {
try {
const result = await importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
})
});
if (result.ok) {
ok++
setBadges((prev) =>
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
)
ok++;
setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
} else {
fail++
fail++;
}
} catch {
fail++
fail++;
}
}
setBatchImporting(false)
setSelected(new Set())
if (ok > 0) toast.success(`${ok} badge(s) imported`)
if (fail > 0) toast.error(`${fail} failed`)
setBatchImporting(false);
setSelected(new Set());
if (ok > 0) toast.success(`${ok} badge(s) imported`);
if (fail > 0) toast.error(`${fail} failed`);
}
function toggleSelect(code: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(code)) next.delete(code)
else next.add(code)
return next
})
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges])
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges]);
function toggleSelectAll() {
if (selected.size === importableBadges.length) {
setSelected(new Set())
setSelected(new Set());
} else {
setSelected(new Set(importableBadges.map((b) => b.code)))
setSelected(new Set(importableBadges.map((b) => b.code)));
}
}
// Stats
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges])
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges]);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
return (
<TooltipProvider delayDuration={300}>
@@ -226,10 +222,10 @@ export function ImportBadgesClient() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
searchRef.current?.blur()
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
searchRef.current?.blur();
}
}}
className="pl-9 pr-8"
@@ -238,9 +234,9 @@ export function ImportBadgesClient() {
<button
type="button"
onClick={() => {
setSearchTerm('')
setActiveSearch('')
fetchBadges('', 0, allHotels)
setSearchTerm("");
setActiveSearch("");
fetchBadges("", 0, allHotels);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -249,28 +245,24 @@ export function ImportBadgesClient() {
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={allHotels ? 'default' : 'outline'}
variant={allHotels ? "default" : "outline"}
size="sm"
onClick={() => setAllHotels((v) => !v)}
>
<Globe className="w-4 h-4 mr-1" />
{allHotels ? 'All' : 'IT'}
{allHotels ? "All" : "IT"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{allHotels
? 'Showing all hotels — click for Italian only'
: 'Showing Italian only — click for all hotels'}
? "Showing all hotels — click for Italian only"
: "Showing Italian only — click for all hotels"}
</p>
</TooltipContent>
</Tooltip>
@@ -287,7 +279,7 @@ export function ImportBadgesClient() {
disabled={batchImporting}
>
{selected.size === importableBadges.length
? 'Deselect All'
? "Deselect All"
: `Select All (${importableBadges.length})`}
</Button>
{selected.size > 0 && (
@@ -330,7 +322,7 @@ export function ImportBadgesClient() {
{!loading && badges.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No badges found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
<p className="text-sm">No badges found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
{!activeSearch && <p className="text-xs mt-1">Try searching by badge name or code</p>}
</div>
)}
@@ -338,20 +330,17 @@ export function ImportBadgesClient() {
{/* Badge grid */}
{badges.length > 0 && (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}
>
{badges.map((badge) => {
const isSelected = selected.has(badge.code)
const isSelected = selected.has(badge.code);
return (
<div
key={badge.code}
className={cn(
'rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all',
badge.alreadyImported && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
"rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all",
badge.alreadyImported && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{/* Checkbox for non-imported */}
@@ -360,10 +349,10 @@ export function ImportBadgesClient() {
type="button"
onClick={() => toggleSelect(badge.code)}
className={cn(
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
@@ -378,12 +367,12 @@ export function ImportBadgesClient() {
className="image-pixelated drop-shadow-sm"
style={{ maxHeight: 48, maxWidth: 48 }}
onError={(e) => {
const img = e.target as HTMLImageElement
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback) {
img.dataset.fallback = '1'
img.src = badge.url_habboassets
img.dataset.fallback = "1";
img.src = badge.url_habboassets;
} else {
img.style.opacity = '0.3'
img.style.opacity = "0.3";
}
}}
/>
@@ -402,9 +391,7 @@ export function ImportBadgesClient() {
<TooltipContent side="bottom" className="max-w-64">
<p className="font-medium">{badge.name}</p>
{badge.description && (
<p className="text-muted-foreground text-xs mt-1">
{badge.description}
</p>
<p className="text-muted-foreground text-xs mt-1">{badge.description}</p>
)}
</TooltipContent>
</Tooltip>
@@ -452,7 +439,7 @@ export function ImportBadgesClient() {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -466,7 +453,7 @@ export function ImportBadgesClient() {
variant="outline"
size="sm"
disabled={offset <= 0 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -474,7 +461,7 @@ export function ImportBadgesClient() {
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -484,13 +471,13 @@ export function ImportBadgesClient() {
{/* Badge Editor Dialog */}
<BadgeEditorDialog
code={editingCode ?? ''}
code={editingCode ?? ""}
open={!!editingCode}
onOpenChange={(open) => {
if (!open) setEditingCode(null)
if (!open) setEditingCode(null);
}}
/>
</div>
</TooltipProvider>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportBadgesLoading() {
return (
@@ -17,5 +17,5 @@ export default function ImportBadgesLoading() {
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
)
);
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportBadgesClient } from './import-badges-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from "./import-badges-client";
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <ImportBadgesClient />
return <ImportBadgesClient />;
}
+262 -286
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
AlertCircle,
@@ -12,46 +12,46 @@ import {
Search,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// ── Types ─────────────────────────────────────────────────────────────────────
interface CloneSource {
id: string
name: string
furnidataUrl: string
nitroBaseUrl: string
iconBaseUrl: string
id: string;
name: string;
furnidataUrl: string;
nitroBaseUrl: string;
iconBaseUrl: string;
}
interface FurniItem {
classname: string
name: string
description?: string
revision?: number
category?: string
itemType: 's' | 'i'
xdim: number
ydim: number
present: boolean
classname: string;
name: string;
description?: string;
revision?: number;
category?: string;
itemType: "s" | "i";
xdim: number;
ydim: number;
present: boolean;
}
interface CloneStats {
total: number
present: number
clonable: number
total: number;
present: number;
clonable: number;
}
interface PageMeta {
page: number
perPage: number
total: number
page: number;
perPage: number;
total: number;
}
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
@@ -63,63 +63,63 @@ async function runSseImport(
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
});
if (!res.body) {
toast.error('No response stream')
return
toast.error("No response stream");
return;
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let succeeded = 0
let failed = 0
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith('data: ')) continue
const evt = JSON.parse(part.slice(6))
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) {
onDone(evt.classname)
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
onDone(evt.classname);
}
if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
onComplete(succeeded, failed)
onComplete(succeeded, failed);
}
// ── Source form ───────────────────────────────────────────────────────────────
interface SourceFormProps {
initial?: Partial<CloneSource>
onSave: (src: Partial<CloneSource>) => Promise<void>
onCancel: () => void
saving: boolean
initial?: Partial<CloneSource>;
onSave: (src: Partial<CloneSource>) => Promise<void>;
onCancel: () => void;
saving: boolean;
}
function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
const [name, setName] = useState(initial?.name ?? '')
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? '')
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? '')
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? '')
const [name, setName] = useState(initial?.name ?? "");
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? "");
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl })
e.preventDefault();
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl });
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border p-4 bg-card">
<div className="text-sm font-medium">{initial?.id ? 'Edit Source' : 'Add Source'}</div>
<div className="text-sm font-medium">{initial?.id ? "Edit Source" : "Add Source"}</div>
<div className="grid gap-2">
<Input
placeholder="Name (e.g. HotelX)"
@@ -156,61 +156,61 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
</Button>
</div>
</form>
)
);
}
// ── Sources manager ───────────────────────────────────────────────────────────
interface SourcesManagerProps {
sources: CloneSource[]
activeSourceId: string | null
onSelect: (src: CloneSource) => void
onRefresh: () => Promise<void>
sources: CloneSource[];
activeSourceId: string | null;
onSelect: (src: CloneSource) => void;
onRefresh: () => Promise<void>;
}
function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) {
const [showForm, setShowForm] = useState(false)
const [editSrc, setEditSrc] = useState<CloneSource | null>(null)
const [saving, setSaving] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false);
const [editSrc, setEditSrc] = useState<CloneSource | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
async function handleSave(src: Partial<CloneSource>) {
setSaving(true)
setSaving(true);
try {
const res = await fetch('/api/admin/import/clone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const res = await fetch("/api/admin/import/clone", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(src),
})
const data = await res.json()
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Save failed')
return
toast.error(data.error || "Save failed");
return;
}
toast.success(src.id ? 'Source updated' : 'Source added')
setShowForm(false)
setEditSrc(null)
await onRefresh()
toast.success(src.id ? "Source updated" : "Source added");
setShowForm(false);
setEditSrc(null);
await onRefresh();
} finally {
setSaving(false)
setSaving(false);
}
}
async function handleDelete(src: CloneSource) {
setDeletingId(src.id)
setDeletingId(src.id);
try {
const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, {
method: 'DELETE',
})
const data = await res.json()
method: "DELETE",
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Delete failed')
return
toast.error(data.error || "Delete failed");
return;
}
toast.success(`${src.name} removed`)
await onRefresh()
toast.success(`${src.name} removed`);
await onRefresh();
} finally {
setDeletingId(null)
setDeletingId(null);
}
}
@@ -223,8 +223,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
variant="outline"
className="h-7 text-xs"
onClick={() => {
setEditSrc(null)
setShowForm(true)
setEditSrc(null);
setShowForm(true);
}}
>
<Plus className="w-3 h-3 mr-1" />
@@ -246,17 +246,12 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
{sources.map((src) => (
<div key={src.id}>
{editSrc?.id === src.id ? (
<SourceForm
initial={src}
onSave={handleSave}
onCancel={() => setEditSrc(null)}
saving={saving}
/>
<SourceForm initial={src} onSave={handleSave} onCancel={() => setEditSrc(null)} saving={saving} />
) : (
<div
className={cn(
'w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border',
activeSourceId === src.id && 'border-primary ring-1 ring-primary bg-primary/5',
"w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border",
activeSourceId === src.id && "border-primary ring-1 ring-primary bg-primary/5",
)}
>
<button
@@ -277,8 +272,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
className="h-6 w-6 p-0"
title="Edit source"
onClick={() => {
setEditSrc(src)
setShowForm(false)
setEditSrc(src);
setShowForm(false);
}}
>
<Pencil className="w-3 h-3" />
@@ -303,7 +298,7 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
</div>
))}
</div>
)
);
}
// ── Furni icon with fallback ──────────────────────────────────────────────────
@@ -313,20 +308,20 @@ function FurniIcon({
iconBaseUrl,
classname,
}: {
sourceId: string
iconBaseUrl: string
classname: string
sourceId: string;
iconBaseUrl: string;
classname: string;
}) {
// 0 = source's standalone icon, 1 = icon extracted from the .nitro (our
// endpoint), 2 = give up and show a placeholder.
const [stage, setStage] = useState(0)
const [stage, setStage] = useState(0);
if (stage >= 2) {
return <Package className="w-8 h-8 text-muted-foreground/50" />
return <Package className="w-8 h-8 text-muted-foreground/50" />;
}
const src =
stage === 0
? `${iconBaseUrl}/${classname}_icon.png`
: `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`
: `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`;
return (
// biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset
<img
@@ -336,203 +331,197 @@ function FurniIcon({
className="max-h-full max-w-full object-contain image-pixelated"
onError={() => setStage((s) => s + 1)}
/>
)
);
}
// ── Furni grid view ───────────────────────────────────────────────────────────
interface FurniGridProps {
source: CloneSource
source: CloneSource;
}
const PER_PAGE = 50
const PER_PAGE = 50;
function FurniGrid({ source }: FurniGridProps) {
const [searchTerm, setSearchTerm] = useState('')
const [activeSearch, setActiveSearch] = useState('')
const [items, setItems] = useState<FurniItem[]>([])
const [stats, setStats] = useState<CloneStats | null>(null)
const [meta, setMeta] = useState<PageMeta | null>(null)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [busyClassname, setBusyClassname] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'missing' | 'present'>('all')
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FurniItem[]>([]);
const [stats, setStats] = useState<CloneStats | null>(null);
const [meta, setMeta] = useState<PageMeta | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyClassname, setBusyClassname] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
const fetchItems = useCallback(
async (search: string, pageNum: number, filterVal: 'all' | 'missing' | 'present' = 'all') => {
setLoading(true)
setError('')
async (search: string, pageNum: number, filterVal: "all" | "missing" | "present" = "all") => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({
source: source.id,
page: String(pageNum),
perPage: String(PER_PAGE),
})
if (search) params.set('search', search)
if (filterVal !== 'all') params.set('filter', filterVal)
const res = await fetch(`/api/admin/import/clone?${params}`)
const data = await res.json()
});
if (search) params.set("search", search);
if (filterVal !== "all") params.set("filter", filterVal);
const res = await fetch(`/api/admin/import/clone?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load')
return
setError(data.error || "Failed to load");
return;
}
setItems(data.item || [])
setMeta(data.meta || null)
setStats(data.stats || null)
setSelected(new Set())
setItems(data.item || []);
setMeta(data.meta || null);
setStats(data.stats || null);
setSelected(new Set());
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
},
[source.id],
)
);
useEffect(() => {
setPage(1)
setSearchTerm('')
setActiveSearch('')
setItems([])
setStats(null)
setMeta(null)
setFilter('all')
fetchItems('', 1, 'all')
}, [fetchItems])
setPage(1);
setSearchTerm("");
setActiveSearch("");
setItems([]);
setStats(null);
setMeta(null);
setFilter("all");
fetchItems("", 1, "all");
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm)
setPage(1)
fetchItems(searchTerm, 1, filter)
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1, filter);
}
function goPage(dir: 'prev' | 'next') {
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
setPage(next)
fetchItems(activeSearch, next, filter)
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next, filter);
}
function changeFilter(f: 'all' | 'missing' | 'present') {
setFilter(f)
setPage(1)
fetchItems(activeSearch, 1, f)
function changeFilter(f: "all" | "missing" | "present") {
setFilter(f);
setPage(1);
fetchItems(activeSearch, 1, f);
}
const importable = items.filter((it) => !it.present)
const total = meta?.total ?? 0
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
const importable = items.filter((it) => !it.present);
const total = meta?.total ?? 0;
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(classname: string) {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(classname)) n.delete(classname)
else n.add(classname)
return n
})
const n = new Set(prev);
if (n.has(classname)) n.delete(classname);
else n.add(classname);
return n;
});
}
function markDone(classname: string) {
setItems((prev) =>
prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)),
)
setItems((prev) => prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)));
}
async function cloneViaSse(toClone: FurniItem[]) {
setBatchProgress({ done: 0, total: toClone.length })
let done = 0
setBatchProgress({ done: 0, total: toClone.length });
let done = 0;
await runSseImport(
'/api/admin/import/clone/batch',
"/api/admin/import/clone/batch",
{ sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2 },
(classname) => {
done++
markDone(classname)
setBatchProgress({ done, total: toClone.length })
done++;
markDone(classname);
setBatchProgress({ done, total: toClone.length });
},
(succeeded, failed) => {
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} furni cloned`)
if (failed > 0) toast.error(`${failed} failed`)
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} furni cloned`);
if (failed > 0) toast.error(`${failed} failed`);
// Refresh stats after batch
fetchItems(activeSearch, page, filter)
fetchItems(activeSearch, page, filter);
},
)
setBatchProgress(null)
);
setBatchProgress(null);
}
async function cloneOne(it: FurniItem) {
setBusyClassname(it.classname)
setBusyClassname(it.classname);
try {
await cloneViaSse([it])
await cloneViaSse([it]);
} finally {
setBusyClassname(null)
setBusyClassname(null);
}
}
async function cloneSelected() {
const toClone = items.filter((it) => selected.has(it.classname) && !it.present)
if (toClone.length === 0) return
await cloneViaSse(toClone)
const toClone = items.filter((it) => selected.has(it.classname) && !it.present);
if (toClone.length === 0) return;
await cloneViaSse(toClone);
}
// Clone EVERY not-yet-present furni from the source (across all pages),
// in chunks of 400 to stay under the batch endpoint's 500 cap.
async function cloneAll() {
let names: string[] = []
let names: string[] = [];
try {
const res = await fetch(
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
)
const data = await res.json()
);
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to load clonable list')
return
toast.error(data.error || "Failed to load clonable list");
return;
}
names = data.classnames || []
names = data.classnames || [];
} catch {
toast.error('Network error')
return
toast.error("Network error");
return;
}
if (names.length === 0) {
toast.info('Nothing to clone — everything is already present')
return
toast.info("Nothing to clone — everything is already present");
return;
}
if (
!window.confirm(
`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`,
)
) {
return
if (!window.confirm(`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`)) {
return;
}
const CHUNK = 400
setBatchProgress({ done: 0, total: names.length })
let done = 0
let okCount = 0
let failCount = 0
const CHUNK = 400;
setBatchProgress({ done: 0, total: names.length });
let done = 0;
let okCount = 0;
let failCount = 0;
for (let i = 0; i < names.length; i += CHUNK) {
const chunk = names.slice(i, i + CHUNK)
const chunk = names.slice(i, i + CHUNK);
await runSseImport(
'/api/admin/import/clone/batch',
"/api/admin/import/clone/batch",
{ sourceId: source.id, classnames: chunk, concurrency: 2 },
(classname) => {
done++
markDone(classname)
setBatchProgress({ done, total: names.length })
done++;
markDone(classname);
setBatchProgress({ done, total: names.length });
},
(succeeded, failed) => {
okCount += succeeded
failCount += failed
okCount += succeeded;
failCount += failed;
},
)
);
}
setBatchProgress(null)
setSelected(new Set())
if (okCount > 0) toast.success(`${okCount} furni cloned`)
if (failCount > 0) toast.error(`${failCount} failed`)
fetchItems(activeSearch, page, filter)
setBatchProgress(null);
setSelected(new Set());
if (okCount > 0) toast.success(`${okCount} furni cloned`);
if (failCount > 0) toast.error(`${failCount} failed`);
fetchItems(activeSearch, page, filter);
}
return (
@@ -567,12 +556,12 @@ function FurniGrid({ source }: FurniGridProps) {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1, filter)
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1, filter);
}
}}
className="pl-9 pr-8"
@@ -581,10 +570,10 @@ function FurniGrid({ source }: FurniGridProps) {
<button
type="button"
onClick={() => {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1, filter)
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1, filter);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -601,22 +590,22 @@ function FurniGrid({ source }: FurniGridProps) {
<div className="flex items-center gap-1 flex-wrap">
{(
[
{ key: 'all', label: 'All', count: stats?.total },
{ key: 'missing', label: "Not present (don't have)", count: stats?.clonable },
{ key: 'present', label: 'Present', count: stats?.present },
{ key: "all", label: "All", count: stats?.total },
{ key: "missing", label: "Not present (don't have)", count: stats?.clonable },
{ key: "present", label: "Present", count: stats?.present },
] as const
).map((f) => (
<Button
key={f.key}
type="button"
size="sm"
variant={filter === f.key ? 'default' : 'outline'}
variant={filter === f.key ? "default" : "outline"}
className="h-7 text-xs"
disabled={loading || !!batchProgress}
onClick={() => changeFilter(f.key)}
>
{f.label}
{typeof f.count === 'number' ? ` (${f.count})` : ''}
{typeof f.count === "number" ? ` (${f.count})` : ""}
</Button>
))}
</div>
@@ -632,15 +621,11 @@ function FurniGrid({ source }: FurniGridProps) {
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((it) => it.classname)),
prev.size === importable.length ? new Set() : new Set(importable.map((it) => it.classname)),
)
}
>
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
</Button>
)}
{selected.size > 0 && (
@@ -689,27 +674,22 @@ function FurniGrid({ source }: FurniGridProps) {
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<CloudDownload className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
</div>
)}
{/* Grid */}
{items.length > 0 && (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
>
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
{items.map((it) => {
const isSelected = selected.has(it.classname)
const isSelected = selected.has(it.classname);
return (
<div
key={it.classname}
className={cn(
'group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
it.present && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
"group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
it.present && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!it.present && (
@@ -717,10 +697,10 @@ function FurniGrid({ source }: FurniGridProps) {
type="button"
onClick={() => toggle(it.classname)}
className={cn(
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
@@ -729,23 +709,19 @@ function FurniGrid({ source }: FurniGridProps) {
{/* Type badge */}
<div className="absolute top-1.5 right-1.5 z-10">
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80">
{it.itemType === 's' ? 'floor' : 'wall'}
{it.itemType === "s" ? "floor" : "wall"}
</Badge>
</div>
<div className="relative flex items-center justify-center h-28 bg-accent/20">
<FurniIcon
sourceId={source.id}
iconBaseUrl={source.iconBaseUrl}
classname={it.classname}
/>
<FurniIcon sourceId={source.id} iconBaseUrl={source.iconBaseUrl} classname={it.classname} />
{/* Hover detail tooltip */}
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div className="bg-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
{it.description ? <p className="truncate">{it.description}</p> : null}
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
{it.revision ? <span>Rev: {it.revision}</span> : null}
<span>{it.itemType === 's' ? 'Floor' : 'Wall'}</span>
<span>{it.itemType === "s" ? "Floor" : "Wall"}</span>
<span>
{it.xdim}×{it.ydim}
</span>
@@ -762,7 +738,7 @@ function FurniGrid({ source }: FurniGridProps) {
{it.classname}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{it.itemType === 's' ? 'Floor' : 'Wall'} · {it.category || 'other'}
{it.itemType === "s" ? "Floor" : "Wall"} · {it.category || "other"}
</div>
<div className="pt-1">
{it.present ? (
@@ -789,7 +765,7 @@ function FurniGrid({ source }: FurniGridProps) {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -805,7 +781,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -813,7 +789,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -821,33 +797,33 @@ function FurniGrid({ source }: FurniGridProps) {
</div>
)}
</div>
)
);
}
// ── Root component ────────────────────────────────────────────────────────────
export function ImportCloneClient() {
const [sources, setSources] = useState<CloneSource[]>([])
const [activeSource, setActiveSource] = useState<CloneSource | null>(null)
const [loadingSources, setLoadingSources] = useState(true)
const [sources, setSources] = useState<CloneSource[]>([]);
const [activeSource, setActiveSource] = useState<CloneSource | null>(null);
const [loadingSources, setLoadingSources] = useState(true);
const fetchSources = useCallback(async () => {
try {
const res = await fetch('/api/admin/import/clone?action=sources')
const data = await res.json()
const res = await fetch("/api/admin/import/clone?action=sources");
const data = await res.json();
if (res.ok) {
setSources(data.sources || [])
setSources(data.sources || []);
}
} catch {
/* ignore */
} finally {
setLoadingSources(false)
setLoadingSources(false);
}
}, [])
}, []);
useEffect(() => {
fetchSources()
}, [fetchSources])
fetchSources();
}, [fetchSources]);
return (
<div className="space-y-6">
@@ -883,5 +859,5 @@ export function ImportCloneClient() {
</div>
)}
</div>
)
);
}
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportCloneClient } from './import-clone-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from "./import-clone-client";
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <ImportCloneClient />
return <ImportCloneClient />;
}
@@ -1,45 +1,35 @@
'use client'
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
Search,
Shirt,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { AlertCircle, Check, Download, Loader2, Package, Search, Shirt, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// ── Shared types ─────────────────────────────────────────────────────────────
interface FigureItem {
lib: string
revision: number
nitroExists: boolean
previewType?: string
previewId?: number
lib: string;
revision: number;
nitroExists: boolean;
previewType?: string;
previewId?: number;
}
interface ClothingSetItem {
setType: string
setId: number
gender: string
club: number
sellable: boolean
present: boolean
setType: string;
setId: number;
gender: string;
club: number;
sellable: boolean;
present: boolean;
}
type Mode = 'libs' | 'sets'
type Mode = "libs" | "sets";
const PER_PAGE = 100
const PER_PAGE = 100;
// ── Shared SSE reader ─────────────────────────────────────────────────────────
@@ -50,38 +40,38 @@ async function runSseImport(
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
});
if (!res.body) {
toast.error('No response stream')
return
toast.error("No response stream");
return;
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let succeeded = 0
let failed = 0
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith('data: ')) continue
const evt = JSON.parse(part.slice(6))
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) {
onDone(evt.classname)
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
onDone(evt.classname);
}
if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
onComplete(succeeded, failed)
onComplete(succeeded, failed);
}
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
@@ -89,30 +79,30 @@ async function runSseImport(
// A default dressed avatar; the previewed part overrides/adds its slot so the
// item is shown on a complete figure.
const BASE_FIGURE: Record<string, string> = {
hd: 'hd-180-1',
hr: 'hr-828-45',
ch: 'ch-665-92',
lg: 'lg-700-82',
sh: 'sh-705-62',
}
hd: "hd-180-1",
hr: "hr-828-45",
ch: "ch-665-92",
lg: "lg-700-82",
sh: "sh-705-62",
};
// Head-worn slots → render head-only (zoomed) so the item fills the frame;
// everything else renders the full body.
const HEAD_TYPES = new Set(['hd', 'hr', 'ha', 'he', 'ea', 'fa', 'fc'])
const HEAD_TYPES = new Set(["hd", "hr", "ha", "he", "ea", "fa", "fc"]);
function buildPreviewFigure(type: string, id: number): string {
const parts: Record<string, string> = { ...BASE_FIGURE }
parts[type] = `${type}-${id}` // default color
return Object.values(parts).join('.')
const parts: Record<string, string> = { ...BASE_FIGURE };
parts[type] = `${type}-${id}`; // default color
return Object.values(parts).join(".");
}
function AvatarPreview({ type, id }: { type?: string; id?: number }) {
const [failed, setFailed] = useState(false)
const [failed, setFailed] = useState(false);
if (failed || !type || id === undefined) {
return <Shirt className="w-8 h-8 text-muted-foreground/60" />
return <Shirt className="w-8 h-8 text-muted-foreground/60" />;
}
const figure = buildPreviewFigure(type, id)
const headOnly = HEAD_TYPES.has(type) ? '&headonly=1' : ''
const figure = buildPreviewFigure(type, id);
const headOnly = HEAD_TYPES.has(type) ? "&headonly=1" : "";
return (
// biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset
<img
@@ -121,129 +111,129 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) {
className="max-h-full object-contain image-pixelated"
onError={() => setFailed(true)}
/>
)
);
}
// ── Librerie (v1) view ────────────────────────────────────────────────────────
function LibsView() {
const [searchTerm, setSearchTerm] = useState('')
const [activeSearch, setActiveSearch] = useState('')
const [items, setItems] = useState<FigureItem[]>([])
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [busyLib, setBusyLib] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FigureItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true)
setError('')
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ page: String(pageNum) })
if (search) params.set('search', search)
const res = await fetch(`/api/admin/import/clothing?${params}`)
const data = await res.json()
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/clothing?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load')
return
setError(data.error || "Failed to load");
return;
}
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
setItems(data.item || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchItems('', 1)
}, [fetchItems])
fetchItems("", 1);
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm)
setPage(1)
fetchItems(searchTerm, 1)
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
}
function goPage(dir: 'prev' | 'next') {
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
setPage(next)
fetchItems(activeSearch, next)
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next);
}
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items])
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items])
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items]);
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items]);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(lib)) n.delete(lib)
else n.add(lib)
return n
})
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setItems((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)))
setItems((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)));
}
async function importViaSse(toImport: FigureItem[]) {
setBatchProgress({ done: 0, total: toImport.length })
let done = 0
setBatchProgress({ done: 0, total: toImport.length });
let done = 0;
await runSseImport(
'/api/admin/import/clothing/batch',
"/api/admin/import/clothing/batch",
{ items: toImport, concurrency: 3 },
(label) => {
done++
markDone(label)
setBatchProgress({ done, total: toImport.length })
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
},
(succeeded, failed) => {
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} libraries imported`)
if (failed > 0) toast.error(`${failed} failed`)
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
)
setBatchProgress(null)
);
setBatchProgress(null);
}
async function importOne(p: FigureItem) {
setBusyLib(p.lib)
setBusyLib(p.lib);
try {
await importViaSse([p])
await importViaSse([p]);
} finally {
setBusyLib(null)
setBusyLib(null);
}
}
async function importSelected() {
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists)
if (toImport.length === 0) return
await importViaSse(toImport)
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (toImport.length === 0) return;
await importViaSse(toImport);
}
async function remove(p: FigureItem) {
setBusyLib(p.lib)
setBusyLib(p.lib);
try {
const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, {
method: 'DELETE',
})
const data = await res.json()
method: "DELETE",
});
const data = await res.json();
if (res.ok) {
toast.success(`${p.lib} removed`)
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)))
toast.success(`${p.lib} removed`);
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
} else {
toast.error(data.error || 'Delete failed')
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null)
setBusyLib(null);
}
}
@@ -277,12 +267,12 @@ function LibsView() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}
}}
className="pl-9 pr-8"
@@ -291,10 +281,10 @@ function LibsView() {
<button
type="button"
onClick={() => {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -320,9 +310,7 @@ function LibsView() {
)
}
>
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -352,28 +340,21 @@ function LibsView() {
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No libraries found{activeSearch ? ` for "${activeSearch}"` : ''}
</p>
<p className="text-sm">No libraries found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
</div>
)}
{items.length > 0 && (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
>
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
{items.map((p) => {
const isSelected = selected.has(p.lib)
const isSelected = selected.has(p.lib);
return (
<div
key={p.lib}
className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!p.nitroExists && (
@@ -381,10 +362,10 @@ function LibsView() {
type="button"
onClick={() => toggle(p.lib)}
className={cn(
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
@@ -434,7 +415,7 @@ function LibsView() {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -449,7 +430,7 @@ function LibsView() {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -457,7 +438,7 @@ function LibsView() {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -465,120 +446,120 @@ function LibsView() {
</div>
)}
</div>
)
);
}
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
function SetsView() {
const [searchTerm, setSearchTerm] = useState('')
const [activeSearch, setActiveSearch] = useState('')
const [items, setItems] = useState<ClothingSetItem[]>([])
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<ClothingSetItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
function itemKey(it: ClothingSetItem): string {
return `${it.setType}-${it.setId}`
return `${it.setType}-${it.setId}`;
}
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true)
setError('')
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ page: String(pageNum) })
if (search) params.set('search', search)
const res = await fetch(`/api/admin/import/clothing/sets?${params}`)
const data = await res.json()
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/clothing/sets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load')
return
setError(data.error || "Failed to load");
return;
}
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
setItems(data.item || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchItems('', 1)
}, [fetchItems])
fetchItems("", 1);
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm)
setPage(1)
fetchItems(searchTerm, 1)
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
}
function goPage(dir: 'prev' | 'next') {
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
setPage(next)
fetchItems(activeSearch, next)
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next);
}
const importable = useMemo(() => items.filter((it) => !it.present), [items])
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items])
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
const importable = useMemo(() => items.filter((it) => !it.present), [items]);
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items]);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(key: string) {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(key)) n.delete(key)
else n.add(key)
return n
})
const n = new Set(prev);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
}
function markDone(label: string) {
setItems((prev) => prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)))
setItems((prev) => prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)));
}
async function importViaSse(toImport: ClothingSetItem[]) {
setBatchProgress({ done: 0, total: toImport.length })
let done = 0
setBatchProgress({ done: 0, total: toImport.length });
let done = 0;
await runSseImport(
'/api/admin/import/clothing/sets/batch',
"/api/admin/import/clothing/sets/batch",
{
items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })),
concurrency: 3,
},
(label) => {
done++
markDone(label)
setBatchProgress({ done, total: toImport.length })
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
},
(succeeded, failed) => {
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} sets imported`)
if (failed > 0) toast.error(`${failed} failed`)
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} sets imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
)
setBatchProgress(null)
);
setBatchProgress(null);
}
async function importOne(it: ClothingSetItem) {
const key = itemKey(it)
setBusyKey(key)
const key = itemKey(it);
setBusyKey(key);
try {
await importViaSse([it])
await importViaSse([it]);
} finally {
setBusyKey(null)
setBusyKey(null);
}
}
async function importSelected() {
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present)
if (toImport.length === 0) return
await importViaSse(toImport)
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present);
if (toImport.length === 0) return;
await importViaSse(toImport);
}
return (
@@ -611,12 +592,12 @@ function SetsView() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}
}}
className="pl-9 pr-8"
@@ -625,10 +606,10 @@ function SetsView() {
<button
type="button"
onClick={() => {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -650,15 +631,11 @@ function SetsView() {
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((it) => itemKey(it))),
prev.size === importable.length ? new Set() : new Set(importable.map((it) => itemKey(it))),
)
}
>
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -688,27 +665,22 @@ function SetsView() {
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No sets found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
<p className="text-sm">No sets found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
</div>
)}
{items.length > 0 && (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
>
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
{items.map((it) => {
const key = itemKey(it)
const isSelected = selected.has(key)
const key = itemKey(it);
const isSelected = selected.has(key);
return (
<div
key={key}
className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
it.present && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
it.present && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!it.present && (
@@ -716,10 +688,10 @@ function SetsView() {
type="button"
onClick={() => toggle(key)}
className={cn(
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
@@ -772,7 +744,7 @@ function SetsView() {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -787,7 +759,7 @@ function SetsView() {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -795,7 +767,7 @@ function SetsView() {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -803,36 +775,36 @@ function SetsView() {
</div>
)}
</div>
)
);
}
// ── Root component with mode toggle ──────────────────────────────────────────
export function ImportClothingClient() {
const [mode, setMode] = useState<Mode>('libs')
const [mode, setMode] = useState<Mode>("libs");
return (
<div className="space-y-4">
<div className="flex gap-1 rounded-lg border p-1 w-fit">
<Button
size="sm"
variant={mode === 'libs' ? 'default' : 'ghost'}
variant={mode === "libs" ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setMode('libs')}
onClick={() => setMode("libs")}
>
Librerie
</Button>
<Button
size="sm"
variant={mode === 'sets' ? 'default' : 'ghost'}
variant={mode === "sets" ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setMode('sets')}
onClick={() => setMode("sets")}
>
Vestiti (set)
</Button>
</div>
{mode === 'libs' ? <LibsView /> : <SetsView />}
{mode === "libs" ? <LibsView /> : <SetsView />}
</div>
)
);
}
+1 -1
View File
@@ -9,5 +9,5 @@ export default function Loading() {
</div>
))}
</div>
)
);
}
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportClothingClient } from './import-clothing-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from "./import-clothing-client";
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <ImportClothingClient />
return <ImportClothingClient />;
}
@@ -1,5 +1,5 @@
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
'use client'
"use client";
import {
AlertCircle,
@@ -12,165 +12,160 @@ import {
Sparkles,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface EffectItem {
id: string
lib: string
type: string
revision: number
present: boolean
nitroExists: boolean
id: string;
lib: string;
type: string;
revision: number;
present: boolean;
nitroExists: boolean;
}
type Filter = 'all' | 'fx' | 'dance'
type Filter = "all" | "fx" | "dance";
export function ImportEffectsClient() {
const [searchTerm, setSearchTerm] = useState('')
const [effects, setEffects] = useState<EffectItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [busyLib, setBusyLib] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState("");
const [effects, setEffects] = useState<EffectItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const fetchEffects = useCallback(async (search: string) => {
setLoading(true)
setError('')
setLoading(true);
setError("");
try {
const params = new URLSearchParams()
if (search) params.set('search', search)
const res = await fetch(`/api/admin/import/effects?${params}`)
const data = await res.json()
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/effects?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load')
return
setError(data.error || "Failed to load");
return;
}
setEffects(data.effects || [])
setSelected(new Set())
setEffects(data.effects || []);
setSelected(new Set());
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchEffects('')
}, [fetchEffects])
fetchEffects("");
}, [fetchEffects]);
const visible = useMemo(
() => effects.filter((e) => filter === 'all' || e.type === filter),
() => effects.filter((e) => filter === "all" || e.type === filter),
[effects, filter],
)
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible])
const importedCount = useMemo(
() => visible.filter((e) => e.present && e.nitroExists).length,
[visible],
)
);
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible]);
const importedCount = useMemo(() => visible.filter((e) => e.present && e.nitroExists).length, [visible]);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(lib)) n.delete(lib)
else n.add(lib)
return n
})
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setEffects((prev) =>
prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)),
)
setEffects((prev) => prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)));
}
async function importViaSse(items: EffectItem[]) {
setBatchProgress({ done: 0, total: items.length })
const res = await fetch('/api/admin/import/effects/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/effects/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
})
});
if (!res.body) {
toast.error('No response stream')
setBatchProgress(null)
return
toast.error("No response stream");
setBatchProgress(null);
return;
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let done = 0
let succeeded = 0
let failed = 0
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith('data: ')) continue
const evt = JSON.parse(part.slice(6))
if (evt.type === 'item_progress' && evt.status === 'done') {
done++
succeeded++
markDone(evt.classname)
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
done++
failed++
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
succeeded++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
failed++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`)
if (failed > 0) toast.error(`${failed} failed`)
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
}
async function importOne(e: EffectItem) {
setBusyLib(e.lib)
setBusyLib(e.lib);
try {
await importViaSse([e])
await importViaSse([e]);
} finally {
setBusyLib(null)
setBusyLib(null);
}
}
async function importSelected() {
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists))
if (items.length === 0) return
await importViaSse(items)
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists));
if (items.length === 0) return;
await importViaSse(items);
}
async function remove(e: EffectItem) {
setBusyLib(e.lib)
setBusyLib(e.lib);
try {
const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, {
method: 'DELETE',
})
const data = await res.json()
method: "DELETE",
});
const data = await res.json();
if (res.ok) {
toast.success(`${e.lib} removed`)
toast.success(`${e.lib} removed`);
setEffects((prev) =>
prev.map((x) => (x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x)),
)
);
} else {
toast.error(data.error || 'Delete failed')
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null)
setBusyLib(null);
}
}
@@ -206,10 +201,10 @@ export function ImportEffectsClient() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') fetchEffects(searchTerm)
if (e.key === 'Escape') {
setSearchTerm('')
fetchEffects('')
if (e.key === "Enter") fetchEffects(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchEffects("");
}
}}
className="pl-9 pr-8"
@@ -218,8 +213,8 @@ export function ImportEffectsClient() {
<button
type="button"
onClick={() => {
setSearchTerm('')
fetchEffects('')
setSearchTerm("");
fetchEffects("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -230,11 +225,11 @@ export function ImportEffectsClient() {
<Button onClick={() => fetchEffects(searchTerm)} size="sm" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
</Button>
{(['all', 'fx', 'dance'] as Filter[]).map((f) => (
{(["all", "fx", "dance"] as Filter[]).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? 'default' : 'outline'}
variant={filter === f ? "default" : "outline"}
onClick={() => setFilter(f)}
>
{f.toUpperCase()}
@@ -256,9 +251,7 @@ export function ImportEffectsClient() {
)
}
>
{selected.size === importable.length
? 'Deselect All'
: `Select All (${importable.length})`}
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -296,22 +289,17 @@ export function ImportEffectsClient() {
{/* Grid */}
{visible.length > 0 && (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
>
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
{visible.map((e) => {
const imported = e.present && e.nitroExists
const isSelected = selected.has(e.lib)
const imported = e.present && e.nitroExists;
const isSelected = selected.has(e.lib);
return (
<div
key={e.lib}
className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
imported && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
imported && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!imported && (
@@ -319,10 +307,10 @@ export function ImportEffectsClient() {
type="button"
onClick={() => toggle(e.lib)}
className={cn(
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
@@ -330,13 +318,13 @@ export function ImportEffectsClient() {
)}
<div
className={cn(
'flex flex-col items-center justify-center h-20 gap-1',
e.type === 'dance'
? 'bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10'
: 'bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10',
"flex flex-col items-center justify-center h-20 gap-1",
e.type === "dance"
? "bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10"
: "bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10",
)}
>
{e.type === 'dance' ? (
{e.type === "dance" ? (
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
) : (
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
@@ -385,10 +373,10 @@ export function ImportEffectsClient() {
</div>
</div>
</div>
)
);
})}
</div>
)}
</div>
)
);
}
+1 -1
View File
@@ -10,5 +10,5 @@ export default function Loading() {
</div>
))}
</div>
)
);
}
+6 -6
View File
@@ -1,12 +1,12 @@
// src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportEffectsClient } from './import-effects-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportEffectsClient } from "./import-effects-client";
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <ImportEffectsClient />
return <ImportEffectsClient />;
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportFurniLoading() {
return (
@@ -17,5 +17,5 @@ export default function ImportFurniLoading() {
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
)
);
}
+184 -215
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
Box,
@@ -14,71 +14,65 @@ import {
Ruler,
Save,
Sparkles,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
interface NitroEditorDialogProps {
classname: string
open: boolean
onOpenChange: (open: boolean) => void
classname: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function updateNested(obj: any, path: string, value: unknown): any {
const clone = JSON.parse(JSON.stringify(obj))
const keys = path.split('.')
let current = clone
const clone = JSON.parse(JSON.stringify(obj));
const keys = path.split(".");
let current = clone;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {}
current = current[keys[i]]
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value
return clone
current[keys[keys.length - 1]] = value;
return clone;
}
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function getNested(obj: any, path: string, fallback?: unknown): any {
const keys = path.split('.')
let current = obj
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current == null || typeof current !== 'object') return fallback
current = current[key]
if (current == null || typeof current !== "object") return fallback;
current = current[key];
}
return current ?? fallback
return current ?? fallback;
}
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7]
const DIR_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7];
const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
function hexToColor(hex: string): string {
return `#${hex.padStart(6, '0')}`
return `#${hex.padStart(6, "0")}`;
}
function CopyClassname({ value }: { value: string }) {
const [copied, setCopied] = useState(false)
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
onClick={() => {
navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
<span className="truncate">{value}</span>
@@ -88,178 +82,177 @@ function CopyClassname({ value }: { value: string }) {
<Copy className="w-3 h-3 shrink-0" />
)}
</button>
)
);
}
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const [metadata, setMetadata] = useState<any>(null)
const [metadata, setMetadata] = useState<any>(null);
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const [originalMetadata, setOriginalMetadata] = useState<any>(null)
const [jsonText, setJsonText] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<string>('visual')
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false })
const [originalMetadata, setOriginalMetadata] = useState<any>(null);
const [jsonText, setJsonText] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<string>("visual");
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false });
const [originalFlags, setOriginalFlags] = useState({
canstandon: false,
cansiton: false,
canlayon: false,
})
const [jsonError, setJsonError] = useState<string | null>(null)
});
const [jsonError, setJsonError] = useState<string | null>(null);
const fetchMetadata = useCallback(async () => {
if (!classname) return
setLoading(true)
setJsonError(null)
if (!classname) return;
setLoading(true);
setJsonError(null);
try {
const res = await fetch(
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
)
);
if (!res.ok) {
const err = await res.json().catch(() => ({}))
toast.error(err.error || 'Failed to load .nitro metadata')
onOpenChange(false)
return
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Failed to load .nitro metadata");
onOpenChange(false);
return;
}
const data = await res.json()
setMetadata(data.metadata)
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)))
setJsonText(JSON.stringify(data.metadata, null, 2))
setFlags(data.flags)
setOriginalFlags({ ...data.flags })
const data = await res.json();
setMetadata(data.metadata);
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)));
setJsonText(JSON.stringify(data.metadata, null, 2));
setFlags(data.flags);
setOriginalFlags({ ...data.flags });
} catch {
toast.error('Network error loading .nitro metadata')
onOpenChange(false)
toast.error("Network error loading .nitro metadata");
onOpenChange(false);
} finally {
setLoading(false)
setLoading(false);
}
}, [classname, onOpenChange])
}, [classname, onOpenChange]);
useEffect(() => {
if (open && classname) {
setActiveTab('visual')
fetchMetadata()
setActiveTab("visual");
fetchMetadata();
}
}, [open, classname, fetchMetadata])
}, [open, classname, fetchMetadata]);
const handleTabChange = (newTab: string) => {
if (activeTab === 'json' && newTab === 'visual') {
if (activeTab === "json" && newTab === "visual") {
try {
const parsed = JSON.parse(jsonText)
setMetadata(parsed)
setJsonError(null)
const parsed = JSON.parse(jsonText);
setMetadata(parsed);
setJsonError(null);
} catch {
toast.error('Invalid JSON — fix it before switching to Visual tab')
return
toast.error("Invalid JSON — fix it before switching to Visual tab");
return;
}
} else if (activeTab === 'visual' && newTab === 'json') {
setJsonText(JSON.stringify(metadata, null, 2))
} else if (activeTab === "visual" && newTab === "json") {
setJsonText(JSON.stringify(metadata, null, 2));
}
setActiveTab(newTab)
}
setActiveTab(newTab);
};
const handleReset = () => {
if (!originalMetadata) return
setMetadata(JSON.parse(JSON.stringify(originalMetadata)))
setFlags({ ...originalFlags })
setJsonText(JSON.stringify(originalMetadata, null, 2))
toast.info('Reset to original values')
}
if (!originalMetadata) return;
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2));
toast.info("Reset to original values");
};
const handleSave = async () => {
let metadataToSave = metadata
let metadataToSave = metadata;
if (activeTab === 'json') {
if (activeTab === "json") {
try {
metadataToSave = JSON.parse(jsonText)
metadataToSave = JSON.parse(jsonText);
} catch {
toast.error('Invalid JSON')
return
toast.error("Invalid JSON");
return;
}
}
setSaving(true)
setSaving(true);
try {
const res = await fetch('/api/admin/import/furni/nitro-editor', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
const res = await fetch("/api/admin/import/furni/nitro-editor", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classname,
metadata: metadataToSave,
...flags,
}),
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}))
toast.error(err.error || 'Save failed')
return
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Save failed");
return;
}
toast.success('Nitro metadata salvato')
setMetadata(metadataToSave)
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)))
setOriginalFlags({ ...flags })
setJsonText(JSON.stringify(metadataToSave, null, 2))
toast.success("Nitro metadata salvato");
setMetadata(metadataToSave);
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)));
setOriginalFlags({ ...flags });
setJsonText(JSON.stringify(metadataToSave, null, 2));
} catch {
toast.error('Network error')
toast.error("Network error");
} finally {
setSaving(false)
setSaving(false);
}
}
};
// ── Visual tab helpers ──
const dims = metadata
? getNested(metadata, 'logic.model.dimensions', { x: 1, y: 1, z: 0 })
: { x: 1, y: 1, z: 0 }
const directions: number[] = metadata ? getNested(metadata, 'logic.model.directions', [0]) : [0]
? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 })
: { x: 1, y: 1, z: 0 };
const directions: number[] = metadata ? getNested(metadata, "logic.model.directions", [0]) : [0];
const setDim = (axis: 'x' | 'y' | 'z', value: string) => {
const num = axis === 'z' ? parseFloat(value) || 0 : parseInt(value, 10) || 0
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num))
}
const setDim = (axis: "x" | "y" | "z", value: string) => {
const num = axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0;
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num));
};
const toggleDirection = (dir: number) => {
const current: number[] = getNested(metadata, 'logic.model.directions', [0])
const current: number[] = getNested(metadata, "logic.model.directions", [0]);
const next = current.includes(dir)
? current.filter((d: number) => d !== dir)
: [...current, dir].sort((a: number, b: number) => a - b)
if (next.length === 0) return
setMetadata((prev: unknown) => updateNested(prev, 'logic.model.directions', next))
}
: [...current, dir].sort((a: number, b: number) => a - b);
if (next.length === 0) return;
setMetadata((prev: unknown) => updateNested(prev, "logic.model.directions", next));
};
// Get visualization with size=64 for layer/color editing
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64)
const viz64Index =
metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64);
const viz64Index = metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1;
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0;
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0;
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0;
const handleJsonTextChange = (value: string) => {
setJsonText(value)
setJsonText(value);
try {
JSON.parse(value)
setJsonError(null)
JSON.parse(value);
setJsonError(null);
} catch (e) {
setJsonError((e as Error).message)
setJsonError((e as Error).message);
}
}
};
// Check if there are unsaved changes
const hasChanges =
metadata &&
originalMetadata &&
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
JSON.stringify(flags) !== JSON.stringify(originalFlags))
JSON.stringify(flags) !== JSON.stringify(originalFlags));
// Furni preview URL
const starIdx = classname.indexOf('*')
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const starIdx = classname.indexOf("*");
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -271,18 +264,18 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<div
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
style={{
backgroundImage: 'url(/images/furni_floor.png)',
backgroundRepeat: 'repeat',
backgroundPosition: 'center',
backgroundImage: "url(/images/furni_floor.png)",
backgroundRepeat: "repeat",
backgroundPosition: "center",
}}
>
<img
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
alt={classname}
className="max-w-[60px] max-h-[60px] drop-shadow-md"
style={{ imageRendering: 'pixelated' }}
style={{ imageRendering: "pixelated" }}
onError={(e) => {
;(e.target as HTMLImageElement).style.opacity = '0'
(e.target as HTMLImageElement).style.opacity = "0";
}}
/>
</div>
@@ -318,7 +311,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
)}
<Badge variant="outline" className="text-[10px] gap-1">
<Eye className="w-2.5 h-2.5" />
{metadata.visualizationType || '?'}
{metadata.visualizationType || "?"}
</Badge>
</div>
)}
@@ -331,11 +324,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : metadata ? (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
<div className="px-6 pt-3 pb-0">
<TabsList className="w-fit">
<TabsTrigger value="visual" className="gap-1.5">
@@ -343,7 +332,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
Visual
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
{'{ }'}
{"{ }"}
JSON
</TabsTrigger>
</TabsList>
@@ -369,7 +358,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
type="number"
min={1}
value={dims.x}
onChange={(e) => setDim('x', e.target.value)}
onChange={(e) => setDim("x", e.target.value)}
className="mt-1"
/>
</div>
@@ -382,7 +371,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
type="number"
min={1}
value={dims.y}
onChange={(e) => setDim('y', e.target.value)}
onChange={(e) => setDim("y", e.target.value)}
className="mt-1"
/>
</div>
@@ -396,7 +385,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
step={0.01}
min={0}
value={dims.z}
onChange={(e) => setDim('z', e.target.value)}
onChange={(e) => setDim("z", e.target.value)}
className="mt-1"
/>
</div>
@@ -440,9 +429,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Direzioni</h3>
<span className="text-xs text-muted-foreground ml-auto">
{directions.length} attive
</span>
<span className="text-xs text-muted-foreground ml-auto">{directions.length} attive</span>
</div>
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
@@ -451,19 +438,19 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<div className="absolute inset-4 rounded-full border border-muted/50" />
{/* 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)
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 (
<button
type="button"
key={dir}
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
isActive
? 'bg-primary text-primary-foreground border-primary shadow-md scale-110'
: 'bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105'
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
}`}
style={{ left: x, top: y }}
onClick={() => toggleDirection(dir)}
@@ -471,13 +458,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<span className="text-[9px] leading-none">{DIR_LABELS[dir]}</span>
<span className="text-[8px] leading-none opacity-60">{dir}</span>
</button>
)
);
})}
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground font-medium">
{directions.length}/8
</span>
<span className="text-xs text-muted-foreground font-medium">{directions.length}/8</span>
</div>
</div>
</div>
@@ -496,10 +481,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</Label>
<Input
id="viz-type"
value={metadata.visualizationType || ''}
value={metadata.visualizationType || ""}
onChange={(e) =>
setMetadata((prev: unknown) =>
updateNested(prev, 'visualizationType', e.target.value),
updateNested(prev, "visualizationType", e.target.value),
)
}
className="mt-1 font-mono text-sm"
@@ -511,11 +496,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</Label>
<Input
id="logic-type"
value={metadata.logicType || ''}
value={metadata.logicType || ""}
onChange={(e) =>
setMetadata((prev: unknown) =>
updateNested(prev, 'logicType', e.target.value),
)
setMetadata((prev: unknown) => updateNested(prev, "logicType", e.target.value))
}
className="mt-1 font-mono text-sm"
/>
@@ -542,12 +525,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<span>Ink</span>
</div>
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
const layer = layerData as Record<string, unknown>
const layer = layerData as Record<string, unknown>;
return (
<div
key={layerId}
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
idx % 2 === 0 ? 'bg-card' : 'bg-muted/20'
idx % 2 === 0 ? "bg-card" : "bg-muted/20"
}`}
>
<span className="font-mono text-xs">
@@ -566,7 +549,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.z`,
parseInt(e.target.value, 10) || 0,
),
)
);
}}
/>
<Input
@@ -582,12 +565,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
parseInt(e.target.value, 10) || 0,
),
)
);
}}
/>
<Input
className="h-7 text-xs font-mono"
value={(layer.ink as string) ?? ''}
value={(layer.ink as string) ?? ""}
placeholder="—"
onChange={(e) => {
setMetadata((prev: unknown) =>
@@ -596,11 +579,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.ink`,
e.target.value || undefined,
),
)
);
}}
/>
</div>
)
);
})}
</div>
</section>
@@ -623,22 +606,20 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<span className="text-xs font-medium">Variante #{colorId}</span>
{/* Color preview dots */}
<div className="flex gap-1 ml-auto">
{Object.values(colorLayers as Record<string, { color: number }>).map(
(lc, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
key={i}
className="w-4 h-4 rounded-full border border-border shadow-sm"
style={{
backgroundColor: hexToColor(
typeof lc.color === 'number'
? lc.color.toString(16).padStart(6, '0')
: '000000',
),
}}
/>
),
)}
{Object.values(colorLayers as Record<string, { color: number }>).map((lc, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
key={i}
className="w-4 h-4 rounded-full border border-border shadow-sm"
style={{
backgroundColor: hexToColor(
typeof lc.color === "number"
? lc.color.toString(16).padStart(6, "0")
: "000000",
),
}}
/>
))}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
@@ -652,25 +633,20 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Input
className="h-7 text-xs font-mono pl-8"
value={
typeof layerColor.color === 'number'
? layerColor.color
.toString(16)
.padStart(6, '0')
.toUpperCase()
typeof layerColor.color === "number"
? layerColor.color.toString(16).padStart(6, "0").toUpperCase()
: String(layerColor.color)
}
onChange={(e) => {
const hex = e.target.value
.replace(/[^0-9a-fA-F]/g, '')
.slice(0, 6)
const num = parseInt(hex, 16) || 0
const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
const num = parseInt(hex, 16) || 0;
setMetadata((prev: unknown) =>
updateNested(
prev,
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
num,
),
)
);
}}
/>
{/* Color swatch inside input */}
@@ -678,9 +654,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
style={{
backgroundColor: hexToColor(
typeof layerColor.color === 'number'
? layerColor.color.toString(16).padStart(6, '0')
: '000000',
typeof layerColor.color === "number"
? layerColor.color.toString(16).padStart(6, "0")
: "000000",
),
}}
/>
@@ -718,10 +694,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
<div className="flex items-center gap-2 w-full">
{hasChanges && (
<Badge
variant="secondary"
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
>
<Badge variant="secondary" className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]">
Modifiche non salvate
</Badge>
)}
@@ -739,11 +712,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Annulla
</Button>
<Button
onClick={handleSave}
disabled={saving || loading || !metadata}
className="gap-1.5"
>
<Button onClick={handleSave} disabled={saving || loading || !metadata} className="gap-1.5">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Salva
</Button>
@@ -751,5 +720,5 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportFurniClient } from './import-furni-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <ImportFurniClient />
return <ImportFurniClient />;
}
+109 -126
View File
@@ -1,32 +1,22 @@
'use client'
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
PawPrint,
Search,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { AlertCircle, Check, Download, Loader2, Package, PawPrint, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface PetItem {
lib: string
nitroExists: boolean
lib: string;
nitroExists: boolean;
}
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
function PetIcon({ lib }: { lib: string }) {
const [failed, setFailed] = useState(false)
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />
const [failed, setFailed] = useState(false);
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
return (
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
<img
@@ -36,135 +26,135 @@ function PetIcon({ lib }: { lib: string }) {
style={{ maxHeight: 56, maxWidth: 56 }}
onError={() => setFailed(true)}
/>
)
);
}
export function ImportPetsClient() {
const [searchTerm, setSearchTerm] = useState('')
const [pets, setPets] = useState<PetItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [busyLib, setBusyLib] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState("");
const [pets, setPets] = useState<PetItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const fetchPets = useCallback(async (search: string) => {
setLoading(true)
setError('')
setLoading(true);
setError("");
try {
const params = new URLSearchParams()
if (search) params.set('search', search)
const res = await fetch(`/api/admin/import/pets?${params}`)
const data = await res.json()
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/pets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to load')
return
setError(data.error || "Failed to load");
return;
}
setPets(data.pets || [])
setSelected(new Set())
setPets(data.pets || []);
setSelected(new Set());
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchPets('')
}, [fetchPets])
fetchPets("");
}, [fetchPets]);
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets])
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets])
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(lib)) n.delete(lib)
else n.add(lib)
return n
})
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setPets((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)))
setPets((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)));
}
async function importViaSse(items: PetItem[]) {
setBatchProgress({ done: 0, total: items.length })
const res = await fetch('/api/admin/import/pets/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/pets/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
})
});
if (!res.body) {
toast.error('No response stream')
setBatchProgress(null)
return
toast.error("No response stream");
setBatchProgress(null);
return;
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let done = 0
let succeeded = 0
let failed = 0
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith('data: ')) continue
const evt = JSON.parse(part.slice(6))
if (evt.type === 'item_progress' && evt.status === 'done') {
done++
markDone(evt.classname)
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
done++
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`)
if (failed > 0) toast.error(`${failed} failed`)
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
}
async function importOne(p: PetItem) {
setBusyLib(p.lib)
setBusyLib(p.lib);
try {
await importViaSse([p])
await importViaSse([p]);
} finally {
setBusyLib(null)
setBusyLib(null);
}
}
async function importSelected() {
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists)
if (items.length === 0) return
await importViaSse(items)
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (items.length === 0) return;
await importViaSse(items);
}
async function remove(p: PetItem) {
setBusyLib(p.lib)
setBusyLib(p.lib);
try {
const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, {
method: 'DELETE',
})
const data = await res.json()
method: "DELETE",
});
const data = await res.json();
if (res.ok) {
toast.success(`${p.lib} removed`)
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)))
toast.success(`${p.lib} removed`);
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
} else {
toast.error(data.error || 'Delete failed')
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null)
setBusyLib(null);
}
}
@@ -198,10 +188,10 @@ export function ImportPetsClient() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') fetchPets(searchTerm)
if (e.key === 'Escape') {
setSearchTerm('')
fetchPets('')
if (e.key === "Enter") fetchPets(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchPets("");
}
}}
className="pl-9 pr-8"
@@ -210,8 +200,8 @@ export function ImportPetsClient() {
<button
type="button"
onClick={() => {
setSearchTerm('')
fetchPets('')
setSearchTerm("");
fetchPets("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -237,9 +227,7 @@ export function ImportPetsClient() {
)
}
>
{selected.size === importable.length
? 'Deselect All'
: `Select All (${importable.length})`}
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -274,21 +262,16 @@ export function ImportPetsClient() {
)}
{pets.length > 0 && (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
>
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
{pets.map((p) => {
const isSelected = selected.has(p.lib)
const isSelected = selected.has(p.lib);
return (
<div
key={p.lib}
className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!p.nitroExists && (
@@ -296,10 +279,10 @@ export function ImportPetsClient() {
type="button"
onClick={() => toggle(p.lib)}
className={cn(
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
@@ -347,10 +330,10 @@ export function ImportPetsClient() {
</div>
</div>
</div>
)
);
})}
</div>
)}
</div>
)
);
}
+1 -1
View File
@@ -9,5 +9,5 @@ export default function Loading() {
</div>
))}
</div>
)
);
}
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportPetsClient } from './import-pets-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportPetsClient } from "./import-pets-client";
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <ImportPetsClient />
return <ImportPetsClient />;
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { RepairClient } from './repair-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairClient } from "./repair-client";
export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
return <RepairClient />
return <RepairClient />;
}
+247 -277
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
AlertCircle,
@@ -10,13 +10,13 @@ import {
Trash2,
Wrench,
XCircle,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { deleteImportedFurni } from '@/actions/import-furni'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteImportedFurni } from "@/actions/import-furni";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -24,245 +24,238 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type MissingKind = 'nitro' | 'icon' | 'both'
type MissingKind = "nitro" | "icon" | "both";
interface MissingItem {
id: number
classname: string
baseClassname: string
revision: number
type: 's' | 'i'
publicName: string
hasSwf: boolean
hasIcon: boolean
hasNitro: boolean
missing: MissingKind
unrepairable: boolean
id: number;
classname: string;
baseClassname: string;
revision: number;
type: "s" | "i";
publicName: string;
hasSwf: boolean;
hasIcon: boolean;
hasNitro: boolean;
missing: MissingKind;
unrepairable: boolean;
}
interface AuditSummary {
total: number
missingBoth: number
missingNitro: number
missingIcon: number
unrepairable: number
items: MissingItem[]
total: number;
missingBoth: number;
missingNitro: number;
missingIcon: number;
unrepairable: number;
items: MissingItem[];
}
type ItemStatus = 'idle' | 'working' | 'done' | 'partial' | 'failed'
type ItemStatus = "idle" | "working" | "done" | "partial" | "failed";
interface ItemProgress {
status: ItemStatus
message?: string
nitroRepaired?: boolean
iconRepaired?: boolean
unrepairable?: boolean
status: ItemStatus;
message?: string;
nitroRepaired?: boolean;
iconRepaired?: boolean;
unrepairable?: boolean;
}
interface RepairSummary {
succeeded: number
partial: number
failed: number
unrepairable: number
duration: number
succeeded: number;
partial: number;
failed: number;
unrepairable: number;
duration: number;
}
const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = {
all: () => true,
both: (m) => m.missing === 'both',
nitro: (m) => m.missing === 'nitro',
icon: (m) => m.missing === 'icon',
both: (m) => m.missing === "both",
nitro: (m) => m.missing === "nitro",
icon: (m) => m.missing === "icon",
unrepairable: (m) => m.unrepairable,
}
};
export function RepairClient() {
const [summary, setSummary] = useState<AuditSummary | null>(null)
const [auditing, setAuditing] = useState(false)
const [repairing, setRepairing] = useState(false)
const [search, setSearch] = useState('')
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map())
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null)
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false)
const [summary, setSummary] = useState<AuditSummary | null>(null);
const [auditing, setAuditing] = useState(false);
const [repairing, setRepairing] = useState(false);
const [search, setSearch] = useState("");
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map());
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null);
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
// ── Audit ──────────────────────────────────────────────────────────
const runAudit = useCallback(async () => {
setAuditing(true)
setLastSummary(null)
setAuditing(true);
setLastSummary(null);
try {
const res = await fetch('/api/admin/import/repair/audit', { method: 'POST' })
const res = await fetch("/api/admin/import/repair/audit", { method: "POST" });
if (!res.ok) {
toast.error('Audit failed')
return
toast.error("Audit failed");
return;
}
const data = await res.json()
setSummary(data)
setSelected(new Set())
setProgress(new Map())
const data = await res.json();
setSummary(data);
setSelected(new Set());
setProgress(new Map());
toast.success(
`Audit: ${data.items.length} item${data.items.length === 1 ? '' : 's'} need repair (${data.total} scanned)`,
)
`Audit: ${data.items.length} item${data.items.length === 1 ? "" : "s"} need repair (${data.total} scanned)`,
);
} catch {
toast.error('Audit network error')
toast.error("Audit network error");
} finally {
setAuditing(false)
setAuditing(false);
}
}, [])
}, []);
// ── Filters ────────────────────────────────────────────────────────
const filtered = useMemo(() => {
if (!summary) return []
const s = search.trim().toLowerCase()
if (!summary) return [];
const s = search.trim().toLowerCase();
return summary.items.filter((it) => {
if (!TAB_FILTERS[tab](it)) return false
if (!s) return true
if (!TAB_FILTERS[tab](it)) return false;
if (!s) return true;
return (
it.classname.toLowerCase().includes(s) ||
it.publicName.toLowerCase().includes(s) ||
String(it.id).includes(s)
)
})
}, [summary, search, tab])
);
});
}, [summary, search, tab]);
// ── Selection ──────────────────────────────────────────────────────
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname))
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname));
const toggleAll = () => {
setSelected((prev) => {
if (allSelected) {
const next = new Set(prev)
const next = new Set(prev);
for (const i of filtered) {
next.delete(i.classname)
next.delete(i.classname);
}
return next
return next;
}
const next = new Set(prev)
const next = new Set(prev);
for (const i of filtered) {
next.add(i.classname)
next.add(i.classname);
}
return next
})
}
return next;
});
};
const toggleOne = (cn: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(cn)) next.delete(cn)
else next.add(cn)
return next
})
}
const next = new Set(prev);
if (next.has(cn)) next.delete(cn);
else next.add(cn);
return next;
});
};
// ── Repair ─────────────────────────────────────────────────────────
const runRepair = useCallback(async (classnames: string[]) => {
if (classnames.length === 0) return
setRepairing(true)
setLastSummary(null)
if (classnames.length === 0) return;
setRepairing(true);
setLastSummary(null);
// Init progress for these items
setProgress((prev) => {
const next = new Map(prev)
const next = new Map(prev);
for (const cn of classnames) {
next.set(cn, { status: 'working' })
next.set(cn, { status: "working" });
}
return next
})
return next;
});
try {
const res = await fetch('/api/admin/import/repair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const res = await fetch("/api/admin/import/repair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classnames, concurrency: 6 }),
})
});
if (!res.ok || !res.body) {
toast.error('Failed to start repair')
setRepairing(false)
return
toast.error("Failed to start repair");
setRepairing(false);
return;
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith('data: ')) continue
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'repair_progress') {
const event = JSON.parse(line.slice(6));
if (event.type === "repair_progress") {
setProgress((prev) => {
const next = new Map(prev)
const next = new Map(prev);
next.set(event.classname, {
status: event.status as ItemStatus,
message: event.error || (event.warnings?.[0] ?? undefined),
nitroRepaired: event.nitroRepaired,
iconRepaired: event.iconRepaired,
unrepairable: event.unrepairable,
})
return next
})
});
return next;
});
// Promote newly-discovered unrepairable flags onto the audit list
if (event.unrepairable) {
setSummary((prev) => {
if (!prev) return prev
const item = prev.items.find((i) => i.classname === event.classname)
if (!item || item.unrepairable) return prev
if (!prev) return prev;
const item = prev.items.find((i) => i.classname === event.classname);
if (!item || item.unrepairable) return prev;
return {
...prev,
unrepairable: prev.unrepairable + 1,
items: prev.items.map((i) =>
i.classname === event.classname ? { ...i, unrepairable: true } : i,
),
}
})
};
});
}
// Remove repaired items from the audit list on "done"
if (event.status === 'done') {
if (event.status === "done") {
setSummary((prev) => {
if (!prev) return prev
const items = prev.items.filter((i) => i.classname !== event.classname)
const diff = prev.items.length - items.length
if (diff === 0) return prev
const removed = prev.items.find((i) => i.classname === event.classname)
if (!prev) return prev;
const items = prev.items.filter((i) => i.classname !== event.classname);
const diff = prev.items.length - items.length;
if (diff === 0) return prev;
const removed = prev.items.find((i) => i.classname === event.classname);
return {
...prev,
items,
missingBoth: prev.missingBoth - (removed?.missing === 'both' ? 1 : 0),
missingNitro: prev.missingNitro - (removed?.missing === 'nitro' ? 1 : 0),
missingIcon: prev.missingIcon - (removed?.missing === 'icon' ? 1 : 0),
}
})
missingBoth: prev.missingBoth - (removed?.missing === "both" ? 1 : 0),
missingNitro: prev.missingNitro - (removed?.missing === "nitro" ? 1 : 0),
missingIcon: prev.missingIcon - (removed?.missing === "icon" ? 1 : 0),
};
});
setSelected((prev) => {
const next = new Set(prev)
next.delete(event.classname)
return next
})
const next = new Set(prev);
next.delete(event.classname);
return next;
});
}
} else if (event.type === 'repair_complete') {
} else if (event.type === "repair_complete") {
setLastSummary({
succeeded: event.succeeded,
partial: event.partial,
failed: event.failed,
unrepairable: event.unrepairable ?? 0,
duration: event.duration,
})
});
}
} catch {
/* skip malformed */
@@ -270,72 +263,72 @@ export function RepairClient() {
}
}
} catch {
toast.error('Connection lost during repair')
toast.error("Connection lost during repair");
} finally {
setRepairing(false)
setRepairing(false);
}
}, [])
}, []);
// ── Delete unrepairable items ──────────────────────────────────────
const deleteSelected = useCallback(async () => {
const toDelete = Array.from(selected)
if (toDelete.length === 0) return
setConfirmDeleteAll(false)
const toDelete = Array.from(selected);
if (toDelete.length === 0) return;
setConfirmDeleteAll(false);
let ok = 0
let fail = 0
let ok = 0;
let fail = 0;
for (const cn of toDelete) {
setProgress((prev) => {
const next = new Map(prev)
next.set(cn, { status: 'working', message: 'Deleting…' })
return next
})
const next = new Map(prev);
next.set(cn, { status: "working", message: "Deleting…" });
return next;
});
try {
const result = await deleteImportedFurni({ classname: cn })
const result = await deleteImportedFurni({ classname: cn });
if (result.ok) {
ok++
ok++;
setSummary((prev) => {
if (!prev) return prev
const removed = prev.items.find((i) => i.classname === cn)
if (!removed) return prev
if (!prev) return prev;
const removed = prev.items.find((i) => i.classname === cn);
if (!removed) return prev;
return {
...prev,
total: prev.total - 1,
items: prev.items.filter((i) => i.classname !== cn),
missingBoth: prev.missingBoth - (removed.missing === 'both' ? 1 : 0),
missingNitro: prev.missingNitro - (removed.missing === 'nitro' ? 1 : 0),
missingIcon: prev.missingIcon - (removed.missing === 'icon' ? 1 : 0),
}
})
missingBoth: prev.missingBoth - (removed.missing === "both" ? 1 : 0),
missingNitro: prev.missingNitro - (removed.missing === "nitro" ? 1 : 0),
missingIcon: prev.missingIcon - (removed.missing === "icon" ? 1 : 0),
};
});
setSelected((prev) => {
const next = new Set(prev)
next.delete(cn)
return next
})
const next = new Set(prev);
next.delete(cn);
return next;
});
} else {
fail++
fail++;
setProgress((prev) => {
const next = new Map(prev)
next.set(cn, { status: 'failed', message: 'Delete failed' })
return next
})
const next = new Map(prev);
next.set(cn, { status: "failed", message: "Delete failed" });
return next;
});
}
} catch {
fail++
fail++;
setProgress((prev) => {
const next = new Map(prev)
next.set(cn, { status: 'failed', message: 'Delete error' })
return next
})
const next = new Map(prev);
next.set(cn, { status: "failed", message: "Delete error" });
return next;
});
}
}
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ''}`)
}, [selected])
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`);
}, [selected]);
// ── Render ─────────────────────────────────────────────────────────
const selectedArray = Array.from(selected)
const selectedCount = selectedArray.length
const selectedArray = Array.from(selected);
const selectedCount = selectedArray.length;
return (
<div className="space-y-6">
@@ -344,9 +337,8 @@ export function RepairClient() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1>
<p className="text-muted-foreground">
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and
icons for items already imported. Items with no public asset on habbo.com can be deleted
from here.
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and icons for
items already imported. Items with no public asset on habbo.com can be deleted from here.
</p>
</div>
<div className="flex gap-2">
@@ -357,7 +349,7 @@ export function RepairClient() {
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? 'Re-scan' : 'Scan Assets'}
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? "Re-scan" : "Scan Assets"}
</>
)}
</Button>
@@ -368,12 +360,10 @@ export function RepairClient() {
{!summary && !auditing && (
<div className="rounded-md border bg-muted/30 p-10 text-center">
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Click &ldquo;Scan Assets&rdquo; to audit imported furniture.
</p>
<p className="font-medium">Click &ldquo;Scan Assets&rdquo; to audit imported furniture.</p>
<p className="text-sm text-muted-foreground">
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k
items takes a few seconds.
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k items takes
a few seconds.
</p>
</div>
)}
@@ -388,12 +378,7 @@ export function RepairClient() {
tone="danger"
sub="no .nitro · no icon"
/>
<StatCard
label="Missing .nitro"
value={summary.missingNitro}
tone="warn"
sub="shows black cube"
/>
<StatCard label="Missing .nitro" value={summary.missingNitro} tone="warn" sub="shows black cube" />
<StatCard
label="Missing icon"
value={summary.missingIcon}
@@ -437,9 +422,7 @@ export function RepairClient() {
<div className="rounded-md border bg-muted/30 p-10 text-center">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
<p className="font-medium">All imported items have their assets on disk.</p>
<p className="text-sm text-muted-foreground">
{summary.total} items scanned, nothing to repair.
</p>
<p className="text-sm text-muted-foreground">{summary.total} items scanned, nothing to repair.</p>
</div>
)}
@@ -461,16 +444,15 @@ export function RepairClient() {
variant="default"
onClick={() => {
// Skip unrepairable items — no point paying the network cost for them
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname)
const unrep =
summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? []
const unrepSet = new Set(unrep)
const targets = pool.filter((cn) => !unrepSet.has(cn))
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname);
const unrep = summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? [];
const unrepSet = new Set(unrep);
const targets = pool.filter((cn) => !unrepSet.has(cn));
if (targets.length === 0) {
toast.info('Nothing to repair — selected items are all flagged unrepairable.')
return
toast.info("Nothing to repair — selected items are all flagged unrepairable.");
return;
}
runRepair(targets)
runRepair(targets);
}}
disabled={repairing || filtered.length === 0}
>
@@ -502,9 +484,9 @@ export function RepairClient() {
variant="destructive"
onClick={() => {
// Auto-select every unrepairable item, then open the same confirm dialog
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname)
setSelected(new Set(unrep))
setConfirmDeleteAll(true)
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname);
setSelected(new Set(unrep));
setConfirmDeleteAll(true);
}}
disabled={repairing}
>
@@ -529,11 +511,7 @@ export function RepairClient() {
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
<Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />
</TableHead>
<TableHead className="w-[80px]">ID</TableHead>
<TableHead>Classname</TableHead>
@@ -576,13 +554,12 @@ export function RepairClient() {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
Delete {selectedCount} imported item{selectedCount === 1 ? '' : 's'}?
Delete {selectedCount} imported item{selectedCount === 1 ? "" : "s"}?
</DialogTitle>
<DialogDescription>
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the
entry in <code>FurnitureData.json</code>, and any remaining asset files on disk. Use
this for items that have no public asset on habbo.com and therefore cannot be
repaired. This cannot be undone.
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the entry in{" "}
<code>FurnitureData.json</code>, and any remaining asset files on disk. Use this for items that
have no public asset on habbo.com and therefore cannot be repaired. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -596,7 +573,7 @@ export function RepairClient() {
</DialogContent>
</Dialog>
</div>
)
);
}
// ── Small cell helpers ────────────────────────────────────────────────
@@ -607,24 +584,24 @@ function StatCard({
tone,
sub,
}: {
label: string
value: number
tone: 'default' | 'danger' | 'warn' | 'info'
sub?: string
label: string;
value: number;
tone: "default" | "danger" | "warn" | "info";
sub?: string;
}) {
const tones = {
default: 'border-border',
danger: 'border-destructive/40 bg-destructive/5',
warn: 'border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]',
info: 'border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5',
}
default: "border-border",
danger: "border-destructive/40 bg-destructive/5",
warn: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5",
};
return (
<div className={`rounded-md border p-4 ${tones[tone]}`}>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</div>
)
);
}
function FileBadge({ present }: { present: boolean }) {
@@ -639,49 +616,49 @@ function FileBadge({ present }: { present: boolean }) {
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
<XCircle className="mr-1 h-3 w-3" /> Missing
</Badge>
)
);
}
function StatusCell({ progress }: { progress?: ItemProgress }) {
if (!progress || progress.status === 'idle')
return <span className="text-muted-foreground text-xs">—</span>
if (progress.status === 'working') {
if (!progress || progress.status === "idle")
return <span className="text-muted-foreground text-xs">—</span>;
if (progress.status === "working") {
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{progress.message || 'Working…'}
{progress.message || "Working…"}
</span>
)
);
}
if (progress.status === 'done') {
if (progress.status === "done") {
const parts = [
progress.nitroRepaired ? '.nitro ✓' : null,
progress.iconRepaired ? 'icon ✓' : null,
].filter(Boolean)
progress.nitroRepaired ? ".nitro ✓" : null,
progress.iconRepaired ? "icon ✓" : null,
].filter(Boolean);
return (
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
<CheckCircle2 className="h-3 w-3" />
{parts.length > 0 ? parts.join(' · ') : 'Repaired'}
{parts.length > 0 ? parts.join(" · ") : "Repaired"}
</span>
)
);
}
if (progress.status === 'partial') {
if (progress.status === "partial") {
return (
<span
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
title={progress.message}
>
<AlertCircle className="h-3 w-3" />
Partial · {progress.message || 'check logs'}
Partial · {progress.message || "check logs"}
</span>
)
);
}
return (
<span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}>
<XCircle className="h-3 w-3" />
{progress.message || 'Failed'}
{progress.message || "Failed"}
</span>
)
);
}
function ItemRow({
@@ -692,39 +669,32 @@ function ItemRow({
onRepair,
disabled,
}: {
item: MissingItem
selected: boolean
onToggle: () => void
progress?: ItemProgress
onRepair: () => void
disabled: boolean
item: MissingItem;
selected: boolean;
onToggle: () => void;
progress?: ItemProgress;
onRepair: () => void;
disabled: boolean;
}) {
const isUnrepairable = item.unrepairable || progress?.unrepairable
const isUnrepairable = item.unrepairable || progress?.unrepairable;
return (
<TableRow className={isUnrepairable ? 'bg-destructive/5' : undefined}>
<TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
<TableCell>
<Checkbox
checked={selected}
onCheckedChange={onToggle}
aria-label={`Select ${item.classname}`}
/>
<Checkbox checked={selected} onCheckedChange={onToggle} aria-label={`Select ${item.classname}`} />
</TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{item.classname}</span>
{isUnrepairable && (
<Badge
variant="secondary"
className="border-destructive/40 bg-destructive/10 text-destructive"
>
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">{item.publicName}</div>
</TableCell>
<TableCell className="text-xs">{item.revision || '—'}</TableCell>
<TableCell className="text-xs">{item.revision || "—"}</TableCell>
<TableCell>
<FileBadge present={item.hasNitro} />
</TableCell>
@@ -742,10 +712,10 @@ function ItemRow({
size="sm"
variant="outline"
onClick={onRepair}
disabled={disabled || progress?.status === 'working' || isUnrepairable}
title={isUnrepairable ? 'habbo.com does not have this asset' : 'Retry download + convert'}
disabled={disabled || progress?.status === "working" || isUnrepairable}
title={isUnrepairable ? "habbo.com does not have this asset" : "Retry download + convert"}
>
{progress?.status === 'working' ? (
{progress?.status === "working" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Wrench className="h-3 w-3" />
@@ -753,5 +723,5 @@ function ItemRow({
</Button>
</TableCell>
</TableRow>
)
);
}
+3 -1
View File
@@ -188,7 +188,9 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
{initial}
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">{staff.username}</div>
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
{staff.username}
</div>
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
{t("rankLabel", { rank: staff.rank })}
</span>
+46 -46
View File
@@ -1,39 +1,39 @@
'use client'
"use client";
import { useState } from 'react'
import { DataTable } from '@/components/admin/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { DataTableColumn, PaginatedResult } from '@/types'
import { useState } from "react";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface AuditRow {
id: number
username: string
action: string
target: string
targetId: number | null
diff: string | null
createdAt: string
id: number;
username: string;
action: string;
target: string;
targetId: number | null;
diff: string | null;
createdAt: string;
}
function DiffCell({ diff }: { diff: string | null }) {
const [open, setOpen] = useState(false)
if (!diff) return <span className="text-muted-foreground">-</span>
const [open, setOpen] = useState(false);
if (!diff) return <span className="text-muted-foreground">-</span>;
let parsed: Record<string, { from: unknown; to: unknown }>
let parsed: Record<string, { from: unknown; to: unknown }>;
try {
parsed = JSON.parse(diff)
parsed = JSON.parse(diff);
} catch {
return <span className="text-muted-foreground">-</span>
return <span className="text-muted-foreground">-</span>;
}
const keys = Object.keys(parsed)
if (keys.length === 0) return <span className="text-muted-foreground">-</span>
const keys = Object.keys(parsed);
if (keys.length === 0) return <span className="text-muted-foreground">-</span>;
const summary =
keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? '∅')} → ${String(parsed[keys[0]].to ?? '∅')}`
: `${keys.length} changes`
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: `${keys.length} changes`;
return (
<div>
@@ -50,53 +50,53 @@ function DiffCell({ diff }: { diff: string | null }) {
{keys.map((k) => (
<div key={k} className="flex gap-1">
<span className="font-medium">{k}:</span>
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? '∅')}</span>
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? "∅")}</span>
<span>→</span>
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? '∅')}</span>
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? "∅")}</span>
</div>
))}
</div>
)}
</div>
)
);
}
const columns: DataTableColumn<AuditRow>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'username', label: 'User', sortable: true },
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User", sortable: true },
{
key: 'action',
label: 'Action',
key: "action",
label: "Action",
sortable: true,
render: (value) => {
const v = String(value)
let variant: 'default' | 'secondary' | 'destructive' = 'secondary'
if (v.includes('create') || v.includes('add')) variant = 'default'
if (v.includes('delete') || v.includes('remove') || v.includes('ban')) variant = 'destructive'
return <Badge variant={variant}>{v}</Badge>
const v = String(value);
let variant: "default" | "secondary" | "destructive" = "secondary";
if (v.includes("create") || v.includes("add")) variant = "default";
if (v.includes("delete") || v.includes("remove") || v.includes("ban")) variant = "destructive";
return <Badge variant={variant}>{v}</Badge>;
},
},
{ key: 'target', label: 'Target', sortable: true },
{ key: "target", label: "Target", sortable: true },
{
key: 'targetId',
label: 'Target ID',
render: (value) => <span>{value != null ? String(value) : '-'}</span>,
key: "targetId",
label: "Target ID",
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
},
{
key: 'diff',
label: 'Details',
key: "diff",
label: "Details",
render: (_value, row) => <DiffCell diff={row.diff} />,
},
{
key: 'createdAt',
label: 'Date',
key: "createdAt",
label: "Date",
sortable: true,
render: (value) => <span>{String(value) || 'N/A'}</span>,
render: (value) => <span>{String(value) || "N/A"}</span>,
},
]
];
interface AuditTableProps {
data: PaginatedResult<AuditRow>
data: PaginatedResult<AuditRow>;
}
export function AuditTable({ data }: AuditTableProps) {
@@ -107,5 +107,5 @@ export function AuditTable({ data }: AuditTableProps) {
searchPlaceholder="Search by action or target..."
exportUrl="/api/admin/export?type=audit"
/>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function AuditLogsLoading() {
return (
@@ -24,5 +24,5 @@ export default function AuditLogsLoading() {
))}
</div>
</div>
)
);
}
+17 -17
View File
@@ -1,29 +1,29 @@
import { redirect } from 'next/navigation'
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { getAuditLogs } from '@/lib/services/audit'
import { AuditTable } from './audit-table'
import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getAuditLogs } from "@/lib/services/audit";
import { AuditTable } from "./audit-table";
export default async function AuditLogsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise
const { session, permissions } = await getAdminContext()
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const params = await searchParams
const sp = new URLSearchParams(params)
const { search, perPage, page } = parseListParams(sp)
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp);
const result = await getAuditLogs({ search, page, perPage })
const result = await getAuditLogs({ search, page, perPage });
const pagination = calcPagination(result.total, result.page, result.perPage)
const pagination = calcPagination(result.total, result.page, result.perPage);
const rows = result.rows.map((r) => ({
id: r.id,
@@ -33,7 +33,7 @@ export default async function AuditLogsPage({
targetId: r.targetId,
diff: r.diff,
createdAt: r.createdAt,
}))
}));
return (
<div className="space-y-6">
@@ -44,5 +44,5 @@ export default async function AuditLogsPage({
<AuditTable data={{ rows, ...pagination }} />
</div>
)
);
}
+19 -19
View File
@@ -1,36 +1,36 @@
'use client'
"use client";
import { DataTable } from '@/components/admin/data-table'
import { formatTimestamp } from '@/lib/admin-helpers'
import type { DataTableColumn, PaginatedResult } from '@/types'
import { DataTable } from "@/components/admin/data-table";
import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface ChatLogRow {
id: number
username: string
message: string
timestamp: number
roomId: number
id: number;
username: string;
message: string;
timestamp: number;
roomId: number;
}
const columns: DataTableColumn<ChatLogRow>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'username', label: 'User' },
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{
key: 'message',
label: 'Message',
key: "message",
label: "Message",
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
},
{
key: 'timestamp',
label: 'Time',
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: 'roomId', label: 'Room ID', sortable: true },
]
{ key: "roomId", label: "Room ID", sortable: true },
];
interface ChatTableProps {
data: PaginatedResult<ChatLogRow>
data: PaginatedResult<ChatLogRow>;
}
export function ChatTable({ data }: ChatTableProps) {
@@ -41,5 +41,5 @@ export function ChatTable({ data }: ChatTableProps) {
searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs"
/>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function ChatLogsLoading() {
return (
@@ -24,5 +24,5 @@ export default function ChatLogsLoading() {
))}
</div>
</div>
)
);
}
+9 -9
View File
@@ -1,19 +1,19 @@
import { fetchAdminList } from '@/lib/admin-list'
import { PERMS } from '@/lib/permissions'
import { ChatTable } from './chat-table'
import { fetchAdminList } from "@/lib/admin-list";
import { PERMS } from "@/lib/permissions";
import { ChatTable } from "./chat-table";
export default async function ChatLogsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: 'chatLog',
searchFields: ['message', 'user.username'],
model: "chatLog",
searchFields: ["message", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
@@ -26,7 +26,7 @@ export default async function ChatLogsPage({
},
paramsPromise,
searchParams,
)
);
return (
<div className="space-y-6">
@@ -45,5 +45,5 @@ export default async function ChatLogsPage({
}}
/>
</div>
)
);
}
+20 -20
View File
@@ -1,38 +1,38 @@
'use client'
"use client";
import { DataTable } from '@/components/admin/data-table'
import { formatTimestamp } from '@/lib/admin-helpers'
import type { DataTableColumn, PaginatedResult } from '@/types'
import { DataTable } from "@/components/admin/data-table";
import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface CommandLogRow {
id: number
username: string
command: string
params: string
timestamp: number
id: number;
username: string;
command: string;
params: string;
timestamp: number;
}
const columns: DataTableColumn<CommandLogRow>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'username', label: 'User' },
{ key: 'command', label: 'Command', sortable: true },
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{ key: "command", label: "Command", sortable: true },
{
key: 'params',
label: 'Params',
key: "params",
label: "Params",
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || '-'}</span>
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || "-"}</span>
),
},
{
key: 'timestamp',
label: 'Time',
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
]
];
interface CommandsTableProps {
data: PaginatedResult<CommandLogRow>
data: PaginatedResult<CommandLogRow>;
}
export function CommandsTable({ data }: CommandsTableProps) {
@@ -43,5 +43,5 @@ export function CommandsTable({ data }: CommandsTableProps) {
searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands"
/>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function CommandLogsLoading() {
return (
@@ -24,5 +24,5 @@ export default function CommandLogsLoading() {
))}
</div>
</div>
)
);
}
+9 -9
View File
@@ -1,19 +1,19 @@
import { fetchAdminList } from '@/lib/admin-list'
import { PERMS } from '@/lib/permissions'
import { CommandsTable } from './commands-table'
import { fetchAdminList } from "@/lib/admin-list";
import { PERMS } from "@/lib/permissions";
import { CommandsTable } from "./commands-table";
export default async function CommandLogsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: 'commandLog',
searchFields: ['command', 'params', 'user.username'],
model: "commandLog",
searchFields: ["command", "params", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
@@ -26,7 +26,7 @@ export default async function CommandLogsPage({
},
paramsPromise,
searchParams,
)
);
return (
<div className="space-y-6">
@@ -45,5 +45,5 @@ export default async function CommandLogsPage({
}}
/>
</div>
)
);
}
+7 -1
View File
@@ -15,7 +15,13 @@ function fromDate(d: Date | null | undefined): string {
export default async function AdminStaffActivities({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string; authorization?: string }>;
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
}) {
const t = await getTranslations("pages.admin.logs");
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff. Show more