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: | run: |
set -e set -e
echo "--- EPIC WEB CONTROL: Starting Auto-Cleanup & Deploy ---" echo "--- EPIC WEB CONTROL: Starting Auto-Cleanup & Deploy ---"
# 1. Clean up unused build images safely # 1. Clean up unused build images safely
docker image prune -f docker image prune -f
# 2. Navigate to your website directory # 2. Navigate to your website directory
cd /var/www/atom-nexst/ cd /var/www/atom-nexst/
# CRITICAL: Prevent Git permission blocks caused by the www-data ownership change # CRITICAL: Prevent Git permission blocks caused by the www-data ownership change
git config --global --add safe.directory /var/www/atom-nexst git config --global --add safe.directory /var/www/atom-nexst
# Point Git directly to the local Gitea folder path # Point Git directly to the local Gitea folder path
git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/ git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
# 3. Fetch and update code # 3. Fetch and update code
git fetch origin --prune git fetch origin --prune
git reset --hard origin/main git reset --hard origin/main
# Preserve .next/cache so Next.js can reuse its incremental build cache. # Preserve .next/cache so Next.js can reuse its incremental build cache.
# next build safely refreshes stale server/static output itself. # next build safely refreshes stale server/static output itself.
rm -rf .output dist rm -rf .output dist
# 4. Configure trusted dependencies globally and install cleanly # 4. Configure trusted dependencies globally and install cleanly
export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp" export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp"
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
# 5. Apply versioned CMS migrations and generate the Prisma client safely # 5. Apply versioned CMS migrations and generate the Prisma client safely
pnpm db:migrate pnpm db:migrate
pnpm prisma:generate pnpm prisma:generate
# 6. Next.js Build # 6. Next.js Build
pnpm build pnpm build
# 7. Fix ownership: Build first, THEN set permissions for the web server # 7. Fix ownership: Build first, THEN set permissions for the web server
sudo chown -R www-data:www-data /var/www/atom-nexst/ sudo chown -R www-data:www-data /var/www/atom-nexst/
# 8. Hard restart of the Systemd service to clear memory cache # 8. Hard restart of the Systemd service to clear memory cache
echo "Hard resetting systemd service..." echo "Hard resetting systemd service..."
sudo systemctl stop atom-nexst.service sudo systemctl stop atom-nexst.service
sudo systemctl start atom-nexst.service sudo systemctl start atom-nexst.service
# Extra health check: Ensure the service is actually running # Extra health check: Ensure the service is actually running
sleep 2 sleep 2
if ! systemctl is-active --quiet atom-nexst.service; then if ! systemctl is-active --quiet atom-nexst.service; then
echo "ERROR: atom-nexst.service failed to start!" >&2 echo "ERROR: atom-nexst.service failed to start!" >&2
exit 1 exit 1
fi fi
echo "--- Deployment successfully completed ---" 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", () => { describe("mysqlConnectionUrl", () => {
it("removes Prisma-only pool options before passing the URL to MySQL2", () => { 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("connection_limit");
expect(result).not.toContain("pool_timeout"); expect(result).not.toContain("pool_timeout");
expect(result).toContain("charset=utf8mb4"); expect(result).toContain("charset=utf8mb4");
+1 -3
View File
@@ -1,7 +1,5 @@
export function splitSqlStatements(sql: string): string[] { export function splitSqlStatements(sql: string): string[] {
const withoutComments = sql const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*--.*$/gm, "");
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*--.*$/gm, "");
return withoutComments return withoutComments
.split(";") .split(";")
+7 -1
View File
@@ -19,7 +19,13 @@ export async function updateBadge({ code, name, desc }: { code: string; name: st
await prisma.websiteBadges.upsert({ await prisma.websiteBadges.upsert({
where: { badgeKey: code }, where: { badgeKey: code },
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() }, 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"); revalidatePath("/admin/import/badges");
} }
+34 -34
View File
@@ -1,70 +1,70 @@
'use server' "use server";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
const bannerSchema = z.object({ const bannerSchema = z.object({
title: z.string().min(1).max(255), 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), image: z.string().max(500),
link: z.string().max(500).optional().default(''), link: z.string().max(500).optional().default(""),
color: z.string().max(20).optional().default(''), color: z.string().max(20).optional().default(""),
isActive: z.coerce.number().int().min(0).max(1).default(1), isActive: z.coerce.number().int().min(0).max(1).default(1),
sortOrder: z.coerce.number().int().min(0).default(0), sortOrder: z.coerce.number().int().min(0).default(0),
startDate: z.string().max(50).nullable().optional(), startDate: z.string().max(50).nullable().optional(),
endDate: z.string().max(50).nullable().optional(), endDate: z.string().max(50).nullable().optional(),
}) });
export const createBanner = adminAction( export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, { permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => { async (ctx) => {
const banner = await prisma.websiteBanner.create({ data: ctx.data }) const banner = await prisma.websiteBanner.create({ data: ctx.data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'banner_create', action: "banner_create",
target: 'WebsiteBanner', target: "WebsiteBanner",
targetId: banner.id, targetId: banner.id,
after: { title: banner.title }, 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( export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data const { id, ...data } = ctx.data;
const existing = await prisma.websiteBanner.findUnique({ where: { id } }) const existing = await prisma.websiteBanner.findUnique({ where: { id } });
if (!existing) throw new ActionError('Banner not found') if (!existing) throw new ActionError("Banner not found");
await prisma.websiteBanner.update({ where: { id }, data }) await prisma.websiteBanner.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'banner_update', action: "banner_update",
target: 'WebsiteBanner', target: "WebsiteBanner",
targetId: id, 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( export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => { async (ctx) => {
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } }) await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'banner_delete', action: "banner_delete",
target: 'WebsiteBanner', target: "WebsiteBanner",
targetId: ctx.data.id, 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 { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared"; 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 staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } }); const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
await logStaffActivity({ 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 } }; 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 staff = await requireStaff();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let banned = 0; let banned = 0;
@@ -53,7 +65,17 @@ export async function bulkBan({ userIds, reason, duration }: { userIds: number[]
return { ok: true as const, data: { banned } }; 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(); const staff = await requireStaff();
let given = 0; let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; 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 } }; 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(); const staff = await requireStaff();
let given = 0; let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; 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"); 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(); const staff = await requireStaff();
await prisma.catalogItemsBc.update({ where: { id }, data: data as any }); await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
await logStaffActivity({ await logStaffActivity({
@@ -46,7 +55,16 @@ export async function updateBcItem({ id, ...data }: { id: number; itemIds?: stri
revalidatePath("/admin/catalog/builder-club"); 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 staff = await requireStaff();
const created = await prisma.catalogItemsBc.create({ const created = await prisma.catalogItemsBc.create({
data: { pageId, ...data }, 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" }) { export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
await requireStaff(); 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; if (!page) return;
await prisma.catalogPagesBc.update({ await prisma.catalogPagesBc.update({
where: { id }, 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: {} }; 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(); await requireStaff();
for (const { id, orderNumber } of orders) { for (const { id, orderNumber } of orders) {
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } }); 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: {} }; 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(); const staff = await requireStaff();
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any }); await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
if (baseItem) { if (baseItem) {
@@ -90,15 +102,22 @@ export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: n
return { ok: true as const, data: {} }; 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(); await requireStaff();
let namesUpdated = 0; let namesUpdated = 0;
let descriptionsUpdated = 0; let descriptionsUpdated = 0;
let furniDataUpdated = 0; let furniDataUpdated = 0;
let furniDataInserted = 0; const furniDataInserted = 0;
for (const item of items) { 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 (!existing) continue;
if (item.publicName && item.publicName !== existing.catalogName) { if (item.publicName && item.publicName !== existing.catalogName) {
@@ -127,5 +146,14 @@ export async function translateCatalogItems({ items }: { items: Array<{ id: numb
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); 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 { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared"; 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(); const staff = await requireStaff();
await prisma.catalogPages.update({ where: { id }, data: fields as any }); await prisma.catalogPages.update({ where: { id }, data: fields as any });
await rcon.updateCatalog(); await rcon.updateCatalog();
@@ -37,9 +40,18 @@ export async function deleteCatalogPage({ id }: { id: number }) {
return { ok: true as const, data: {} }; 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(); 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" }; if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible"; const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.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: {} }; 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 staff = await requireStaff();
const created = await prisma.catalogPages.create({ const created = await prisma.catalogPages.create({
data: { data: {
@@ -83,11 +105,7 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: created.id } };
} }
export async function reorderTreePage(input: { export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) {
pageId: number;
newParentId?: number;
newOrderNum: number;
}) {
await requireStaff(); await requireStaff();
await prisma.catalogPages.update({ await prisma.catalogPages.update({
where: { id: input.pageId }, where: { id: input.pageId },
+17 -17
View File
@@ -1,39 +1,39 @@
'use server' "use server";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { actionOk } from '@/lib/safe-action-shared' import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
import { rcon } from '@/lib/services/rcon' import { rcon } from "@/lib/services/rcon";
const saveEmulatorSettingsSchema = z.object({ const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()), settings: z.record(z.string(), z.string()),
}) });
export const saveEmulatorSettings = adminAction( export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema }, { permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => { async (ctx) => {
const entries = Object.entries(ctx.data.settings) const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) { for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({ await prisma.emulatorSettings.upsert({
where: { key }, where: { key },
update: { value: String(value) }, update: { value: String(value) },
create: { key, value: String(value) }, create: { key, value: String(value) },
}) });
} }
await rcon.updateConfig() await rcon.updateConfig();
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'emulator_settings_update', action: "emulator_settings_update",
target: 'EmulatorSettings', target: "EmulatorSettings",
after: ctx.data.settings, 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 { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
import { import {
createEventSchema, createEventSchema,
eventPrizeSchema, eventPrizeSchema,
eventTypeSchema, eventTypeSchema,
eventWinnerSchema, eventWinnerSchema,
updateEventSchema, updateEventSchema,
} from '@/lib/validators/event' } from "@/lib/validators/event";
// ── Event Types ───────────────────────────────────────────────────── // ── Event Types ─────────────────────────────────────────────────────
@@ -21,63 +21,63 @@ export const createEventType = adminAction(
async (ctx) => { async (ctx) => {
const eventType = await prisma.websiteEventType.create({ const eventType = await prisma.websiteEventType.create({
data: ctx.data, data: ctx.data,
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_type_create', action: "event_type_create",
target: 'WebsiteEventType', target: "WebsiteEventType",
targetId: eventType.id, targetId: eventType.id,
after: { name: eventType.name }, after: { name: eventType.name },
}) });
return actionOk({ id: eventType.id }) return actionOk({ id: eventType.id });
}, },
) );
const updateEventTypeInput = eventTypeSchema.partial().extend({ const updateEventTypeInput = eventTypeSchema.partial().extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const updateEventType = adminAction( export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({ where: { id } }) const existing = await prisma.websiteEventType.findUnique({ where: { id } });
if (!existing) throw new ActionError('Event type not found') if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.update({ where: { id }, data }) await prisma.websiteEventType.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_type_update', action: "event_type_update",
target: 'WebsiteEventType', target: "WebsiteEventType",
targetId: id, targetId: id,
before: { name: existing.name }, before: { name: existing.name },
after: data, after: data,
}) });
return actionOk({ id }) return actionOk({ id });
}, },
) );
const deleteEventTypeInput = z.object({ const deleteEventTypeInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const deleteEventType = adminAction( export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } }) const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } });
if (!existing) throw new ActionError('Event type not found') 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({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_type_delete', action: "event_type_delete",
target: 'WebsiteEventType', target: "WebsiteEventType",
targetId: ctx.data.id, targetId: ctx.data.id,
before: { name: existing.name }, before: { name: existing.name },
}) });
return actionOk() return actionOk();
}, },
) );
// ── Events ────────────────────────────────────────────────────────── // ── Events ──────────────────────────────────────────────────────────
@@ -89,97 +89,97 @@ export const createEvent = adminAction(
...ctx.data, ...ctx.data,
hostUserId: Number(ctx.session.user.id), hostUserId: Number(ctx.session.user.id),
}, },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_create', action: "event_create",
target: 'WebsiteEvent', target: "WebsiteEvent",
targetId: event.id, targetId: event.id,
after: { title: event.title }, after: { title: event.title },
}) });
return actionOk({ id: event.id }) return actionOk({ id: event.id });
}, },
) );
const updateEventInput = updateEventSchema.extend({ const updateEventInput = updateEventSchema.extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const updateEvent = adminAction( export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data const { id, ...data } = ctx.data;
const existing = await prisma.websiteEvent.findUnique({ where: { id } }) const existing = await prisma.websiteEvent.findUnique({ where: { id } });
if (!existing) throw new ActionError('Event not found') if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.update({ where: { id }, data }) await prisma.websiteEvent.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_update', action: "event_update",
target: 'WebsiteEvent', target: "WebsiteEvent",
targetId: id, targetId: id,
before: { title: existing.title, status: existing.status }, before: { title: existing.title, status: existing.status },
after: data, after: data,
}) });
return actionOk({ id }) return actionOk({ id });
}, },
) );
const deleteEventInput = z.object({ const deleteEventInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const deleteEvent = adminAction( export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } }) const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } });
if (!existing) throw new ActionError('Event not found') 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({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_delete', action: "event_delete",
target: 'WebsiteEvent', target: "WebsiteEvent",
targetId: ctx.data.id, targetId: ctx.data.id,
before: { title: existing.title }, before: { title: existing.title },
}) });
return actionOk() return actionOk();
}, },
) );
// ── Prizes ────────────────────────────────────────────────────────── // ── Prizes ──────────────────────────────────────────────────────────
export const addEventPrize = adminAction( export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => { async (ctx) => {
const prize = await prisma.websiteEventPrize.create({ data: ctx.data }) const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
return actionOk({ id: prize.id }) 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( export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput }, { permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => { async (ctx) => {
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } }) await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
return actionOk() return actionOk();
}, },
) );
// ── Winners ───────────────────────────────────────────────────────── // ── Winners ─────────────────────────────────────────────────────────
export const addEventWinner = adminAction( export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => { async (ctx) => {
const winner = await prisma.websiteEventWinner.create({ data: ctx.data }) const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'event_winner_add', action: "event_winner_add",
target: 'WebsiteEventWinner', target: "WebsiteEventWinner",
targetId: winner.id, targetId: winner.id,
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position }, 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 { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; 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(); await requireStaff();
try { try {
await prisma.websiteBadges.upsert({ await prisma.websiteBadges.upsert({
where: { badgeKey: code }, where: { badgeKey: code },
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() }, 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 }; return { ok: true as const };
} catch { } catch {
+13 -12
View File
@@ -1,19 +1,20 @@
'use server' "use server";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { actionOk } from '@/lib/safe-action-shared' import { actionOk } from "@/lib/safe-action-shared";
import { cleanupConvertedSwfs, deleteImportedItem } from '@/lib/services/furni-import' import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import";
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => { export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
const result = await cleanupConvertedSwfs() const result = await cleanupConvertedSwfs();
return actionOk(result as unknown as Record<string, unknown>) 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( export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema }, { 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 { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
import { rcon } from '@/lib/services/rcon' import { rcon } from "@/lib/services/rcon";
// ── CFH Ticket Actions ────────────────────────────────────────────── // ── 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( export const assignCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }) const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
if (!ticket) throw new ActionError('Ticket not found') if (!ticket) throw new ActionError("Ticket not found");
await prisma.supportTickets.update({ await prisma.supportTickets.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { modId: ctx.session.user.id, state: 1 }, data: { modId: ctx.session.user.id, state: 1 },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'cfh_assign', action: "cfh_assign",
target: 'support_tickets', target: "support_tickets",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
}) });
return actionOk() return actionOk();
}, },
) );
const cfhStateSchema = z.object({ const cfhStateSchema = z.object({
ticketId: z.coerce.number().int().positive(), ticketId: z.coerce.number().int().positive(),
state: z.coerce.number().int().min(0).max(3), state: z.coerce.number().int().min(0).max(3),
}) });
export const updateCfhState = adminAction( export const updateCfhState = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }) const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
if (!ticket) throw new ActionError('Ticket not found') if (!ticket) throw new ActionError("Ticket not found");
await prisma.supportTickets.update({ await prisma.supportTickets.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { state: ctx.data.state, modId: ctx.session.user.id }, data: { state: ctx.data.state, modId: ctx.session.user.id },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'cfh_state_change', action: "cfh_state_change",
target: 'support_tickets', target: "support_tickets",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { state: ticket.state }, before: { state: ticket.state },
after: { state: ctx.data.state }, after: { state: ctx.data.state },
}) });
return actionOk() return actionOk();
}, },
) );
export const closeCfhTicket = adminAction( export const closeCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
@@ -69,138 +69,138 @@ export const closeCfhTicket = adminAction(
await prisma.supportTickets.update({ await prisma.supportTickets.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { state: 2, modId: ctx.session.user.id }, data: { state: 2, modId: ctx.session.user.id },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'cfh_close', action: "cfh_close",
target: 'support_tickets', target: "support_tickets",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
}) });
return actionOk() return actionOk();
}, },
) );
// ── Quick Mod Actions ──────────────────────────────────────────────── // ── 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( export const quickKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await rcon.disconnectUser(ctx.data.userId) await rcon.disconnectUser(ctx.data.userId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'mod_kick', action: "mod_kick",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}) });
return actionOk() return actionOk();
}, },
) );
const muteSchema = z.object({ const muteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
}) });
export const quickMute = adminAction( export const quickMute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema }, { permission: PERMS.MODERATION_EDIT, schema: muteSchema },
async (ctx) => { async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration) await rcon.muteUser(ctx.data.userId, ctx.data.duration);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'mod_mute', action: "mod_mute",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { duration: ctx.data.duration }, after: { duration: ctx.data.duration },
}) });
return actionOk() return actionOk();
}, },
) );
export const quickUnmute = adminAction( export const quickUnmute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await rcon.unmuteUser(ctx.data.userId) await rcon.unmuteUser(ctx.data.userId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'mod_unmute', action: "mod_unmute",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}) });
return actionOk() return actionOk();
}, },
) );
const alertSchema = z.object({ const alertSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500), message: z.string().min(1).max(500),
}) });
export const quickAlert = adminAction( export const quickAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema }, { permission: PERMS.MODERATION_EDIT, schema: alertSchema },
async (ctx) => { async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message) await rcon.alertUser(ctx.data.userId, ctx.data.message);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'mod_alert', action: "mod_alert",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { message: ctx.data.message }, 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( export const quickRoomKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
async (ctx) => { async (ctx) => {
await rcon.kickAll(ctx.data.roomId) await rcon.kickAll(ctx.data.roomId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'mod_room_kick', action: "mod_room_kick",
target: 'Room', target: "Room",
targetId: ctx.data.roomId, targetId: ctx.data.roomId,
}) });
return actionOk() return actionOk();
}, },
) );
const broadcastSchema = z.object({ const broadcastSchema = z.object({
message: z.string().min(1).max(500), message: z.string().min(1).max(500),
type: z.enum(['hotel', 'staff']), type: z.enum(["hotel", "staff"]),
}) });
export const broadcastAlert = adminAction( export const broadcastAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema }, { permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
async (ctx) => { async (ctx) => {
if (ctx.data.type === 'hotel') { if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message) await rcon.hotelAlert(ctx.data.message);
} else { } else {
await rcon.staffAlert(ctx.data.message) await rcon.staffAlert(ctx.data.message);
} }
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: `mod_broadcast_${ctx.data.type}`, action: `mod_broadcast_${ctx.data.type}`,
target: 'broadcast', target: "broadcast",
after: { message: ctx.data.message }, 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 { revalidateTag } from "next/cache";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permission-slugs' import { PERMS } from "@/lib/permission-slugs";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from '@/lib/services/permission-ranks' import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks";
import { rcon } from '@/lib/services/rcon' import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from '@/lib/services/staff-activity' import { logStaffActivity } from "@/lib/services/staff-activity";
const createRankSchema = z.object({ const createRankSchema = z.object({
rank_name: z.string().trim().min(1).max(25), rank_name: z.string().trim().min(1).max(25),
level: z.coerce.number().int().min(1), level: z.coerce.number().int().min(1),
}) });
export const createRank = adminAction( export const createRank = adminAction(
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const id = await createEmulatorRank(prisma, ctx.data) const id = await createEmulatorRank(prisma, ctx.data);
await prisma.aclRole.upsert({ await prisma.aclRole.upsert({
where: { slug: `rank_${id}` }, 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 }, 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 logStaffActivity({
await rcon.send('updatepermissions') staffId: ctx.session.user.id,
revalidateTag('permissions', { expire: 0 }) action: "rank_create",
return actionOk({ id }) 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( export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const users = await prisma.user.count({ where: { rank: 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`) 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}` } }) const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } });
await deleteEmulatorRank(prisma, ctx.data.id) await deleteEmulatorRank(prisma, ctx.data.id);
if (role) { if (role) {
await prisma.$transaction([ 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.aclModelRole.deleteMany({ where: { roleId: role.id } }),
prisma.aclRole.delete({ where: { id: 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 logStaffActivity({
await rcon.send('updatepermissions') staffId: ctx.session.user.id,
revalidateTag('permissions', { expire: 0 }) action: "rank_delete",
return actionOk() 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({ const saveRankSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
fields: z.record(z.string(), z.union([z.string(), z.number()])), fields: z.record(z.string(), z.union([z.string(), z.number()])),
}) });
export const saveRank = adminAction( export const saveRank = adminAction(
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields) await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
if (typeof ctx.data.fields.rank_name === 'string') { 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 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 logStaffActivity({
await rcon.send('updatepermissions') staffId: ctx.session.user.id,
revalidateTag('permissions', { expire: 0 }) action: "rank_update",
return actionOk() 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({ const setCmsPermsSchema = z.object({
roleId: z.coerce.number().int().positive(), roleId: z.coerce.number().int().positive(),
permissionSlugs: z.array(z.string().trim().min(1)).max(500), permissionSlugs: z.array(z.string().trim().min(1)).max(500),
}) });
export const setCmsPermissions = adminAction( export const setCmsPermissions = adminAction(
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const role = await prisma.aclRole.findUnique({ where: { id: ctx.data.roleId }, select: { id: true, slug: true } }) const role = await prisma.aclRole.findUnique({
if (!role) throw new ActionError('Role not found') where: { id: ctx.data.roleId },
select: { id: true, slug: true },
});
if (!role) throw new ActionError("Role not found");
const permissions = await prisma.aclPermission.findMany({ const permissions = await prisma.aclPermission.findMany({
where: { slug: { in: ctx.data.permissionSlugs } }, where: { slug: { in: ctx.data.permissionSlugs } },
select: { id: true }, select: { id: true },
}) });
await prisma.$transaction(async (tx) => { 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) { if (permissions.length) {
await tx.aclModelPermission.createMany({ 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 }) await logStaffActivity({
revalidateTag('permissions', { expire: 0 }) staffId: ctx.session.user.id,
return actionOk() 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 { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
import { createPollSchema, pollQuestionSchema, updatePollSchema } from '@/lib/validators/poll' import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll";
// ── Polls ─────────────────────────────────────────────────────────── // ── Polls ───────────────────────────────────────────────────────────
export const createPoll = adminAction( export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema }, { permission: PERMS.POLLS_EDIT, schema: createPollSchema },
async (ctx) => { async (ctx) => {
const poll = await prisma.websitePoll.create({ data: ctx.data }) const poll = await prisma.websitePoll.create({ data: ctx.data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'poll_create', action: "poll_create",
target: 'WebsitePoll', target: "WebsitePoll",
targetId: poll.id, targetId: poll.id,
after: { title: poll.title }, after: { title: poll.title },
}) });
return actionOk({ id: poll.id }) return actionOk({ id: poll.id });
}, },
) );
const updatePollInput = updatePollSchema.extend({ const updatePollInput = updatePollSchema.extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const updatePoll = adminAction( export const updatePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput }, { permission: PERMS.POLLS_EDIT, schema: updatePollInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data const { id, ...data } = ctx.data;
const existing = await prisma.websitePoll.findUnique({ where: { id } }) const existing = await prisma.websitePoll.findUnique({ where: { id } });
if (!existing) throw new ActionError('Poll not found') if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.update({ where: { id }, data }) await prisma.websitePoll.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'poll_update', action: "poll_update",
target: 'WebsitePoll', target: "WebsitePoll",
targetId: id, targetId: id,
before: { title: existing.title, status: existing.status }, before: { title: existing.title, status: existing.status },
after: data, after: data,
}) });
return actionOk({ id }) return actionOk({ id });
}, },
) );
const deletePollInput = z.object({ const deletePollInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const deletePoll = adminAction( export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput }, { permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } }) const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } });
if (!existing) throw new ActionError('Poll not found') 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({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'poll_delete', action: "poll_delete",
target: 'WebsitePoll', target: "WebsitePoll",
targetId: ctx.data.id, targetId: ctx.data.id,
before: { title: existing.title }, before: { title: existing.title },
}) });
return actionOk() return actionOk();
}, },
) );
// ── Questions ─────────────────────────────────────────────────────── // ── Questions ───────────────────────────────────────────────────────
export const addPollQuestion = adminAction( export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => { async (ctx) => {
const question = await prisma.websitePollQuestion.create({ data: ctx.data }) const question = await prisma.websitePollQuestion.create({ data: ctx.data });
return actionOk({ id: question.id }) return actionOk({ id: question.id });
}, },
) );
const updateQuestionInput = pollQuestionSchema.partial().extend({ const updateQuestionInput = pollQuestionSchema.partial().extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const updatePollQuestion = adminAction( export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data const { id, ...data } = ctx.data;
await prisma.websitePollQuestion.update({ where: { id }, data }) await prisma.websitePollQuestion.update({ where: { id }, data });
return actionOk({ id }) return actionOk({ id });
}, },
) );
const deleteQuestionInput = z.object({ const deleteQuestionInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const deletePollQuestion = adminAction( export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => { async (ctx) => {
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } }) await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
return actionOk() return actionOk();
}, },
) );
+45 -45
View File
@@ -1,10 +1,10 @@
'use server' "use server";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings // Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
// are not represented in prisma/schema.prisma yet — we use raw queries with // 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(), icon: z.string().optional(),
effect: z.string().optional(), effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).default(1), active: z.coerce.number().int().min(0).max(1).default(1),
}) });
export const createPrefix = adminAction( export const createPrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
async (ctx) => { 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 }[]>` const users = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM users WHERE username = ${username} LIMIT 1 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` await prisma.$executeRaw`
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) 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 ─────────────────────────────────────────────────── // ── Update prefix ───────────────────────────────────────────────────
@@ -49,94 +49,94 @@ const updatePrefixSchema = z.object({
icon: z.string().optional(), icon: z.string().optional(),
effect: z.string().optional(), effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).optional(), active: z.coerce.number().int().min(0).max(1).optional(),
}) });
export const updatePrefix = adminAction( export const updatePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
async (ctx) => { async (ctx) => {
const { id, text, color, icon, effect, active } = ctx.data const { id, text, color, icon, effect, active } = ctx.data;
await prisma.$executeRaw` await prisma.$executeRaw`
UPDATE custom_prefixes 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} WHERE id = ${id}
` `;
return actionOk() return actionOk();
}, },
) );
// ── Delete prefix ─────────────────────────────────────────────────── // ── Delete prefix ───────────────────────────────────────────────────
const deletePrefixSchema = z.object({ const deletePrefixSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const deletePrefix = adminAction( export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}` await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`;
return actionOk() return actionOk();
}, },
) );
// ── Add blacklist word ────────────────────────────────────────────── // ── Add blacklist word ──────────────────────────────────────────────
const addBlacklistWordSchema = z.object({ const addBlacklistWordSchema = z.object({
word: z.string().min(1).max(100), word: z.string().min(1).max(100),
}) });
export const addBlacklistWord = adminAction( export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, { permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw` await prisma.$executeRaw`
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()}) INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
` `;
return actionOk() return actionOk();
}, },
) );
// ── Remove blacklist word ─────────────────────────────────────────── // ── Remove blacklist word ───────────────────────────────────────────
const removeBlacklistWordSchema = z.object({ const removeBlacklistWordSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const removeBlacklistWord = adminAction( export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, { permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}` await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`;
return actionOk() return actionOk();
}, },
) );
// ── Update prefix settings ────────────────────────────────────────── // ── Update prefix settings ──────────────────────────────────────────
const SETTINGS_WHITELIST = new Set([ const SETTINGS_WHITELIST = new Set([
'enabled', "enabled",
'max_length', "max_length",
'min_rank', "min_rank",
'allow_colors', "allow_colors",
'allow_bold', "allow_bold",
'allow_italic', "allow_italic",
'default_color', "default_color",
]) ]);
const updatePrefixSettingsSchema = z.object({ const updatePrefixSettingsSchema = z.object({
settings: z.record(z.string(), z.string()), settings: z.record(z.string(), z.string()),
}) });
export const updatePrefixSettings = adminAction( export const updatePrefixSettings = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
async (ctx) => { async (ctx) => {
for (const [key, value] of Object.entries(ctx.data.settings)) { 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` await prisma.$executeRaw`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`) INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${value}) VALUES (${key}, ${value})
ON DUPLICATE KEY UPDATE \`value\` = ${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"); 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(); const staff = await requireStaff();
await prisma.rooms.update({ where: { id }, data: data as any }); await prisma.rooms.update({ where: { id }, data: data as any });
await logStaffActivity({ await logStaffActivity({
+13 -1
View File
@@ -10,7 +10,19 @@ export async function deleteSoundtrack({ id }: { id: number }) {
revalidatePath("/admin/sounds"); 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 requireStaff();
await prisma.soundtracks.update({ await prisma.soundtracks.update({
where: { id }, where: { id },
+22 -24
View File
@@ -1,47 +1,45 @@
'use server' "use server";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
const templateSchema = z.object({ const templateSchema = z.object({
title: z.string().min(1).max(255), title: z.string().min(1).max(255),
content: z.string().min(1), 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), sortOrder: z.coerce.number().int().min(0).default(0),
}) });
export const createTemplate = adminAction( export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema }, { permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => { async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data }) const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
return actionOk({ id: tpl.id }) return actionOk({ id: tpl.id });
}, },
) );
const updateTemplateInput = templateSchema const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() });
.partial()
.extend({ id: z.coerce.number().int().positive() })
export const updateTemplate = adminAction( export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data const { id, ...data } = ctx.data;
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } }) const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } });
if (!existing) throw new ActionError('Template not found') if (!existing) throw new ActionError("Template not found");
await prisma.websiteTicketTemplate.update({ where: { id }, data }) await prisma.websiteTicketTemplate.update({ where: { id }, data });
return actionOk({ id }) 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( export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => { async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } }) await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
return actionOk() return actionOk();
}, },
) );
+73 -73
View File
@@ -1,18 +1,18 @@
'use server' "use server";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from '@/lib/safe-action' import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
import { notify } from '@/lib/services/webhook' import { notify } from "@/lib/services/webhook";
import { import {
assignTicketSchema, assignTicketSchema,
createTicketSchema, createTicketSchema,
replyTicketSchema, replyTicketSchema,
updateTicketPrioritySchema, updateTicketPrioritySchema,
updateTicketStatusSchema, updateTicketStatusSchema,
} from '@/lib/validators/ticket' } from "@/lib/validators/ticket";
// ── User actions (authenticated, no admin perms needed) ────────────── // ── User actions (authenticated, no admin perms needed) ──────────────
@@ -23,7 +23,7 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
category: ctx.data.category, category: ctx.data.category,
creatorId: ctx.session.user.id, creatorId: ctx.session.user.id,
}, },
}) });
// Create the first message // Create the first message
await prisma.websiteTicketMessage.create({ await prisma.websiteTicketMessage.create({
@@ -33,26 +33,26 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
message: ctx.data.message, message: ctx.data.message,
isStaff: 0, isStaff: 0,
}, },
}) });
notify({ notify({
action: 'ticket_create', action: "ticket_create",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`, target: `#${ticket.id} - ${ticket.subject}`,
details: `Category: ${ticket.category}`, details: `Category: ${ticket.category}`,
}) });
return actionOk({ id: ticket.id }) return actionOk({ id: ticket.id });
}) });
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => { export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}) });
if (!ticket) throw new ActionError('Ticket not found') if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized') if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
if (ticket.status === 'closed') throw new ActionError('Ticket is closed') if (ticket.status === "closed") throw new ActionError("Ticket is closed");
await prisma.websiteTicketMessage.create({ await prisma.websiteTicketMessage.create({
data: { data: {
@@ -61,38 +61,38 @@ export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (
message: ctx.data.message, message: ctx.data.message,
isStaff: 0, isStaff: 0,
}, },
}) });
// If ticket was in "waiting" (waiting for user), move back to open // If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === 'waiting') { if (ticket.status === "waiting") {
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { status: 'open' }, data: { status: "open" },
}) });
} }
return actionOk() return actionOk();
}) });
export const closeTicketByUser = authAction( export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) }, { schema: replyTicketSchema.pick({ ticketId: true }) },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}) });
if (!ticket) throw new ActionError('Ticket not found') if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized') if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
if (ticket.status === 'closed') throw new ActionError('Ticket is already closed') if (ticket.status === "closed") throw new ActionError("Ticket is already closed");
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { status: 'closed', closedAt: new Date() }, data: { status: "closed", closedAt: new Date() },
}) });
return actionOk() return actionOk();
}, },
) );
// ── Admin actions ──────────────────────────────────────────────────── // ── Admin actions ────────────────────────────────────────────────────
@@ -101,9 +101,9 @@ export const adminReplyTicket = adminAction(
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, 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({ await prisma.websiteTicketMessage.create({
data: { data: {
@@ -112,118 +112,118 @@ export const adminReplyTicket = adminAction(
message: ctx.data.message, message: ctx.data.message,
isStaff: 1, isStaff: 1,
}, },
}) });
// Auto-assign if not assigned yet // Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: 'waiting' } const updates: Record<string, unknown> = { status: "waiting" };
if (!ticket.assigneeId) { if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id updates.assigneeId = ctx.session.user.id;
} }
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: updates, data: updates,
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'ticket_reply', action: "ticket_reply",
target: 'WebsiteTicket', target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
}) });
return actionOk() return actionOk();
}, },
) );
export const updateTicketStatus = adminAction( export const updateTicketStatus = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema }, { permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, 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 } const data: Record<string, unknown> = { status: ctx.data.status };
if (ctx.data.status === 'closed') { if (ctx.data.status === "closed") {
data.closedAt = new Date() data.closedAt = new Date();
} }
if (ctx.data.status === 'in_progress' && !ticket.assigneeId) { if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id data.assigneeId = ctx.session.user.id;
} }
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data, data,
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'ticket_status_change', action: "ticket_status_change",
target: 'WebsiteTicket', target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { status: ticket.status }, before: { status: ticket.status },
after: { status: ctx.data.status }, after: { status: ctx.data.status },
}) });
return actionOk() return actionOk();
}, },
) );
export const assignTicket = adminAction( export const assignTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema }, { permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, 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({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { data: {
assigneeId: ctx.data.assigneeId, assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? 'in_progress' : 'open', status: ctx.data.assigneeId ? "in_progress" : "open",
}, },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'ticket_assign', action: "ticket_assign",
target: 'WebsiteTicket', target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId }, before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId }, after: { assigneeId: ctx.data.assigneeId },
}) });
return actionOk() return actionOk();
}, },
) );
export const updateTicketPriority = adminAction( export const updateTicketPriority = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema }, { permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, 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({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { priority: ctx.data.priority }, data: { priority: ctx.data.priority },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'ticket_priority_change', action: "ticket_priority_change",
target: 'WebsiteTicket', target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { priority: ticket.priority }, before: { priority: ticket.priority },
after: { priority: ctx.data.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 fs from "node:fs/promises";
import path from 'node:path' import path from "node:path";
import JSON5 from 'json5' import JSON5 from "json5";
import { z } from 'zod' import { z } from "zod";
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files' import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
import { patchJson5 } from '@/lib/json5-patch' import { patchJson5 } from "@/lib/json5-patch";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({ const saveTranslationsSchema = z.object({
locale: z.enum(['en', 'it']), locale: z.enum(["en", "it"]),
data: z.record(z.string(), z.unknown()), data: z.record(z.string(), z.unknown()),
}) });
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => { 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`) const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), 'utf-8') await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
return actionOk() return actionOk();
}) });
const saveClientTranslationsSchema = z.object({ const saveClientTranslationsSchema = z.object({
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]), fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
data: z.record(z.string(), z.string()), data: z.record(z.string(), z.string()),
}) });
export const saveClientTranslations = adminAction( export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => {
{ schema: saveClientTranslationsSchema }, if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
const file = getClientTranslationFile(ctx.data.fileId) const file = getClientTranslationFile(ctx.data.fileId);
if (!file) throw new ActionError('Unknown file') if (!file) throw new ActionError("Unknown file");
if (file.readOnly) throw new ActionError('File is read-only') if (file.readOnly) throw new ActionError("File is read-only");
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but // file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it // Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list. // pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath) const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath);
const raw = await fs.readFile(absPath, 'utf-8') const raw = await fs.readFile(absPath, "utf-8");
if (file.format === 'json') { if (file.format === "json") {
// Plain JSON — no comments to preserve, just round-trip. // Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), 'utf-8') await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8");
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] }) 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 const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
// 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) if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, "utf-8");
return actionOk({ commentsLost: false, unpatchedKeys });
}
if (unpatchedKeys.length === 0) { // At least one key could not be patched surgically (e.g. unusual
await fs.writeFile(absPath, content, 'utf-8') // formatting or a brand-new key). Fall back to a full re-serialization
return actionOk({ commentsLost: false, unpatchedKeys }) // 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 crypto from "node:crypto";
import { hash } from 'bcryptjs' import { hash } from "bcryptjs";
import { z } from 'zod' import { z } from "zod";
import { Prisma } from '@/generated/prisma/client' import { Prisma } from "@/generated/prisma/client";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from '@/lib/safe-action-shared' import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from '@/lib/services/audit' import { logAudit } from "@/lib/services/audit";
import { rcon } from '@/lib/services/rcon' import { rcon } from "@/lib/services/rcon";
import { notify } from '@/lib/services/webhook' import { notify } from "@/lib/services/webhook";
import { import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user";
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( export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema }, { permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => { 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) { 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 hashedPassword = await hash(password, 12);
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
try { try {
const user = await prisma.$transaction(async (tx) => { const user = await prisma.$transaction(async (tx) => {
@@ -45,221 +40,211 @@ export const createUser = adminAction(
credits: 5000, credits: 5000,
pixels: 5000, pixels: 5000,
accountCreated: now, accountCreated: now,
ipRegister: '0.0.0.0', ipRegister: "0.0.0.0",
ipCurrent: '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({ await tx.usersCurrency.createMany({
data: [ data: [
{ userId: created.id, type: 0, amount: 5000 }, { userId: created.id, type: 0, amount: 5000 },
{ userId: created.id, type: 5, amount: 5000 }, { userId: created.id, type: 5, amount: 5000 },
], ],
}) });
return created return created;
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'user_create', action: "user_create",
target: 'User', target: "User",
targetId: user.id, targetId: user.id,
after: { username, mail, rank }, after: { username, mail, rank },
}) });
notify({ notify({
action: 'user_edit', action: "user_edit",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: username, target: username,
targetId: user.id, 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) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
const target = (err.meta?.target as string[]) ?? [] const target = (err.meta?.target as string[]) ?? [];
if (target.includes('username')) throw new ActionError('Username already taken') if (target.includes("username")) throw new ActionError("Username already taken");
if (target.includes('mail')) throw new ActionError('Email already registered') if (target.includes("mail")) throw new ActionError("Email already registered");
throw new ActionError('Username or email already in use') throw new ActionError("Username or email already in use");
} }
throw err throw err;
} }
}, },
) );
const updateUserInput = updateUserSchema.extend({ const updateUserInput = updateUserSchema.extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}) });
export const updateUser = adminAction( export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput }, { permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => { 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 ( if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
userData.rank !== undefined && throw new ActionError("Cannot assign rank equal or higher than your own");
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) { if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 5 } }, where: { userId_type: { userId: id, type: 5 } },
update: { amount: diamonds }, update: { amount: diamonds },
create: { userId: id, type: 5, amount: diamonds }, create: { userId: id, type: 5, amount: diamonds },
}) });
} }
if (duckets !== undefined) { if (duckets !== undefined) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 0 } }, where: { userId_type: { userId: id, type: 0 } },
update: { amount: duckets }, update: { amount: duckets },
create: { userId: id, type: 0, amount: duckets }, create: { userId: id, type: 0, amount: duckets },
}) });
} }
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'user_edit', action: "user_edit",
target: 'User', target: "User",
targetId: id, targetId: id,
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank }, before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
after: userData, after: userData,
}) });
notify({ notify({
action: 'user_edit', action: "user_edit",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: targetUser.username, target: targetUser.username,
targetId: id, targetId: id,
}) });
return actionOk() return actionOk();
}, },
) );
const banInput = banUserSchema.extend({}) const banInput = banUserSchema.extend({});
export const banUser = adminAction( export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => {
{ permission: PERMS.USERS_BAN, schema: banInput }, const { userId, reason, duration, type, ip } = ctx.data;
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 now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0 const banExpire = duration > 0 ? now + duration * 3600 : 0;
await prisma.ban.create({ await prisma.ban.create({
data: { data: {
userId, userId,
userStaffId: ctx.session.user.id, userStaffId: ctx.session.user.id,
timestamp: now, timestamp: now,
banExpire, banExpire,
banReason: reason, banReason: reason,
type: type || 'account', type: type || "account",
ip: ip || '', ip: ip || "",
machineId: '', machineId: "",
}, },
}) });
await rcon.disconnectUser(userId) await rcon.disconnectUser(userId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'ban', action: "ban",
target: 'User', target: "User",
targetId: userId, targetId: userId,
after: { reason, type, duration }, after: { reason, type, duration },
}) });
notify({ notify({
action: 'ban', action: "ban",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: targetUser.username, target: targetUser.username,
details: reason, 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( export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => {
{ permission: PERMS.USERS_BAN, schema: unbanInput }, const { userId } = ctx.data;
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({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'unban', action: "unban",
target: 'User', target: "User",
targetId: userId, targetId: userId,
}) });
notify({ notify({
action: 'unban', action: "unban",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: targetUser.username, target: targetUser.username,
}) });
return actionOk() return actionOk();
}, });
)
export const giveBadge = adminAction( export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema }, { permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => { 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 } }) const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
if (existing) throw new ActionError('Badge already assigned') if (existing) throw new ActionError("Badge already assigned");
await prisma.usersBadges.create({ data: { userId, badgeCode } }) await prisma.usersBadges.create({ data: { userId, badgeCode } });
await rcon.giveBadge(userId, badgeCode) await rcon.giveBadge(userId, badgeCode);
return actionOk() return actionOk();
}, },
) );
// ── Remove Badge ──────────────────────────────────────────────────── // ── Remove Badge ────────────────────────────────────────────────────
const removeBadgeSchema = z.object({ const removeBadgeSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
badgeCode: z.string().min(1), badgeCode: z.string().min(1),
}) });
export const removeBadge = adminAction( export const removeBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema }, { permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
async (ctx) => { 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 } }) const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
if (!existing) throw new ActionError('Badge not found') if (!existing) throw new ActionError("Badge not found");
await prisma.usersBadges.delete({ where: { id: existing.id } }) await prisma.usersBadges.delete({ where: { id: existing.id } });
await rcon.removeBadge(userId, badgeCode) await rcon.removeBadge(userId, badgeCode);
return actionOk() return actionOk();
}, },
) );
// ── Rank guard helper ─────────────────────────────────────────────── // ── Rank guard helper ───────────────────────────────────────────────
@@ -267,169 +252,163 @@ async function guardRank(targetUserId: number, sessionRank: number) {
const target = await prisma.user.findUnique({ const target = await prisma.user.findUnique({
where: { id: targetUserId }, where: { id: targetUserId },
select: { username: true, rank: true, mail: true }, 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) { 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 ────────────────────────────────────────────────── // ── Reset Password ──────────────────────────────────────────────────
const resetPasswordSchema = z.object({ const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}) });
export const resetPassword = adminAction( export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema }, { permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
async (ctx) => { 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 newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16);
const hashed = await hash(newPassword, 10) const hashed = await hash(newPassword, 10);
await prisma.user.update({ await prisma.user.update({
where: { id: ctx.data.userId }, where: { id: ctx.data.userId },
data: { password: hashed }, data: { password: hashed },
}) });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'reset_password', action: "reset_password",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}) });
notify({ notify({
action: 'user_edit', action: "user_edit",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: target.username, target: target.username,
details: 'Password reset', details: "Password reset",
}) });
return actionOk({ newPassword }) return actionOk({ newPassword });
}, },
) );
// ── Disconnect User ───────────────────────────────────────────────── // ── Disconnect User ─────────────────────────────────────────────────
const disconnectSchema = z.object({ const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}) });
export const disconnectUser = adminAction( export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema }, { permission: PERMS.USERS_EDIT, schema: disconnectSchema },
async (ctx) => { 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 success = await rcon.disconnectUser(ctx.data.userId) const success = await rcon.disconnectUser(ctx.data.userId);
if (!success) throw new ActionError('Failed to disconnect. Is the emulator running?') if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'user_disconnect', action: "user_disconnect",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}) });
notify({ notify({
action: 'disconnect', action: "disconnect",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: target.username, target: target.username,
}) });
return actionOk() return actionOk();
}, },
) );
// ── Alert User (in-game message) ──────────────────────────────────── // ── Alert User (in-game message) ────────────────────────────────────
const alertUserSchema = z.object({ const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500), message: z.string().min(1).max(500),
}) });
export const alertUser = adminAction( export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema }, { permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => { async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message) const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success) throw new ActionError('Failed to send alert. Is the emulator running?') if (!success) throw new ActionError("Failed to send alert. Is the emulator running?");
return actionOk() return actionOk();
}, },
) );
// ── Mute User ─────────────────────────────────────────────────────── // ── Mute User ───────────────────────────────────────────────────────
const muteSchema = z.object({ const muteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).default(0), duration: z.coerce.number().int().min(0).default(0),
}) });
export const muteUser = adminAction( export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => {
{ permission: PERMS.USERS_EDIT, schema: muteSchema }, const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
async (ctx) => { const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank) if (!success) throw new ActionError("Failed to mute. Is the emulator running?");
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration)
if (!success) throw new ActionError('Failed to mute. Is the emulator running?')
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'user_mute', action: "user_mute",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { duration: ctx.data.duration }, after: { duration: ctx.data.duration },
}) });
return actionOk() return actionOk();
}, });
)
// ── Unmute User ───────────────────────────────────────────────────── // ── Unmute User ─────────────────────────────────────────────────────
const unmuteSchema = z.object({ const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}) });
export const unmuteUser = adminAction( export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => {
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, await guardRank(ctx.data.userId, ctx.session.user.rank);
async (ctx) => { const success = await rcon.unmuteUser(ctx.data.userId);
await guardRank(ctx.data.userId, ctx.session.user.rank) if (!success) throw new ActionError("Failed to unmute. Is the emulator running?");
const success = await rcon.unmuteUser(ctx.data.userId)
if (!success) throw new ActionError('Failed to unmute. Is the emulator running?')
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'user_unmute', action: "user_unmute",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}) });
return actionOk() return actionOk();
}, });
)
// ── Send Credits via RCON ─────────────────────────────────────────── // ── Send Credits via RCON ───────────────────────────────────────────
const sendCreditsSchema = z.object({ const sendCreditsSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().min(1).max(1000000), amount: z.coerce.number().int().min(1).max(1000000),
}) });
export const sendCredits = adminAction( export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema }, { permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => { 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 success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount) const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success) throw new ActionError('Failed to send credits. Is the emulator running?') if (!success) throw new ActionError("Failed to send credits. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: 'user_send_credits', action: "user_send_credits",
target: 'User', target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { amount: ctx.data.amount }, 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 { revalidateTag } from "next/cache";
import { z } from 'zod' import { z } from "zod";
import { PERMS } from '@/lib/permission-slugs' import { PERMS } from "@/lib/permission-slugs";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { adminAction } from '@/lib/safe-action' import { adminAction } from "@/lib/safe-action";
import { actionOk } from '@/lib/safe-action-shared' import { actionOk } from "@/lib/safe-action-shared";
const toggleWatchSchema = z.object({ const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(), targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(), reason: z.string().max(255).optional(),
}) });
/** /**
* Toggle a watch entry for the calling staff on the given target user. * 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( export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema }, { permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => { async (ctx) => {
const staffId = ctx.session.user.id const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data const { targetUserId, reason } = ctx.data;
const existing = await prisma.userWatch.findUnique({ const existing = await prisma.userWatch.findUnique({
where: { staffId_targetUserId: { staffId, targetUserId } }, where: { staffId_targetUserId: { staffId, targetUserId } },
}) });
if (existing) { if (existing) {
await prisma.userWatch.delete({ where: { id: existing.id } }) await prisma.userWatch.delete({ where: { id: existing.id } });
revalidateTag(`user-watch:${staffId}`, { expire: 0 }) revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: false }) return actionOk({ watching: false });
} }
await prisma.userWatch.create({ await prisma.userWatch.create({
data: { staffId, targetUserId, reason: reason ?? '' }, data: { staffId, targetUserId, reason: reason ?? "" },
}) });
revalidateTag(`user-watch:${staffId}`, { expire: 0 }) revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true }) return actionOk({ watching: true });
}, },
) );
+42 -41
View File
@@ -1,17 +1,17 @@
import { MessageSquare, Shield, Terminal, Users } from 'lucide-react' import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { StatsCard } from '@/components/admin/stats-card' import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
async function getActivityData() { async function getActivityData() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400) const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400 const weekAgo = now - 7 * 86400;
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries // 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([ const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
@@ -25,57 +25,61 @@ async function getActivityData() {
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`, FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
]) ]);
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) => const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)])) new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
const chatMap = toMap(chatRows) const chatMap = toMap(chatRows);
const cmdMap = toMap(cmdRows) const cmdMap = toMap(cmdRows);
const banMap = toMap(banRows) const banMap = toMap(banRows);
const regMap = toMap(regRows) const regMap = toMap(regRows);
const dailyData = [] const dailyData = [];
for (let i = 6; i >= 0; i--) { for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400 const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400) const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000) const date = new Date(dayStart * 1000);
dailyData.push({ 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, chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0, commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0, bans: banMap.get(bucket) ?? 0,
registrations: regMap.get(bucket) ?? 0, registrations: regMap.get(bucket) ?? 0,
}) });
} }
// Totals // Totals
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([ const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, prisma.$queryRaw<
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`, { 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.ban.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
]) ]);
const weekChats = Number(weekChatRows[0]?.count ?? 0) const weekChats = Number(weekChatRows[0]?.count ?? 0);
const weekCommands = Number(weekCommandRows[0]?.count ?? 0) const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
// Most active users (by chat messages) // Most active users (by chat messages)
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>` const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
SELECT user_from_id AS userId, COUNT(*) AS messages SELECT user_from_id AS userId, COUNT(*) AS messages
FROM chatlogs_room WHERE timestamp >= ${weekAgo} FROM chatlogs_room WHERE timestamp >= ${weekAgo}
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10 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({ const users = await prisma.user.findMany({
where: { id: { in: userIds } }, where: { id: { in: userIds } },
select: { id: true, username: true }, 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) => ({ const topChattersWithNames = topChatters.map((c) => ({
userId: c.userId, userId: c.userId,
username: userMap.get(c.userId) ?? `User #${c.userId}`, username: userMap.get(c.userId) ?? `User #${c.userId}`,
messages: Number(c.messages), messages: Number(c.messages),
})) }));
return { return {
dailyData, dailyData,
@@ -84,16 +88,16 @@ async function getActivityData() {
weekBans, weekBans,
weekRegistrations, weekRegistrations,
topChatters: topChattersWithNames, topChatters: topChattersWithNames,
} };
} }
export default async function ActivityPage() { export default async function ActivityPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const data = await getActivityData() const data = await getActivityData();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -174,10 +178,7 @@ export default async function ActivityPage() {
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{data.topChatters.map((user, i) => ( {data.topChatters.map((user, i) => (
<div <div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
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"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
{i + 1} {i + 1}
</span> </span>
@@ -193,5 +194,5 @@ export default async function ActivityPage() {
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
+39 -44
View File
@@ -1,47 +1,47 @@
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from 'lucide-react' import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from "lucide-react";
import dynamic from 'next/dynamic' import dynamic from "next/dynamic";
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { StatsCard } from '@/components/admin/stats-card' import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from "@/components/ui/skeleton";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
const RevenueChart = dynamic( 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" /> }, { loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
) );
async function getEconomyData() { async function getEconomyData() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400) const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400 const weekAgo = now - 7 * 86400;
// Total currency in circulation — parallel instead of sequential // Total currency in circulation — parallel instead of sequential
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([ const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
prisma.user.aggregate({ _sum: { credits: true } }), prisma.user.aggregate({ _sum: { credits: true } }),
prisma.user.aggregate({ _sum: { pixels: true } }), prisma.user.aggregate({ _sum: { pixels: true } }),
prisma.user.aggregate({ _sum: { points: true } }), prisma.user.aggregate({ _sum: { points: true } }),
]) ]);
const totalCredits = creditsAgg._sum.credits ?? 0 const totalCredits = creditsAgg._sum.credits ?? 0;
const totalPixels = pixelsAgg._sum.pixels ?? 0 const totalPixels = pixelsAgg._sum.pixels ?? 0;
const totalPoints = pointsAgg._sum.points ?? 0 const totalPoints = pointsAgg._sum.points ?? 0;
// Shop revenue (7 days) // Shop revenue (7 days)
const shopRevenue = await prisma.logsShopPurchases.aggregate({ const shopRevenue = await prisma.logsShopPurchases.aggregate({
where: { timestamp: { gte: weekAgo } }, where: { timestamp: { gte: weekAgo } },
_sum: { costCredits: true, costPoints: true }, _sum: { costCredits: true, costPoints: true },
_count: true, _count: true,
}) });
// Marketplace stats // 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({ const marketplaceSold = await prisma.marketplaceItems.count({
where: { state: 2, soldTimestamp: { gte: weekAgo } }, where: { state: 2, soldTimestamp: { gte: weekAgo } },
}) });
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries // 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 }[]>` const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits, COALESCE(SUM(cost_credits), 0) AS credits,
@@ -49,60 +49,55 @@ async function getEconomyData() {
FROM logs_shop_purchases FROM logs_shop_purchases
WHERE timestamp >= ${rangeStart} WHERE timestamp >= ${rangeStart}
GROUP BY day_bucket ORDER BY day_bucket GROUP BY day_bucket ORDER BY day_bucket
` `;
const revMap = new Map( const revMap = new Map(
revRows.map((r) => [ revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]),
Number(r.day_bucket), );
{ credits: Number(r.credits), points: Number(r.points) }, const revenueData = [];
]),
)
const revenueData = []
for (let i = 6; i >= 0; i--) { for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400 const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400) const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000) const date = new Date(dayStart * 1000);
const entry = revMap.get(bucket) const entry = revMap.get(bucket);
revenueData.push({ revenueData.push({
label: date.toLocaleDateString('en', { weekday: 'short' }), label: date.toLocaleDateString("en", { weekday: "short" }),
credits: entry?.credits ?? 0, credits: entry?.credits ?? 0,
points: entry?.points ?? 0, points: entry?.points ?? 0,
}) });
} }
// Trades (7 days) // Trades (7 days)
const weekTrades = await prisma.roomTradeLog.count({ const weekTrades = await prisma.roomTradeLog.count({
where: { timestamp: { gte: weekAgo } }, where: { timestamp: { gte: weekAgo } },
}) });
return { return {
totalCredits, totalCredits,
totalPixels, totalPixels,
totalPoints, totalPoints,
shopPurchases: typeof shopRevenue._count === 'number' ? shopRevenue._count : 0, shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0, shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0, shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
marketplaceActive, marketplaceActive,
marketplaceSold, marketplaceSold,
weekTrades, weekTrades,
revenueData, revenueData,
} };
} }
export default async function EconomyPage() { export default async function EconomyPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const data = await getEconomyData() const data = await getEconomyData();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Economy</h1> <h1 className="text-3xl font-bold tracking-tight">Economy</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p>
Currency circulation, shop revenue and marketplace activity
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -196,5 +191,5 @@ export default async function EconomyPage() {
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
+40 -39
View File
@@ -1,28 +1,27 @@
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from 'lucide-react' import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
import dynamic from 'next/dynamic' import dynamic from "next/dynamic";
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { StatsCard } from '@/components/admin/stats-card' import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from "@/components/ui/skeleton";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
const DashboardChart = dynamic( 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" /> }, { loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
) );
const PeakHoursHeatmap = dynamic( 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" /> }, { loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
) );
async function getAnalyticsData() { async function getAnalyticsData() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400) const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400 const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400 const monthAgo = now - 30 * 86400;
const [ const [
totalUsers, totalUsers,
@@ -36,15 +35,17 @@ async function getAnalyticsData() {
weekBans, weekBans,
] = await Promise.all([ ] = await Promise.all([
prisma.user.count(), 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: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }), prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
prisma.rooms.count(), prisma.rooms.count(),
prisma.rooms.count({ where: { users: { gt: 0 } } }), 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.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.ban.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 // Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
@@ -52,17 +53,17 @@ async function getAnalyticsData() {
FROM users FROM users
WHERE account_created >= ${monthAgo} WHERE account_created >= ${monthAgo}
GROUP BY day_bucket ORDER BY day_bucket GROUP BY day_bucket ORDER BY day_bucket
` `;
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)])) const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]));
const registrationChart = [] const registrationChart = [];
for (let i = 29; i >= 0; i--) { for (let i = 29; i >= 0; i--) {
const dayStart = todayStart - i * 86400 const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400) const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000) const date = new Date(dayStart * 1000);
registrationChart.push({ registrationChart.push({
label: date.toLocaleDateString('en', { month: 'short', day: 'numeric' }), label: date.toLocaleDateString("en", { month: "short", day: "numeric" }),
value: regCountMap.get(bucket) ?? 0, value: regCountMap.get(bucket) ?? 0,
}) });
} }
// Peak hours — single GROUP BY query instead of fetching all user rows // Peak hours — single GROUP BY query instead of fetching all user rows
@@ -73,13 +74,13 @@ async function getAnalyticsData() {
FROM users FROM users
WHERE last_online >= ${weekAgo} WHERE last_online >= ${weekAgo}
GROUP BY d, h GROUP BY d, h
` `;
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)) const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0 let max = 0;
for (const r of peakRows) { for (const r of peakRows) {
const count = Number(r.c) const count = Number(r.c);
grid[r.d][r.h] = count grid[r.d][r.h] = count;
if (count > max) max = count if (count > max) max = count;
} }
return { return {
@@ -94,16 +95,16 @@ async function getAnalyticsData() {
weekBans, weekBans,
registrationChart, registrationChart,
peakHours: { grid, max }, peakHours: { grid, max },
} };
} }
export default async function AnalyticsPage() { export default async function AnalyticsPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const data = await getAnalyticsData() const data = await getAnalyticsData();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -187,5 +188,5 @@ export default async function AnalyticsPage() {
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
+5 -1
View File
@@ -39,7 +39,11 @@ export default async function EditArticle({
</p> </p>
</div> </div>
</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 <ArticleForm
action={async (fd) => { action={async (fd) => {
"use server"; "use server";
+5 -1
View File
@@ -21,7 +21,11 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
</p> </p>
</div> </div>
</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} /> <ArticleForm action={createArticle} />
</main> </main>
); );
+5 -1
View File
@@ -35,7 +35,11 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
</Link> </Link>
</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}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" /> <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 { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from 'react' import { useState } from "react";
import { createBanner, deleteBanner, updateBanner } from '@/actions/banners' import { createBanner, deleteBanner, updateBanner } from "@/actions/banners";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from '@/components/ui/date-time-picker' import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { Switch } from '@/components/ui/switch' import { Switch } from "@/components/ui/switch";
import { useServerAction } from '@/hooks/use-server-action' import { useServerAction } from "@/hooks/use-server-action";
interface Banner { interface Banner {
id: number id: number;
title: string title: string;
subtitle: string subtitle: string;
image: string image: string;
link: string link: string;
color: string color: string;
isActive: number isActive: number;
sortOrder: number sortOrder: number;
startDate: string | null startDate: string | null;
endDate: string | null endDate: string | null;
} }
interface Props { interface Props {
banners: Banner[] banners: Banner[];
} }
export function BannersManager({ banners }: Props) { export function BannersManager({ banners }: Props) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', title: "",
subtitle: '', subtitle: "",
image: '', image: "",
link: '', link: "",
color: '', color: "",
isActive: 1, isActive: 1,
sortOrder: 0, sortOrder: 0,
startDate: '' as string | null, startDate: "" as string | null,
endDate: '' as string | null, endDate: "" as string | null,
}) });
function resetForm() { function resetForm() {
setForm({ setForm({
title: '', title: "",
subtitle: '', subtitle: "",
image: '', image: "",
link: '', link: "",
color: '', color: "",
isActive: 1, isActive: 1,
sortOrder: 0, sortOrder: 0,
startDate: '', startDate: "",
endDate: '', endDate: "",
}) });
setEditingId(null) setEditingId(null);
setShowForm(false) setShowForm(false);
} }
function handleEdit(b: Banner) { function handleEdit(b: Banner) {
@@ -72,20 +72,20 @@ export function BannersManager({ banners }: Props) {
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
startDate: b.startDate, startDate: b.startDate,
endDate: b.endDate, endDate: b.endDate,
}) });
setEditingId(b.id) setEditingId(b.id);
setShowForm(true) setShowForm(true);
} }
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null } const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null };
if (editingId) { if (editingId) {
run(() => updateBanner({ id: editingId, ...data }), { successMessage: 'Banner updated!' }) run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" });
} else { } else {
run(() => createBanner(data), { successMessage: 'Banner created!' }) run(() => createBanner(data), { successMessage: "Banner created!" });
} }
resetForm() resetForm();
} }
return ( return (
@@ -93,8 +93,8 @@ export function BannersManager({ banners }: Props) {
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={() => { onClick={() => {
resetForm() resetForm();
setShowForm(!showForm) setShowForm(!showForm);
}} }}
> >
<Plus className="h-4 w-4 mr-2" /> New Banner <Plus className="h-4 w-4 mr-2" /> New Banner
@@ -104,7 +104,7 @@ export function BannersManager({ banners }: Props) {
{showForm && ( {showForm && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{editingId ? 'Edit Banner' : 'New Banner'}</CardTitle> <CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -136,10 +136,7 @@ export function BannersManager({ banners }: Props) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Link URL</Label> <Label>Link URL</Label>
<Input <Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} />
value={form.link}
onChange={(e) => setForm({ ...form, link: e.target.value })}
/>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -147,7 +144,7 @@ export function BannersManager({ banners }: Props) {
<Label>Color</Label> <Label>Color</Label>
<Input <Input
type="color" type="color"
value={form.color || '#000000'} value={form.color || "#000000"}
onChange={(e) => setForm({ ...form, color: e.target.value })} onChange={(e) => setForm({ ...form, color: e.target.value })}
/> />
</div> </div>
@@ -191,7 +188,7 @@ export function BannersManager({ banners }: Props) {
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{editingId ? 'Update' : 'Create'} {editingId ? "Update" : "Create"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -206,10 +203,7 @@ export function BannersManager({ banners }: Props) {
{banners.map((b) => ( {banners.map((b) => (
<Card key={b.id} className="overflow-hidden"> <Card key={b.id} className="overflow-hidden">
{b.image && ( {b.image && (
<div <div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} />
className="h-32 bg-cover bg-center"
style={{ backgroundImage: `url(${b.image})` }}
/>
)} )}
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -217,19 +211,14 @@ export function BannersManager({ banners }: Props) {
<h3 className="font-medium">{b.title}</h3> <h3 className="font-medium">{b.title}</h3>
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>} {b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<Badge variant={b.isActive ? 'default' : 'outline'}> <Badge variant={b.isActive ? "default" : "outline"}>
{b.isActive ? 'Active' : 'Inactive'} {b.isActive ? "Active" : "Inactive"}
</Badge> </Badge>
{b.startDate && <Badge variant="secondary">Scheduled</Badge>} {b.startDate && <Badge variant="secondary">Scheduled</Badge>}
</div> </div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}>
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(b)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
@@ -237,7 +226,7 @@ export function BannersManager({ banners }: Props) {
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => 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" /> <Trash2 className="h-3.5 w-3.5 text-destructive" />
@@ -249,5 +238,5 @@ export function BannersManager({ banners }: Props) {
))} ))}
</div> </div>
</div> </div>
) );
} }
+9 -9
View File
@@ -1,17 +1,17 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { BannersManager } from './banners-manager' import { BannersManager } from "./banners-manager";
export default async function BannersPage() { export default async function BannersPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const banners = await prisma.websiteBanner.findMany({ const banners = await prisma.websiteBanner.findMany({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: "asc" },
}) });
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -21,5 +21,5 @@ export default async function BannersPage() {
</div> </div>
<BannersManager banners={banners} /> <BannersManager banners={banners} />
</div> </div>
) );
} }
@@ -1,54 +1,54 @@
'use client' "use client";
import { Languages, Package, Settings } from 'lucide-react' import { Languages, Package, Settings } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { CatalogItemData } from './catalog-items-table' import type { CatalogItemData } from "./catalog-items-table";
import { CatalogItemsTable } from './catalog-items-table' import { CatalogItemsTable } from "./catalog-items-table";
import { CatalogPageForm } from './catalog-page-form' import { CatalogPageForm } from "./catalog-page-form";
import { CatalogTranslateTab } from './catalog-translate-tab' import { CatalogTranslateTab } from "./catalog-translate-tab";
interface CatalogPageData { interface CatalogPageData {
id: number id: number;
caption: string caption: string;
parentId: number parentId: number;
pageLayout: string pageLayout: string;
enabled: string enabled: string;
visible: string visible: string;
minRank: number minRank: number;
clubOnly: string clubOnly: string;
orderNum: number orderNum: number;
iconImage: number iconImage: number;
iconColor: number iconColor: number;
pageHeadline: string pageHeadline: string;
pageTeaser: string pageTeaser: string;
pageSpecial: string | null pageSpecial: string | null;
pageText1: string | null pageText1: string | null;
pageText2: string | null pageText2: string | null;
pageTextDetails: string | null pageTextDetails: string | null;
pageTextTeaser: string | null pageTextTeaser: string | null;
} }
interface BaseItemData { interface BaseItemData {
id: number id: number;
publicName: string publicName: string;
itemName: string itemName: string;
spriteId: number spriteId: number;
type: string type: string;
} }
interface CatalogDetailTabsProps { interface CatalogDetailTabsProps {
catalogPage: CatalogPageData catalogPage: CatalogPageData;
items: CatalogItemData[] items: CatalogItemData[];
baseItems: BaseItemData[] baseItems: BaseItemData[];
catalogNameMap: Record<number, string> catalogNameMap: Record<number, string>;
childPages: { id: number; caption: string; enabled: string }[] childPages: { id: number; caption: string; enabled: string }[];
pageId: number pageId: number;
canEdit: boolean canEdit: boolean;
furniDataIdList: number[] furniDataIdList: number[];
furniDescriptionMap: Record<number, string> furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }> furniRevisionMap: Record<number, { classname: string; revision: number }>;
allPages: { id: number; caption: string }[] allPages: { id: number; caption: string }[];
interactionTypes: string[] interactionTypes: string[];
} }
export function CatalogDetailTabs({ export function CatalogDetailTabs({
@@ -78,14 +78,15 @@ export function CatalogDetailTabs({
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="translate" className="gap-2"> <TabsTrigger value="translate" className="gap-2">
<Languages className="h-4 w-4" /> <Languages className="h-4 w-4" />
Translate ({baseItems.length}){(() => { Translate ({baseItems.length})
const furniSet = new Set(furniDataIdList) {(() => {
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length const furniSet = new Set(furniDataIdList);
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
return missing > 0 ? ( 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} {missing}
</span> </span>
) : null ) : null;
})()} })()}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -122,5 +123,5 @@ export function CatalogDetailTabs({
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) );
} }
@@ -2,6 +2,6 @@
* Re-export barrel for backward compatibility. * Re-export barrel for backward compatibility.
* Implementation moved to ./catalog-items-table/ directory. * Implementation moved to ./catalog-items-table/ directory.
*/ */
export { CatalogItemsTable } from './catalog-items-table/catalog-items-table' export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
export { FurniIcon } from './catalog-items-table/furni-icon' export { FurniIcon } from "./catalog-items-table/furni-icon";
export type { BaseItemData, CatalogItemData } from './catalog-items-table/types' 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 { Check, ChevronsUpDown, Star } from "lucide-react";
import { useMemo, useState } from 'react' import { useMemo, useState } from "react";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -10,11 +10,11 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from '@/components/ui/command' } from "@/components/ui/command";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from '@/components/ui/switch' import { Switch } from "@/components/ui/switch";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
// ── Toggle field ────────────────────────────────────────────── // ── Toggle field ──────────────────────────────────────────────
@@ -25,21 +25,21 @@ export function ToggleField({
onChange, onChange,
disabled, disabled,
}: { }: {
label: string label: string;
/** Boolean checked state */ /** Boolean checked state */
checked?: boolean checked?: boolean;
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */ /** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
value?: number | string value?: number | string;
onChange: (v: boolean) => void onChange: (v: boolean) => void;
disabled?: boolean disabled?: boolean;
}) { }) {
const isChecked = checked ?? (value === 1 || value === '1') const isChecked = checked ?? (value === 1 || value === "1");
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label> <Label className="text-xs">{label}</Label>
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" /> <Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" />
</div> </div>
) );
} }
// ── Interaction Type Combobox ────────────────────────────────── // ── Interaction Type Combobox ──────────────────────────────────
@@ -50,19 +50,19 @@ export function InteractionTypeCombobox({
types, types,
disabled, disabled,
}: { }: {
value: string value: string;
onChange: (v: string) => void onChange: (v: string) => void;
types: string[] types: string[];
disabled?: boolean disabled?: boolean;
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState("");
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!inputValue.trim()) return types.slice(0, 50) if (!inputValue.trim()) return types.slice(0, 50);
const q = inputValue.toLowerCase() const q = inputValue.toLowerCase();
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50) return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
}, [types, inputValue]) }, [types, inputValue]);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -74,7 +74,7 @@ export function InteractionTypeCombobox({
disabled={disabled} disabled={disabled}
className="w-full justify-between font-normal h-8 text-sm" 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" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -92,15 +92,15 @@ export function InteractionTypeCombobox({
type="button" type="button"
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded" className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
onClick={() => { onClick={() => {
onChange(inputValue.trim()) onChange(inputValue.trim());
setOpen(false) setOpen(false);
setInputValue('') setInputValue("");
}} }}
> >
Use &quot;{inputValue.trim()}&quot; Use &quot;{inputValue.trim()}&quot;
</button> </button>
) : ( ) : (
'No types found.' "No types found."
)} )}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
@@ -109,14 +109,12 @@ export function InteractionTypeCombobox({
key={t} key={t}
value={t} value={t}
onSelect={() => { onSelect={() => {
onChange(t) onChange(t);
setOpen(false) setOpen(false);
setInputValue('') setInputValue("");
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === t ? "opacity-100" : "opacity-0")} />
className={cn('mr-2 h-4 w-4', value === t ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{t}</span> <span className="truncate">{t}</span>
</CommandItem> </CommandItem>
))} ))}
@@ -125,7 +123,7 @@ export function InteractionTypeCombobox({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) );
} }
// ── Move Page Combobox with smart suggestions ───────────────── // ── Move Page Combobox with smart suggestions ─────────────────
@@ -137,35 +135,32 @@ export function MovePageCombobox({
currentPageId, currentPageId,
suggestedPageIds, suggestedPageIds,
}: { }: {
value: number | null value: number | null;
onChange: (id: number | null) => void onChange: (id: number | null) => void;
pages: { id: number; caption: string }[] pages: { id: number; caption: string }[];
currentPageId: number currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */ /** 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 // Build suggested pages list
const suggested = useMemo(() => { const suggested = useMemo(() => {
if (!suggestedPageIds?.length) return [] if (!suggestedPageIds?.length) return [];
const pageMap = new Map(options.map((p) => [p.id, p])) const pageMap = new Map(options.map((p) => [p.id, p]));
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as { return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
id: number id: number;
caption: string caption: string;
}[] }[];
}, [suggestedPageIds, options]) }, [suggestedPageIds, options]);
// Non-suggested pages (exclude those already in suggestions) // Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]) const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]);
const remaining = useMemo( const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]);
() => options.filter((p) => !suggestedSet.has(p.id)),
[options, suggestedSet],
)
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -178,9 +173,7 @@ export function MovePageCombobox({
> >
{selectedPage ? ( {selectedPage ? (
<span className="truncate"> <span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5"> <span className="text-muted-foreground font-mono text-xs mr-1.5">#{selectedPage.id}</span>
#{selectedPage.id}
</span>
{selectedPage.caption} {selectedPage.caption}
</span> </span>
) : ( ) : (
@@ -203,20 +196,13 @@ export function MovePageCombobox({
key={`s-${page.id}`} key={`s-${page.id}`}
value={`${page.id} ${page.caption}`} value={`${page.id} ${page.caption}`}
onSelect={() => { onSelect={() => {
onChange(page.id) onChange(page.id);
setOpen(false) setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
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)]" /> <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"> <span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
#{page.id}
</span>
<span className="truncate">{page.caption}</span> <span className="truncate">{page.caption}</span>
</CommandItem> </CommandItem>
))} ))}
@@ -224,19 +210,17 @@ export function MovePageCombobox({
)} )}
{/* All pages */} {/* All pages */}
<CommandGroup heading={suggested.length > 0 ? 'All pages' : undefined}> <CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}>
{remaining.map((page) => ( {remaining.map((page) => (
<CommandItem <CommandItem
key={page.id} key={page.id}
value={`${page.id} ${page.caption}`} value={`${page.id} ${page.caption}`}
onSelect={() => { onSelect={() => {
onChange(page.id) onChange(page.id);
setOpen(false) setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
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="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
<span className="truncate">{page.caption}</span> <span className="truncate">{page.caption}</span>
</CommandItem> </CommandItem>
@@ -246,5 +230,5 @@ export function MovePageCombobox({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) );
} }
@@ -1,7 +1,7 @@
'use client' "use client";
import { Image as ImageIcon } from 'lucide-react' import { Image as ImageIcon } from "lucide-react";
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname' import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) { export function FurniIcon({ classname, revision }: { classname: string; revision: number }) {
if (!classname) { 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"> <div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" /> <ImageIcon className="h-4 w-4 text-muted-foreground" />
</div> </div>
) );
} }
const localUrl = getLocalIconUrl(classname) const localUrl = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) { function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement const img = e.target as HTMLImageElement;
const stage = img.dataset.fallback const stage = img.dataset.fallback;
if (!stage && revision > 0) { if (!stage && revision > 0) {
img.dataset.fallback = 'cdn' img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision) img.src = getHabboCdnIconUrl(classname, revision);
} else { } else {
img.dataset.fallback = 'none' img.dataset.fallback = "none";
img.style.opacity = '0' img.style.opacity = "0";
} }
} }
return ( return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden"> <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 <img
src={localUrl} src={localUrl}
alt={classname} alt={classname}
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
style={{ imageRendering: 'pixelated' }} style={{ imageRendering: "pixelated" }}
onError={handleError} onError={handleError}
loading="lazy" loading="lazy"
/> />
</div> </div>
) );
} }
@@ -1,4 +1,4 @@
export { CatalogItemsTable } from './catalog-items-table' export { CatalogItemsTable } from "./catalog-items-table";
export { FurniIcon } from './furni-icon' export { FurniIcon } from "./furni-icon";
export type { BaseItemData, CatalogItemData, InlineEdit } from './types' export type { BaseItemData, CatalogItemData, InlineEdit } from "./types";
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } 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 { Loader2, Music } from "lucide-react";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
getCachedSongPickerOptions, getCachedSongPickerOptions,
loadSongPickerOptions, loadSongPickerOptions,
type SoundtrackOption, 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'` // Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
// callsites keep working. New code should import from the lib module directly. // 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 { interface Props {
value: number value: number;
onChange: (id: number) => void onChange: (id: number) => void;
disabled?: boolean disabled?: boolean;
} }
function formatLength(seconds: number): string { function formatLength(seconds: number): string {
if (!seconds || seconds <= 0) return '' if (!seconds || seconds <= 0) return "";
const m = Math.floor(seconds / 60) const m = Math.floor(seconds / 60);
const s = seconds % 60 const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}` return `${m}:${String(s).padStart(2, "0")}`;
} }
export function SongPicker({ value, onChange, disabled }: Props) { export function SongPicker({ value, onChange, disabled }: Props) {
const initial = getCachedSongPickerOptions() const initial = getCachedSongPickerOptions();
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []) const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
const [loading, setLoading] = useState(!initial) const [loading, setLoading] = useState(!initial);
useEffect(() => { useEffect(() => {
if (getCachedSongPickerOptions()) return if (getCachedSongPickerOptions()) return;
let cancelled = false let cancelled = false;
loadSongPickerOptions() loadSongPickerOptions()
.then((items) => { .then((items) => {
if (!cancelled) setOptions(items) if (!cancelled) setOptions(items);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setOptions([]) if (!cancelled) setOptions([]);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false);
}) });
return () => { return () => {
cancelled = true cancelled = true;
} };
}, []) }, []);
return ( return (
<Select <Select
@@ -81,14 +75,12 @@ export function SongPicker({ value, onChange, disabled }: Props) {
<span className="font-medium">{opt.name}</span> <span className="font-medium">{opt.name}</span>
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>} {opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>}
{opt.length > 0 && ( {opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono"> <span className="text-xs text-muted-foreground font-mono">{formatLength(opt.length)}</span>
{formatLength(opt.length)}
</span>
)} )}
</span> </span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) );
} }
@@ -1,71 +1,71 @@
export interface BaseItemData { export interface BaseItemData {
id: number id: number;
spriteId: number spriteId: number;
publicName: string publicName: string;
itemName: string itemName: string;
type: string type: string;
width: number width: number;
length: number length: number;
stackHeight: number stackHeight: number;
allowStack: number allowStack: number;
allowSit: number allowSit: number;
allowLay: number allowLay: number;
allowWalk: number allowWalk: number;
allowGift: number allowGift: number;
allowTrade: number allowTrade: number;
allowRecycle: number allowRecycle: number;
allowMarketplaceSell: number allowMarketplaceSell: number;
allowInventoryStack: number allowInventoryStack: number;
interactionType: string interactionType: string;
interactionModesCount: number interactionModesCount: number;
vendingIds: string vendingIds: string;
customparams: string customparams: string;
effectIdMale: number effectIdMale: number;
effectIdFemale: number effectIdFemale: number;
clothingOnWalk: string clothingOnWalk: string;
} }
export interface CatalogItemData { export interface CatalogItemData {
id: number id: number;
catalogName: string catalogName: string;
itemIds: string itemIds: string;
costCredits: number costCredits: number;
costPoints: number costPoints: number;
pointsType: number pointsType: number;
amount: number amount: number;
limitedSells: number limitedSells: number;
limitedStack: number limitedStack: number;
orderNumber: number orderNumber: number;
offerId: number offerId: number;
songId: number songId: number;
haveOffer: string haveOffer: string;
clubOnly: string clubOnly: string;
extradata: string extradata: string;
baseName: string baseName: string;
baseItemName: string baseItemName: string;
spriteId: number spriteId: number;
baseItem: BaseItemData | null baseItem: BaseItemData | null;
} }
export interface InlineEdit { export interface InlineEdit {
costCredits: number costCredits: number;
costPoints: number costPoints: number;
pointsType: number pointsType: number;
amount: number amount: number;
} }
export const POINTS_TYPES: Record<number, { label: string; color: string }> = { export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
0: { label: 'Duckets', color: 'text-[var(--admin-info)]' }, 0: { label: "Duckets", color: "text-[var(--admin-info)]" },
5: { label: 'Diamonds', color: 'text-[var(--admin-accent-text)]' }, 5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
101: { label: 'Snowflakes', color: 'text-[var(--admin-info)]' }, 101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
103: { label: 'Hearts', color: 'text-[var(--admin-error)]' }, 103: { label: "Hearts", color: "text-[var(--admin-error)]" },
104: { label: 'Stars', color: 'text-[var(--admin-warning)]' }, 104: { label: "Stars", color: "text-[var(--admin-warning)]" },
105: { label: 'Shells', color: 'text-[var(--admin-warning)]' }, 105: { label: "Shells", color: "text-[var(--admin-warning)]" },
} };
export const DEFAULT_NEW_ITEM = { export const DEFAULT_NEW_ITEM = {
catalogName: '', catalogName: "",
itemIds: '0', itemIds: "0",
costCredits: 0, costCredits: 0,
costPoints: 0, costPoints: 0,
pointsType: 0, pointsType: 0,
@@ -75,11 +75,11 @@ export const DEFAULT_NEW_ITEM = {
orderNumber: 1, orderNumber: 1,
offerId: -1, offerId: -1,
songId: 0, songId: 0,
haveOffer: '1', haveOffer: "1",
clubOnly: '0', clubOnly: "0",
extradata: '', extradata: "",
} };
export function getPointsLabel(type: number) { 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 { import {
Check, Check,
@@ -11,17 +11,17 @@ import {
Save, Save,
Settings, Settings,
Trash2, Trash2,
} from 'lucide-react' } from "lucide-react";
import Link from 'next/link' import Link from "next/link";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { deleteCatalogPage, updateCatalogPage } from '@/actions/catalog' import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker' import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from '@/components/admin/catalog/icon-picker' import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ImagePreview } from '@/components/admin/catalog-manager/image-preview' import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree' import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -29,120 +29,109 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from '@/components/ui/command' } from "@/components/ui/command";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Switch } from "@/components/ui/switch";
SelectContent, import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
SelectItem, import { Textarea } from "@/components/ui/textarea";
SelectTrigger, import { useServerAction } from "@/hooks/use-server-action";
SelectValue, import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
} from '@/components/ui/select' import { cn } from "@/lib/utils";
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 { interface CatalogPageData {
id: number id: number;
caption: string caption: string;
parentId: number parentId: number;
pageLayout: string pageLayout: string;
enabled: string enabled: string;
visible: string visible: string;
minRank: number minRank: number;
clubOnly: string clubOnly: string;
orderNum: number orderNum: number;
iconImage: number iconImage: number;
iconColor: number iconColor: number;
pageHeadline: string pageHeadline: string;
pageTeaser: string pageTeaser: string;
pageSpecial: string | null pageSpecial: string | null;
pageText1: string | null pageText1: string | null;
pageText2: string | null pageText2: string | null;
pageTextDetails: string | null pageTextDetails: string | null;
pageTextTeaser: string | null pageTextTeaser: string | null;
} }
interface CatalogPageFormProps { interface CatalogPageFormProps {
catalogPage: CatalogPageData catalogPage: CatalogPageData;
childPages: { id: number; caption: string; enabled: string }[] childPages: { id: number; caption: string; enabled: string }[];
canEdit: boolean canEdit: boolean;
allPages: { id: number; caption: string }[] allPages: { id: number; caption: string }[];
} }
export function CatalogPageForm({ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }: CatalogPageFormProps) {
catalogPage, const { isPending, run } = useServerAction();
childPages, const formRef = useRef<HTMLFormElement>(null);
canEdit,
allPages,
}: CatalogPageFormProps) {
const { isPending, run } = useServerAction()
const formRef = useRef<HTMLFormElement>(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
caption: catalogPage.caption, caption: catalogPage.caption,
parentId: catalogPage.parentId, parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout, pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled as '0' | '1', enabled: catalogPage.enabled as "0" | "1",
visible: catalogPage.visible as '0' | '1', visible: catalogPage.visible as "0" | "1",
minRank: catalogPage.minRank, minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly as '0' | '1', clubOnly: catalogPage.clubOnly as "0" | "1",
orderNum: catalogPage.orderNum, orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage, iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor, iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline, pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser, pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial || '', pageSpecial: catalogPage.pageSpecial || "",
pageText1: catalogPage.pageText1 || '', pageText1: catalogPage.pageText1 || "",
pageText2: catalogPage.pageText2 || '', pageText2: catalogPage.pageText2 || "",
pageTextDetails: catalogPage.pageTextDetails || '', pageTextDetails: catalogPage.pageTextDetails || "",
pageTextTeaser: catalogPage.pageTextTeaser || '', pageTextTeaser: catalogPage.pageTextTeaser || "",
}) });
// Ctrl+S / Cmd+S to save // Ctrl+S / Cmd+S to save
useEffect(() => { useEffect(() => {
if (!canEdit) return if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') { if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault() e.preventDefault();
formRef.current?.requestSubmit() formRef.current?.requestSubmit();
} }
} }
document.addEventListener('keydown', onKeyDown) document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]) }, [canEdit]);
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) { 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) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), { run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: 'Page updated and catalog synced.', successMessage: "Page updated and catalog synced.",
errorMessage: 'Failed to update.', errorMessage: "Failed to update.",
}) });
} }
function handleDelete() { function handleDelete() {
const childCount = childPages.length const childCount = childPages.length;
const msg = const msg =
childCount > 0 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? 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?' : "Delete this catalog page and all its items?";
if (!confirm(msg)) return if (!confirm(msg)) return;
run(() => deleteCatalogPage({ id: catalogPage.id }), { run(() => deleteCatalogPage({ id: catalogPage.id }), {
successMessage: 'Page deleted.', successMessage: "Page deleted.",
redirectTo: '/admin/catalog', 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 ( return (
<form ref={formRef} onSubmit={handleSubmit}> <form ref={formRef} onSubmit={handleSubmit}>
@@ -175,7 +164,7 @@ export function CatalogPageForm({
<Input <Input
id="caption" id="caption"
value={form.caption} value={form.caption}
onChange={(e) => update('caption', e.target.value)} onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -183,7 +172,7 @@ export function CatalogPageForm({
<Label>Parent Page</Label> <Label>Parent Page</Label>
<ParentCombobox <ParentCombobox
value={form.parentId} value={form.parentId}
onChange={(id) => update('parentId', id)} onChange={(id) => update("parentId", id)}
pages={allPages} pages={allPages}
currentPageId={catalogPage.id} currentPageId={catalogPage.id}
disabled={!canEdit} disabled={!canEdit}
@@ -195,11 +184,11 @@ export function CatalogPageForm({
<Label>Layout</Label> <Label>Layout</Label>
<LayoutSelect <LayoutSelect
value={form.pageLayout} value={form.pageLayout}
onChange={(v) => update('pageLayout', v)} onChange={(v) => update("pageLayout", v)}
disabled={!canEdit} disabled={!canEdit}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? 'Custom layout'} {LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? "Custom layout"}
</p> </p>
</div> </div>
@@ -210,7 +199,7 @@ export function CatalogPageForm({
id="orderNum" id="orderNum"
type="number" type="number"
value={form.orderNum} 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} disabled={!canEdit}
/> />
</div> </div>
@@ -222,7 +211,7 @@ export function CatalogPageForm({
min={1} min={1}
max={100} max={100}
value={form.minRank} 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} disabled={!canEdit}
/> />
</div> </div>
@@ -235,7 +224,7 @@ export function CatalogPageForm({
<Label>Headline</Label> <Label>Headline</Label>
<CatalogImagePicker <CatalogImagePicker
value={form.pageHeadline} value={form.pageHeadline}
onChange={(v) => update('pageHeadline', v)} onChange={(v) => update("pageHeadline", v)}
type="header" type="header"
label="Headline image" label="Headline image"
disabled={!canEdit} disabled={!canEdit}
@@ -246,7 +235,7 @@ export function CatalogPageForm({
<Label>Teaser</Label> <Label>Teaser</Label>
<CatalogImagePicker <CatalogImagePicker
value={form.pageTeaser} value={form.pageTeaser}
onChange={(v) => update('pageTeaser', v)} onChange={(v) => update("pageTeaser", v)}
type="teaser" type="teaser"
label="Teaser image" label="Teaser image"
disabled={!canEdit} disabled={!canEdit}
@@ -258,7 +247,7 @@ export function CatalogPageForm({
id="pageText1" id="pageText1"
label="Text 1" label="Text 1"
value={form.pageText1} value={form.pageText1}
onChange={(v) => update('pageText1', v)} onChange={(v) => update("pageText1", v)}
disabled={!canEdit} disabled={!canEdit}
rows={4} rows={4}
max={5000} max={5000}
@@ -267,7 +256,7 @@ export function CatalogPageForm({
id="pageText2" id="pageText2"
label="Text 2" label="Text 2"
value={form.pageText2} value={form.pageText2}
onChange={(v) => update('pageText2', v)} onChange={(v) => update("pageText2", v)}
disabled={!canEdit} disabled={!canEdit}
rows={4} rows={4}
max={5000} max={5000}
@@ -278,7 +267,7 @@ export function CatalogPageForm({
id="pageTextDetails" id="pageTextDetails"
label="Details" label="Details"
value={form.pageTextDetails} value={form.pageTextDetails}
onChange={(v) => update('pageTextDetails', v)} onChange={(v) => update("pageTextDetails", v)}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
max={5000} max={5000}
@@ -287,7 +276,7 @@ export function CatalogPageForm({
id="pageTextTeaser" id="pageTextTeaser"
label="Text Teaser" label="Text Teaser"
value={form.pageTextTeaser} value={form.pageTextTeaser}
onChange={(v) => update('pageTextTeaser', v)} onChange={(v) => update("pageTextTeaser", v)}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
max={5000} max={5000}
@@ -297,7 +286,7 @@ export function CatalogPageForm({
id="pageSpecial" id="pageSpecial"
label="Special" label="Special"
value={form.pageSpecial} value={form.pageSpecial}
onChange={(v) => update('pageSpecial', v)} onChange={(v) => update("pageSpecial", v)}
disabled={!canEdit} disabled={!canEdit}
rows={2} rows={2}
max={2048} max={2048}
@@ -311,7 +300,7 @@ export function CatalogPageForm({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<IconPicker <IconPicker
value={form.iconImage} value={form.iconImage}
onChange={(id) => update('iconImage', id)} onChange={(id) => update("iconImage", id)}
disabled={!canEdit} disabled={!canEdit}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -328,12 +317,11 @@ export function CatalogPageForm({
min={0} min={0}
className="w-32" className="w-32"
value={form.iconColor} 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} disabled={!canEdit}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Legacy tint value. Leave at default unless you need to match a specific Legacy tint value. Leave at default unless you need to match a specific theme.
theme.
</p> </p>
</div> </div>
</div> </div>
@@ -361,12 +349,10 @@ export function CatalogPageForm({
> >
<span> <span>
{child.caption} {child.caption}
<span className="ml-2 text-muted-foreground font-mono text-xs"> <span className="ml-2 text-muted-foreground font-mono text-xs">#{child.id}</span>
#{child.id}
</span>
</span> </span>
<Badge variant={child.enabled === '1' ? 'default' : 'outline'}> <Badge variant={child.enabled === "1" ? "default" : "outline"}>
{child.enabled === '1' ? 'Enabled' : 'Disabled'} {child.enabled === "1" ? "Enabled" : "Disabled"}
</Badge> </Badge>
</Link> </Link>
))} ))}
@@ -389,13 +375,8 @@ export function CatalogPageForm({
<CatalogIcon iconImage={form.iconImage} size={36} /> <CatalogIcon iconImage={form.iconImage} size={36} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium">{form.caption || 'Untitled'}</p> <p className="truncate text-sm font-medium">{form.caption || "Untitled"}</p>
<Badge <Badge className={cn("mt-1 h-5 px-1.5 text-[10px] font-mono border-0", layoutColorClass)}>
className={cn(
'mt-1 h-5 px-1.5 text-[10px] font-mono border-0',
layoutColorClass,
)}
>
{form.pageLayout} {form.pageLayout}
</Badge> </Badge>
</div> </div>
@@ -407,24 +388,24 @@ export function CatalogPageForm({
id="tg-enabled" id="tg-enabled"
label="Enabled" label="Enabled"
description="Active in the catalog" description="Active in the catalog"
checked={form.enabled === '1'} checked={form.enabled === "1"}
onChange={(v) => update('enabled', v ? '1' : '0')} onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit} disabled={!canEdit}
/> />
<ToggleRow <ToggleRow
id="tg-visible" id="tg-visible"
label="Visible" label="Visible"
description="Shown in navigation" description="Shown in navigation"
checked={form.visible === '1'} checked={form.visible === "1"}
onChange={(v) => update('visible', v ? '1' : '0')} onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit} disabled={!canEdit}
/> />
<ToggleRow <ToggleRow
id="tg-club" id="tg-club"
label="Club Only" label="Club Only"
description="Requires HC subscription" description="Requires HC subscription"
checked={form.clubOnly === '1'} checked={form.clubOnly === "1"}
onChange={(v) => update('clubOnly', v ? '1' : '0')} onChange={(v) => update("clubOnly", v ? "1" : "0")}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -471,7 +452,7 @@ export function CatalogPageForm({
</div> </div>
</div> </div>
</form> </form>
) );
} }
// ── Toggle row ───────────────────────────────────────────────── // ── Toggle row ─────────────────────────────────────────────────
@@ -484,12 +465,12 @@ function ToggleRow({
onChange, onChange,
disabled, disabled,
}: { }: {
id: string id: string;
label: string label: string;
description: string description: string;
checked: boolean checked: boolean;
onChange: (v: boolean) => void onChange: (v: boolean) => void;
disabled?: boolean disabled?: boolean;
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -501,7 +482,7 @@ function ToggleRow({
</div> </div>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} /> <Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
</div> </div>
) );
} }
// ── Text field with counter ──────────────────────────────────── // ── Text field with counter ────────────────────────────────────
@@ -514,12 +495,12 @@ function _TextField({
disabled, disabled,
max, max,
}: { }: {
id: string id: string;
label: string label: string;
value: string value: string;
onChange: (v: string) => void onChange: (v: string) => void;
disabled?: boolean disabled?: boolean;
max?: number max?: number;
}) { }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -539,7 +520,7 @@ function _TextField({
maxLength={max} maxLength={max}
/> />
</div> </div>
) );
} }
function TextareaField({ function TextareaField({
@@ -551,13 +532,13 @@ function TextareaField({
rows = 3, rows = 3,
max, max,
}: { }: {
id: string id: string;
label: string label: string;
value: string value: string;
onChange: (v: string) => void onChange: (v: string) => void;
disabled?: boolean disabled?: boolean;
rows?: number rows?: number;
max?: number max?: number;
}) { }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -578,7 +559,7 @@ function TextareaField({
maxLength={max} maxLength={max}
/> />
</div> </div>
) );
} }
// ── Layout select with color swatch ──────────────────────────── // ── Layout select with color swatch ────────────────────────────
@@ -588,9 +569,9 @@ function LayoutSelect({
onChange, onChange,
disabled, disabled,
}: { }: {
value: string value: string;
onChange: (v: string) => void onChange: (v: string) => void;
disabled?: boolean disabled?: boolean;
}) { }) {
return ( return (
<Select value={value} onValueChange={onChange} disabled={disabled}> <Select value={value} onValueChange={onChange} disabled={disabled}>
@@ -599,19 +580,19 @@ function LayoutSelect({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CATALOG_LAYOUTS.map((layout) => { {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 ( return (
<SelectItem key={layout} value={layout}> <SelectItem key={layout} value={layout}>
<span className="flex items-center gap-2"> <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} {layout}
</span> </span>
</SelectItem> </SelectItem>
) );
})} })}
</SelectContent> </SelectContent>
</Select> </Select>
) );
} }
// ── Parent page combobox with search ──────────────────────────── // ── Parent page combobox with search ────────────────────────────
@@ -623,16 +604,16 @@ function ParentCombobox({
currentPageId, currentPageId,
disabled, disabled,
}: { }: {
value: number value: number;
onChange: (id: number) => void onChange: (id: number) => void;
pages: { id: number; caption: string }[] pages: { id: number; caption: string }[];
currentPageId: number currentPageId: number;
disabled?: boolean disabled?: boolean;
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const options = pages.filter((p) => p.id !== currentPageId) const options = pages.filter((p) => p.id !== currentPageId);
const selected = value === -1 ? { id: -1, caption: 'Root' } : pages.find((p) => p.id === value) const selected = value === -1 ? { id: -1, caption: "Root" } : pages.find((p) => p.id === value);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -648,7 +629,7 @@ function ParentCombobox({
{selected ? ( {selected ? (
<span className="truncate"> <span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5"> <span className="text-muted-foreground font-mono text-xs mr-1.5">
{selected.id === -1 ? '—' : `#${selected.id}`} {selected.id === -1 ? "—" : `#${selected.id}`}
</span> </span>
{selected.caption} {selected.caption}
</span> </span>
@@ -667,11 +648,11 @@ function ParentCombobox({
<CommandItem <CommandItem
value="root" value="root"
onSelect={() => { onSelect={() => {
onChange(-1) onChange(-1);
setOpen(false) 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> <span className="text-muted-foreground font-mono text-xs mr-1.5">—</span>
Root Root
</CommandItem> </CommandItem>
@@ -680,13 +661,11 @@ function ParentCombobox({
key={page.id} key={page.id}
value={`${page.id} ${page.caption}`} value={`${page.id} ${page.caption}`}
onSelect={() => { onSelect={() => {
onChange(page.id) onChange(page.id);
setOpen(false) setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
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="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
<span className="truncate">{page.caption}</span> <span className="truncate">{page.caption}</span>
</CommandItem> </CommandItem>
@@ -696,5 +675,5 @@ function ParentCombobox({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) );
} }
@@ -1,4 +1,4 @@
'use client' "use client";
import { import {
AlertTriangle, AlertTriangle,
@@ -11,46 +11,39 @@ import {
Save, Save,
ShoppingCart, ShoppingCart,
Sparkles, Sparkles,
} from 'lucide-react' } from "lucide-react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { translateCatalogItems } from '@/actions/catalog-items' import { translateCatalogItems } from "@/actions/catalog-items";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
Table, import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
TableBody, import { useServerAction } from "@/hooks/use-server-action";
TableCell, import { FurniIcon } from "./catalog-items-table";
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 { interface BaseItemForTranslation {
id: number id: number;
publicName: string publicName: string;
itemName: string itemName: string;
spriteId: number spriteId: number;
type: string type: string;
} }
interface CatalogTranslateTabProps { interface CatalogTranslateTabProps {
baseItems: BaseItemForTranslation[] baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string> catalogNameMap: Record<number, string>;
canEdit: boolean canEdit: boolean;
furniDataIdList: number[] furniDataIdList: number[];
furniDescriptionMap: Record<number, string> furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }> furniRevisionMap?: Record<number, { classname: string; revision: number }>;
} }
type FilterMode = 'all' | 'missing' | 'modified' type FilterMode = "all" | "missing" | "modified";
export function CatalogTranslateTab({ export function CatalogTranslateTab({
baseItems, baseItems,
@@ -60,164 +53,164 @@ export function CatalogTranslateTab({
furniDescriptionMap, furniDescriptionMap,
furniRevisionMap = {}, furniRevisionMap = {},
}: CatalogTranslateTabProps) { }: CatalogTranslateTabProps) {
const router = useRouter() const router = useRouter();
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false) const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>('all') const [filterMode, setFilterMode] = useState<FilterMode>("all");
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]) const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]);
const [selected, setSelected] = useState<Set<number>>(new Set()) const [selected, setSelected] = useState<Set<number>>(new Set());
// ── Editable state: names + descriptions ───────────────────────── // ── Editable state: names + descriptions ─────────────────────────
const [editedNames, setEditedNames] = useState<Record<number, string>>( const [editedNames, setEditedNames] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])), Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
) );
const [editedDescs, setEditedDescs] = useState<Record<number, string>>( 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 // Track which items have name or description changes
const isNameChanged = useCallback( const isNameChanged = useCallback(
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName, (item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
[editedNames], [editedNames],
) );
const isDescChanged = useCallback( const isDescChanged = useCallback(
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ''), (item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap], [editedDescs, furniDescriptionMap],
) );
const changedItems = useMemo( const changedItems = useMemo(
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)), () => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged], [baseItems, isNameChanged, isDescChanged],
) );
const missingItems = useMemo( const missingItems = useMemo(
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)), () => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
[baseItems, furniDataIdSet], [baseItems, furniDataIdSet],
) );
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
switch (filterMode) { switch (filterMode) {
case 'missing': case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id)) return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case 'modified': case "modified":
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)) return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item));
default: default:
return baseItems return baseItems;
} }
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]) }, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
const toggleOne = useCallback((id: number) => { const toggleOne = useCallback((id: number) => {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id);
else next.add(id) else next.add(id);
return next return next;
}) });
}, []) }, []);
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
setSelected((prev) => setSelected((prev) =>
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)), 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 // Items to act on: selected ones if any, otherwise all
const targetItems = useMemo(() => { const targetItems = useMemo(() => {
if (selected.size === 0) return baseItems if (selected.size === 0) return baseItems;
return baseItems.filter((i) => selected.has(i.id)) return baseItems.filter((i) => selected.has(i.id));
}, [baseItems, selected]) }, [baseItems, selected]);
function handleNameChange(id: number, value: string) { function handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value })) setEditedNames((prev) => ({ ...prev, [id]: value }));
} }
function handleDescChange(id: number, value: string) { function handleDescChange(id: number, value: string) {
setEditedDescs((prev) => ({ ...prev, [id]: value })) setEditedDescs((prev) => ({ ...prev, [id]: value }));
} }
function handleReset() { function handleReset() {
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName]))) setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])));
setEditedDescs( setEditedDescs(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])), Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
) );
} }
async function handleSuggestFromHabboFurni() { async function handleSuggestFromHabboFurni() {
if (targetItems.length === 0) return if (targetItems.length === 0) return;
setIsSuggesting(true) setIsSuggesting(true);
try { try {
// Collect classnames from selected items (or all if none selected) // 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) { if (classnames.length === 0) {
toast.info('No classnames found to look up.') toast.info("No classnames found to look up.");
setIsSuggesting(false) setIsSuggesting(false);
return return;
} }
// Batch in chunks of 50 to avoid too-long URLs // Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50 const CHUNK_SIZE = 50;
const allSuggestions: Record<string, { name: string; description: string }> = {} const allSuggestions: Record<string, { name: string; description: string }> = {};
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) { 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( const res = await fetch(
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(','))}`, `/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
) );
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
Object.assign(allSuggestions, data.suggestions || {}) Object.assign(allSuggestions, data.suggestions || {});
} }
} }
// Apply suggestions to items that have a match (names + descriptions) // Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0 let nameCount = 0;
let descCount = 0 let descCount = 0;
const newNames = { ...editedNames } const newNames = { ...editedNames };
const newDescs = { ...editedDescs } const newDescs = { ...editedDescs };
for (const item of targetItems) { for (const item of targetItems) {
const suggestion = allSuggestions[item.itemName] const suggestion = allSuggestions[item.itemName];
if (!suggestion) continue if (!suggestion) continue;
if (suggestion.name && suggestion.name !== item.publicName) { if (suggestion.name && suggestion.name !== item.publicName) {
newNames[item.id] = suggestion.name newNames[item.id] = suggestion.name;
nameCount++ nameCount++;
} }
const currentDesc = furniDescriptionMap[item.id] ?? '' const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) { if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description newDescs[item.id] = suggestion.description;
descCount++ descCount++;
} }
} }
setEditedNames(newNames) setEditedNames(newNames);
setEditedDescs(newDescs) setEditedDescs(newDescs);
const total = nameCount + descCount const total = nameCount + descCount;
if (total > 0) { if (total > 0) {
const parts = [] const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`) if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`) if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(`HabboFurni: ${parts.join(' + ')} suggested from habbo.it. Review and save.`) toast.success(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`);
} else { } else {
toast.info( toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`, `HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
) );
} }
} catch (err) { } catch (err) {
toast.error(`HabboFurni error: ${(err as Error).message}`) toast.error(`HabboFurni error: ${(err as Error).message}`);
} finally { } finally {
setIsSuggesting(false) setIsSuggesting(false);
} }
} }
async function handleSaveAll() { async function handleSaveAll() {
if (changedItems.length === 0) return if (changedItems.length === 0) return;
run( run(
() => () =>
@@ -229,26 +222,26 @@ export function CatalogTranslateTab({
})), })),
}), }),
{ {
successMessage: 'Translations saved.', successMessage: "Translations saved.",
errorMessage: 'Failed to save translations.', errorMessage: "Failed to save translations.",
onSuccess: (data) => { onSuccess: (data) => {
const d = data as Record<string, number | boolean> const d = data as Record<string, number | boolean>;
const parts = [] const parts = [];
if (d.namesUpdated && (d.namesUpdated as number) > 0) 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) if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`) parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`) if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push('FurnitureData.json synced') if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) { 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') parts.push("emulator cache refreshed");
toast.success(`${parts.join(' · ')}.`) toast.success(`${parts.join(" · ")}.`);
router.refresh() router.refresh();
}, },
}, },
) );
} }
return ( 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)]"> <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" /> <AlertTriangle className="h-3.5 w-3.5" />
<span> <span>
{missingItems.length} item(s) missing from FurnitureData.json — will be {missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save
auto-inserted on save
</span> </span>
</div> </div>
)} )}
@@ -294,7 +286,7 @@ export function CatalogTranslateTab({
) : ( ) : (
<Sparkles className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
)} )}
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ''} Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -305,11 +297,7 @@ export function CatalogTranslateTab({
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Reset Reset
</Button> </Button>
<Button <Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}>
size="sm"
onClick={handleSaveAll}
disabled={isPending || changedItems.length === 0}
>
{isPending ? ( {isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <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" /> <Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
variant={filterMode === 'all' ? 'default' : 'outline'} variant={filterMode === "all" ? "default" : "outline"}
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setFilterMode('all')} onClick={() => setFilterMode("all")}
> >
All ({baseItems.length}) All ({baseItems.length})
</Button> </Button>
<Button <Button
variant={filterMode === 'missing' ? 'default' : 'outline'} variant={filterMode === "missing" ? "default" : "outline"}
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setFilterMode('missing')} onClick={() => setFilterMode("missing")}
disabled={missingItems.length === 0} disabled={missingItems.length === 0}
> >
<AlertTriangle className="mr-1 h-3 w-3" /> <AlertTriangle className="mr-1 h-3 w-3" />
Missing ({missingItems.length}) Missing ({missingItems.length})
</Button> </Button>
<Button <Button
variant={filterMode === 'modified' ? 'default' : 'outline'} variant={filterMode === "modified" ? "default" : "outline"}
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setFilterMode('modified')} onClick={() => setFilterMode("modified")}
disabled={changedItems.length === 0} disabled={changedItems.length === 0}
> >
Modified ({changedItems.length}) Modified ({changedItems.length})
@@ -362,9 +350,7 @@ export function CatalogTranslateTab({
No items to translate in this page. No items to translate in this page.
</p> </p>
) : filteredItems.length === 0 ? ( ) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">No items match the current filter.</p>
No items match the current filter.
</p>
) : ( ) : (
<TooltipProvider> <TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]"> <div className="rounded-md border overflow-auto max-h-[600px]">
@@ -389,19 +375,19 @@ export function CatalogTranslateTab({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredItems.map((item) => { {filteredItems.map((item) => {
const nameChanged = isNameChanged(item) const nameChanged = isNameChanged(item);
const descChanged = isDescChanged(item) const descChanged = isDescChanged(item);
const isChanged = nameChanged || descChanged const isChanged = nameChanged || descChanged;
const isMissing = !furniDataIdSet.has(item.id) const isMissing = !furniDataIdSet.has(item.id);
const currentCatalogName = catalogNameMap[item.id] const currentCatalogName = catalogNameMap[item.id];
return ( return (
<TableRow <TableRow
key={item.id} key={item.id}
className={ className={
isChanged isChanged
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]' ? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: isMissing : isMissing
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]' ? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: undefined : undefined
} }
> >
@@ -414,24 +400,18 @@ export function CatalogTranslateTab({
</TableCell> </TableCell>
<TableCell className="px-1"> <TableCell className="px-1">
{(() => { {(() => {
const revInfo = furniRevisionMap[item.id] const revInfo = furniRevisionMap[item.id];
return revInfo ? ( return revInfo ? (
<FurniIcon <FurniIcon classname={revInfo.classname} revision={revInfo.revision} />
classname={revInfo.classname}
revision={revInfo.revision}
/>
) : ( ) : (
<FurniIcon classname={item.itemName} revision={0} /> <FurniIcon classname={item.itemName} revision={0} />
) );
})()} })()}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell> <TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell> <TableCell>
<Badge <Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs">
variant={item.type === 's' ? 'default' : 'secondary'} {item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type}
className="text-xs"
>
{item.type === 's' ? 'Floor' : item.type === 'i' ? 'Wall' : item.type}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground font-mono"> <TableCell className="text-xs text-muted-foreground font-mono">
@@ -439,10 +419,10 @@ export function CatalogTranslateTab({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
value={editedNames[item.id] || ''} value={editedNames[item.id] || ""}
onChange={(e) => handleNameChange(item.id, e.target.value)} onChange={(e) => handleNameChange(item.id, e.target.value)}
disabled={!canEdit} 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 && ( {currentCatalogName && currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate"> <p className="text-[11px] text-muted-foreground mt-0.5 truncate">
@@ -452,11 +432,11 @@ export function CatalogTranslateTab({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
value={editedDescs[item.id] || ''} value={editedDescs[item.id] || ""}
onChange={(e) => handleDescChange(item.id, e.target.value)} onChange={(e) => handleDescChange(item.id, e.target.value)}
disabled={!canEdit} disabled={!canEdit}
placeholder="No description" 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>
<TableCell> <TableCell>
@@ -464,7 +444,7 @@ export function CatalogTranslateTab({
{isChanged ? ( {isChanged ? (
<Badge <Badge
variant="secondary" 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 Modified
</Badge> </Badge>
@@ -494,7 +474,7 @@ export function CatalogTranslateTab({
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) );
})} })}
</TableBody> </TableBody>
</Table> </Table>
@@ -503,5 +483,5 @@ export function CatalogTranslateTab({
)} )}
</CardContent> </CardContent>
</Card> </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() { export default function CatalogDetailLoading() {
return ( return (
@@ -37,5 +37,5 @@ export default function CatalogDetailLoading() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
+25 -30
View File
@@ -1,42 +1,42 @@
import { ChevronRight } from 'lucide-react' import { ChevronRight } from "lucide-react";
import Link from 'next/link' import Link from "next/link";
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from "next/navigation";
import { Fragment } from 'react' import { Fragment } from "react";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { loadCatalogItemsData } from '@/lib/services/catalog-items-loader' import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import { getAncestors } from '@/lib/services/catalog-tree' import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from './catalog-detail-tabs' import { CatalogDetailTabs } from "./catalog-detail-tabs";
export default async function CatalogEditPage({ export default async function CatalogEditPage({
params, params,
}: { }: {
params: Promise<{ locale: string; id: string }> params: Promise<{ locale: string; id: string }>;
}) { }) {
const { id } = await params const { id } = await params;
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const pageId = parseInt(id, 10) const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound() if (Number.isNaN(pageId)) notFound();
const catalogPage = await prisma.catalogPages.findUnique({ const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId }, where: { id: pageId },
}) });
if (!catalogPage) notFound() if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([ const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({ prisma.catalogPages.findMany({
where: { parentId: pageId }, where: { parentId: pageId },
orderBy: { orderNum: 'asc' }, orderBy: { orderNum: "asc" },
select: { id: true, caption: true, enabled: true }, select: { id: true, caption: true, enabled: true },
}), }),
loadCatalogItemsData(pageId), loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId), getAncestors(catalogPage.parentId),
]) ]);
const { const {
items: catalogItems, items: catalogItems,
@@ -47,24 +47,21 @@ export default async function CatalogEditPage({
furniRevisionMap, furniRevisionMap,
interactionTypes, interactionTypes,
allPages, allPages,
} = itemsData } = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank) const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground"> <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 Catalog
</Link> </Link>
{ancestors.map((bc) => ( {ancestors.map((bc) => (
<Fragment key={bc.id}> <Fragment key={bc.id}>
<ChevronRight className="h-3 w-3 flex-shrink-0" /> <ChevronRight className="h-3 w-3 flex-shrink-0" />
<Link <Link href={`/admin/catalog/${bc.id}`} className="hover:text-foreground transition-colors">
href={`/admin/catalog/${bc.id}`}
className="hover:text-foreground transition-colors"
>
{bc.caption} {bc.caption}
</Link> </Link>
</Fragment> </Fragment>
@@ -77,9 +74,7 @@ export default async function CatalogEditPage({
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">
{catalogPage.caption} {catalogPage.caption}
<span className="ml-2 text-base font-normal text-muted-foreground"> <span className="ml-2 text-base font-normal text-muted-foreground">#{catalogPage.id}</span>
#{catalogPage.id}
</span>
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items
@@ -121,5 +116,5 @@ export default async function CatalogEditPage({
interactionTypes={interactionTypes} interactionTypes={interactionTypes}
/> />
</div> </div>
) );
} }
@@ -1,31 +1,31 @@
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { BcPageDetail } from '../bc-manager' import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({ export default async function BcPageDetailPage({
params: paramsPromise, params: paramsPromise,
}: { }: {
params: Promise<{ locale: string; id: string }> params: Promise<{ locale: string; id: string }>;
}) { }) {
const { id: idStr } = await paramsPromise const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10) const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound() if (Number.isNaN(id)) notFound();
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) { 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 } }) const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!bcPage) notFound() if (!bcPage) notFound();
const bcItems = await prisma.catalogItemsBc.findMany({ const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id }, where: { pageId: id },
orderBy: { orderNumber: 'asc' }, orderBy: { orderNumber: "asc" },
}) });
return ( return (
<BcPageDetail <BcPageDetail
@@ -41,11 +41,11 @@ export default async function BcPageDetailPage({
enabled: bcPage.enabled, enabled: bcPage.enabled,
pageHeadline: bcPage.pageHeadline, pageHeadline: bcPage.pageHeadline,
pageTeaser: bcPage.pageTeaser, pageTeaser: bcPage.pageTeaser,
pageSpecial: bcPage.pageSpecial ?? '', pageSpecial: bcPage.pageSpecial ?? "",
pageText1: bcPage.pageText1 ?? '', pageText1: bcPage.pageText1 ?? "",
pageText2: bcPage.pageText2 ?? '', pageText2: bcPage.pageText2 ?? "",
pageTextDetails: bcPage.pageTextDetails ?? '', pageTextDetails: bcPage.pageTextDetails ?? "",
pageTextTeaser: bcPage.pageTextTeaser ?? '', pageTextTeaser: bcPage.pageTextTeaser ?? "",
}} }}
items={bcItems.map((i) => ({ items={bcItems.map((i) => ({
id: i.id, id: i.id,
@@ -57,5 +57,5 @@ export default async function BcPageDetailPage({
}))} }))}
canEdit={canEdit} 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 { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from "lucide-react";
import Link from 'next/link' import Link from "next/link";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from '@/actions/catalog-bc' import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from "@/actions/catalog-bc";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Dialog, import { Input } from "@/components/ui/input";
DialogContent, import { Label } from "@/components/ui/label";
DialogFooter, import { Switch } from "@/components/ui/switch";
DialogHeader, import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
DialogTitle, import { Textarea } from "@/components/ui/textarea";
} from '@/components/ui/dialog' import { useServerAction } from "@/hooks/use-server-action";
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 ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
export interface BcPageData { export interface BcPageData {
id: number id: number;
parentId: number parentId: number;
caption: string caption: string;
pageLayout: string pageLayout: string;
iconColor: number iconColor: number;
iconImage: number iconImage: number;
orderNum: number orderNum: number;
visible: string visible: string;
enabled: string enabled: string;
pageHeadline: string pageHeadline: string;
pageTeaser: string pageTeaser: string;
pageSpecial: string pageSpecial: string;
pageText1: string pageText1: string;
pageText2: string pageText2: string;
pageTextDetails: string pageTextDetails: string;
pageTextTeaser: string pageTextTeaser: string;
} }
export interface BcItemData { export interface BcItemData {
id: number id: number;
pageId: number pageId: number;
itemIds: string itemIds: string;
catalogName: string catalogName: string;
orderNumber: number orderNumber: number;
extradata: string extradata: string;
} }
interface BcPageDetailProps { interface BcPageDetailProps {
page: BcPageData page: BcPageData;
items: BcItemData[] items: BcItemData[];
canEdit: boolean canEdit: boolean;
} }
// ── Main Component ─────────────────────────────────────────────────── // ── Main Component ───────────────────────────────────────────────────
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) { export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
const { isPending, run } = useServerAction() const { isPending, run } = useServerAction();
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null);
const [editingItem, setEditingItem] = useState<BcItemData | null>(null) const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
const [addingItem, setAddingItem] = useState(false) const [addingItem, setAddingItem] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
caption: page.caption, caption: page.caption,
orderNum: page.orderNum, orderNum: page.orderNum,
enabled: page.enabled as '0' | '1', enabled: page.enabled as "0" | "1",
visible: page.visible as '0' | '1', visible: page.visible as "0" | "1",
pageHeadline: page.pageHeadline, pageHeadline: page.pageHeadline,
pageTeaser: page.pageTeaser, pageTeaser: page.pageTeaser,
pageSpecial: page.pageSpecial, pageSpecial: page.pageSpecial,
@@ -84,31 +71,31 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
pageText2: page.pageText2, pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails, pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser, pageTextTeaser: page.pageTextTeaser,
}) });
// Ctrl+S / Cmd+S to save // Ctrl+S / Cmd+S to save
useEffect(() => { useEffect(() => {
if (!canEdit) return if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') { if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault() e.preventDefault();
formRef.current?.requestSubmit() formRef.current?.requestSubmit();
} }
} }
document.addEventListener('keydown', onKeyDown) document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]) }, [canEdit]);
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) { 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) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
run(() => updateBcPage({ id: page.id, ...form }), { run(() => updateBcPage({ id: page.id, ...form }), {
successMessage: 'BC page updated and catalog synced.', successMessage: "BC page updated and catalog synced.",
errorMessage: 'Failed to update.', errorMessage: "Failed to update.",
}) });
} }
return ( return (
@@ -150,7 +137,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Input <Input
id="caption" id="caption"
value={form.caption} value={form.caption}
onChange={(e) => update('caption', e.target.value)} onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -160,7 +147,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
id="orderNum" id="orderNum"
type="number" type="number"
value={form.orderNum} 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} disabled={!canEdit}
/> />
</div> </div>
@@ -170,7 +157,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Headline</Label> <Label>Headline</Label>
<Input <Input
value={form.pageHeadline} value={form.pageHeadline}
onChange={(e) => update('pageHeadline', e.target.value)} onChange={(e) => update("pageHeadline", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
placeholder="Headline image path" placeholder="Headline image path"
/> />
@@ -179,7 +166,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Teaser</Label> <Label>Teaser</Label>
<Input <Input
value={form.pageTeaser} value={form.pageTeaser}
onChange={(e) => update('pageTeaser', e.target.value)} onChange={(e) => update("pageTeaser", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
placeholder="Teaser image path" placeholder="Teaser image path"
/> />
@@ -190,7 +177,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Text 1</Label> <Label>Text 1</Label>
<Textarea <Textarea
value={form.pageText1} value={form.pageText1}
onChange={(e) => update('pageText1', e.target.value)} onChange={(e) => update("pageText1", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
/> />
@@ -199,7 +186,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Text 2</Label> <Label>Text 2</Label>
<Textarea <Textarea
value={form.pageText2} value={form.pageText2}
onChange={(e) => update('pageText2', e.target.value)} onChange={(e) => update("pageText2", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
/> />
@@ -211,7 +198,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Details</Label> <Label>Details</Label>
<Textarea <Textarea
value={form.pageTextDetails} value={form.pageTextDetails}
onChange={(e) => update('pageTextDetails', e.target.value)} onChange={(e) => update("pageTextDetails", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
/> />
@@ -220,7 +207,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Text Teaser</Label> <Label>Text Teaser</Label>
<Textarea <Textarea
value={form.pageTextTeaser} value={form.pageTextTeaser}
onChange={(e) => update('pageTextTeaser', e.target.value)} onChange={(e) => update("pageTextTeaser", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
/> />
@@ -231,7 +218,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Special</Label> <Label>Special</Label>
<Textarea <Textarea
value={form.pageSpecial} value={form.pageSpecial}
onChange={(e) => update('pageSpecial', e.target.value)} onChange={(e) => update("pageSpecial", e.target.value)}
disabled={!canEdit} disabled={!canEdit}
rows={2} rows={2}
/> />
@@ -263,9 +250,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">No items in this page.</p>
No items in this page.
</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -288,7 +273,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
</TableCell> </TableCell>
<TableCell className="text-xs">{item.orderNumber}</TableCell> <TableCell className="text-xs">{item.orderNumber}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-32 truncate"> <TableCell className="text-xs text-muted-foreground max-w-32 truncate">
{item.extradata || '\u2014'} {item.extradata || "\u2014"}
</TableCell> </TableCell>
{canEdit && ( {canEdit && (
<TableCell> <TableCell>
@@ -308,10 +293,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
size="sm" size="sm"
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]" className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
onClick={() => { onClick={() => {
if (!confirm(`Delete item "${item.catalogName}"?`)) return if (!confirm(`Delete item "${item.catalogName}"?`)) return;
run(() => deleteBcItem({ id: item.id }), { run(() => deleteBcItem({ id: item.id }), {
successMessage: 'Item deleted.', successMessage: "Item deleted.",
}) });
}} }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -340,16 +325,16 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
id="tg-enabled" id="tg-enabled"
label="Enabled" label="Enabled"
description="Active in the BC catalog" description="Active in the BC catalog"
checked={form.enabled === '1'} checked={form.enabled === "1"}
onChange={(v) => update('enabled', v ? '1' : '0')} onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit} disabled={!canEdit}
/> />
<ToggleRow <ToggleRow
id="tg-visible" id="tg-visible"
label="Visible" label="Visible"
description="Shown in BC navigation" description="Shown in BC navigation"
checked={form.visible === '1'} checked={form.visible === "1"}
onChange={(v) => update('visible', v ? '1' : '0')} onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -360,10 +345,8 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
Layout: <span className="font-mono">{page.pageLayout}</span> Layout: <span className="font-mono">{page.pageLayout}</span>
</span> </span>
<span> <span>
Parent:{' '} Parent:{" "}
<span className="font-mono"> <span className="font-mono">{page.parentId <= 0 ? "Root" : `#${page.parentId}`}</span>
{page.parentId <= 0 ? 'Root' : `#${page.parentId}`}
</span>
</span> </span>
</div> </div>
@@ -398,9 +381,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
onClose={() => setEditingItem(null)} onClose={() => setEditingItem(null)}
onSubmit={(data) => { onSubmit={(data) => {
run(() => updateBcItem({ id: editingItem.id, ...data }), { run(() => updateBcItem({ id: editingItem.id, ...data }), {
successMessage: 'Item updated.', successMessage: "Item updated.",
onSuccess: () => setEditingItem(null), onSuccess: () => setEditingItem(null),
}) });
}} }}
/> />
)} )}
@@ -411,24 +394,24 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
item={{ item={{
id: 0, id: 0,
pageId: page.id, pageId: page.id,
itemIds: '', itemIds: "",
catalogName: '', catalogName: "",
orderNumber: 1, orderNumber: 1,
extradata: '', extradata: "",
}} }}
mode="add" mode="add"
isPending={isPending} isPending={isPending}
onClose={() => setAddingItem(false)} onClose={() => setAddingItem(false)}
onSubmit={(data) => { onSubmit={(data) => {
run(() => createBcItem({ pageId: page.id, ...data }), { run(() => createBcItem({ pageId: page.id, ...data }), {
successMessage: 'Item created.', successMessage: "Item created.",
onSuccess: () => setAddingItem(false), onSuccess: () => setAddingItem(false),
}) });
}} }}
/> />
)} )}
</div> </div>
) );
} }
// ── Toggle row ─────────────────────────────────────────────────────── // ── Toggle row ───────────────────────────────────────────────────────
@@ -441,12 +424,12 @@ function ToggleRow({
onChange, onChange,
disabled, disabled,
}: { }: {
id: string id: string;
label: string label: string;
description: string description: string;
checked: boolean checked: boolean;
onChange: (v: boolean) => void onChange: (v: boolean) => void;
disabled?: boolean disabled?: boolean;
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -458,7 +441,7 @@ function ToggleRow({
</div> </div>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} /> <Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
</div> </div>
) );
} }
// ── Item Dialog ────────────────────────────────────────────────────── // ── Item Dialog ──────────────────────────────────────────────────────
@@ -470,29 +453,24 @@ function ItemDialog({
onClose, onClose,
onSubmit, onSubmit,
}: { }: {
item: BcItemData item: BcItemData;
mode: 'add' | 'edit' mode: "add" | "edit";
isPending: boolean isPending: boolean;
onClose: () => void onClose: () => void;
onSubmit: (data: { onSubmit: (data: { itemIds: string; catalogName: string; orderNumber: number; extradata: string }) => void;
itemIds: string
catalogName: string
orderNumber: number
extradata: string
}) => void
}) { }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
itemIds: item.itemIds, itemIds: item.itemIds,
catalogName: item.catalogName, catalogName: item.catalogName,
orderNumber: item.orderNumber, orderNumber: item.orderNumber,
extradata: item.extradata, extradata: item.extradata,
}) });
return ( return (
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === 'add' ? 'Add BC Item' : `Edit Item #${item.id}`}</DialogTitle> <DialogTitle>{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -515,9 +493,7 @@ function ItemDialog({
<Input <Input
type="number" type="number"
value={form.orderNumber} value={form.orderNumber}
onChange={(e) => onChange={(e) => setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))}
setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -534,15 +510,12 @@ function ItemDialog({
<Button variant="outline" onClick={onClose} disabled={isPending}> <Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel Cancel
</Button> </Button>
<Button <Button onClick={() => onSubmit(form)} disabled={isPending || !form.catalogName || !form.itemIds}>
onClick={() => onSubmit(form)}
disabled={isPending || !form.catalogName || !form.itemIds}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === 'add' ? 'Add Item' : 'Save'} {mode === "add" ? "Add Item" : "Save"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function BuilderClubPage() { 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 { Loader2, Plus } from "lucide-react";
import { useState } from 'react' import { useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { createCatalogPage } from '@/actions/catalog' import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from '@/actions/catalog-bc' import { createBcPage } from "@/actions/catalog-bc";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
Dialog, import { Input } from "@/components/ui/input";
DialogContent, import { Label } from "@/components/ui/label";
DialogHeader, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
DialogTitle, import { useServerAction } from "@/hooks/use-server-action";
DialogTrigger, import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
} 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 { interface CatalogActionsProps {
catalogType?: 'normal' | 'bc' catalogType?: "normal" | "bc";
} }
export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps) { export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const [caption, setCaption] = useState('') const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState('-1') const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState('default_3x3') const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === 'bc' const isBc = catalogType === "bc";
function handleCreate() { function handleCreate() {
if (!caption.trim()) { if (!caption.trim()) {
toast.error('Caption is required') toast.error("Caption is required");
return return;
} }
const input = { const input = {
caption: caption.trim(), caption: caption.trim(),
parentId: parseInt(parentId, 10), parentId: parseInt(parentId, 10),
pageLayout, pageLayout,
} };
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input) const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
run(fn, { run(fn, {
successMessage: `Catalog page "${caption.trim()}" created.`, successMessage: `Catalog page "${caption.trim()}" created.`,
onSuccess: (data) => { onSuccess: (data) => {
setOpen(false) setOpen(false);
setCaption('') setCaption("");
setParentId('-1') setParentId("-1");
if (data.id) { if (data.id) {
window.location.href = isBc window.location.href = isBc
? `/admin/catalog/builder-club/${data.id}` ? `/admin/catalog/builder-club/${data.id}`
: `/admin/catalog/${data.id}` : `/admin/catalog/${data.id}`;
} }
}, },
}) });
} }
return ( return (
@@ -76,7 +64,7 @@ export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps)
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{isBc ? 'Create Builder Club Page' : 'Create Catalog Page'}</DialogTitle> <DialogTitle>{isBc ? "Create Builder Club Page" : "Create Catalog Page"}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -124,5 +112,5 @@ export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps)
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
+66 -77
View File
@@ -1,57 +1,56 @@
'use client' "use client";
import { Pencil } from 'lucide-react' import { Pencil } from "lucide-react";
import Link from 'next/link' import Link from "next/link";
import { toggleCatalogPage } from '@/actions/catalog' import { toggleCatalogPage } from "@/actions/catalog";
import { toggleBcPage } from '@/actions/catalog-bc' import { toggleBcPage } from "@/actions/catalog-bc";
import { DataTable } from '@/components/admin/data-table' import { DataTable } from "@/components/admin/data-table";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { useServerAction } from '@/hooks/use-server-action' import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from '@/types' import type { DataTableColumn, PaginatedResult } from "@/types";
interface CatalogPageRow { interface CatalogPageRow {
id: number id: number;
caption: string caption: string;
parentId: number parentId: number;
parentCaption: string parentCaption: string;
pageLayout: string pageLayout: string;
enabled: string enabled: string;
visible: string visible: string;
minRank: number minRank: number;
orderNum: number orderNum: number;
itemCount: number itemCount: number;
childCount: number childCount: number;
} }
interface CatalogTableProps { interface CatalogTableProps {
data: PaginatedResult<CatalogPageRow> data: PaginatedResult<CatalogPageRow>;
canEdit: boolean canEdit: boolean;
catalogType?: 'normal' | 'bc' catalogType?: "normal" | "bc";
} }
export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogTableProps) { export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogTableProps) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const isBc = catalogType === 'bc' const isBc = catalogType === "bc";
// Parent URLs stay in the same catalog; root takes you to the catalog index. // Parent URLs stay in the same catalog; root takes you to the catalog index.
const detailHref = (id: number) => const detailHref = (id: number) => (isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`);
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) { if (isBc) {
const field = action === 'toggleEnabled' ? 'enabled' : 'visible' const field = action === "toggleEnabled" ? "enabled" : "visible";
run(() => toggleBcPage({ id, field }), { successMessage: 'Updated.' }) run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
} else { } else {
run(() => toggleCatalogPage({ id, action }), { successMessage: 'Updated.' }) run(() => toggleCatalogPage({ id, action }), { successMessage: "Updated." });
} }
} }
const columns: DataTableColumn<CatalogPageRow>[] = [ const columns: DataTableColumn<CatalogPageRow>[] = [
{ key: 'id', label: 'ID', sortable: true }, { key: "id", label: "ID", sortable: true },
{ {
key: 'caption', key: "caption",
label: 'Caption', label: "Caption",
sortable: true, sortable: true,
render: (value, row) => ( render: (value, row) => (
<Link href={detailHref(row.id)} className="font-medium text-primary hover:underline"> <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', key: "parentId",
label: 'Parent', label: "Parent",
sortable: true, sortable: true,
render: (_, row) => render: (_, row) =>
row.parentId === -1 ? ( row.parentId === -1 ? (
@@ -73,86 +72,78 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
), ),
}, },
{ {
key: 'pageLayout', key: "pageLayout",
label: 'Layout', label: "Layout",
render: (value) => <Badge variant="secondary">{String(value)}</Badge>, render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
}, },
{ {
key: 'enabled', key: "enabled",
label: 'Enabled', label: "Enabled",
sortable: true, sortable: true,
filterKey: 'filter_enabled', filterKey: "filter_enabled",
filterOptions: [ filterOptions: [
{ label: 'Enabled', value: '1' }, { label: "Enabled", value: "1" },
{ label: 'Disabled', value: '0' }, { label: "Disabled", value: "0" },
], ],
render: (value, row) => render: (value, row) =>
canEdit ? ( canEdit ? (
<button <button
type="button" type="button"
onClick={() => handleToggle(row.id, 'toggleEnabled')} onClick={() => handleToggle(row.id, "toggleEnabled")}
disabled={isPending} disabled={isPending}
className="cursor-pointer disabled:opacity-50" className="cursor-pointer disabled:opacity-50"
> >
<Badge variant={value === '1' ? 'default' : 'outline'}> <Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
{value === '1' ? 'Yes' : 'No'}
</Badge>
</button> </button>
) : ( ) : (
<Badge variant={value === '1' ? 'default' : 'outline'}> <Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
{value === '1' ? 'Yes' : 'No'}
</Badge>
), ),
}, },
{ {
key: 'visible', key: "visible",
label: 'Visible', label: "Visible",
sortable: true, sortable: true,
filterKey: 'filter_visible', filterKey: "filter_visible",
filterOptions: [ filterOptions: [
{ label: 'Visible', value: '1' }, { label: "Visible", value: "1" },
{ label: 'Hidden', value: '0' }, { label: "Hidden", value: "0" },
], ],
render: (value, row) => render: (value, row) =>
canEdit ? ( canEdit ? (
<button <button
type="button" type="button"
onClick={() => handleToggle(row.id, 'toggleVisible')} onClick={() => handleToggle(row.id, "toggleVisible")}
disabled={isPending} disabled={isPending}
className="cursor-pointer disabled:opacity-50" className="cursor-pointer disabled:opacity-50"
> >
<Badge variant={value === '1' ? 'default' : 'outline'}> <Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
{value === '1' ? 'Yes' : 'No'}
</Badge>
</button> </button>
) : ( ) : (
<Badge variant={value === '1' ? 'default' : 'outline'}> <Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
{value === '1' ? 'Yes' : 'No'}
</Badge>
), ),
}, },
...(isBc ...(isBc
? [] ? []
: [ : [
{ {
key: 'minRank' as const, key: "minRank" as const,
label: 'Min Rank', label: "Min Rank",
sortable: true, sortable: true,
} satisfies DataTableColumn<CatalogPageRow>, } satisfies DataTableColumn<CatalogPageRow>,
]), ]),
{ {
key: 'itemCount', key: "itemCount",
label: 'Items', label: "Items",
render: (value) => <span>{String(value)}</span>, render: (value) => <span>{String(value)}</span>,
}, },
{ {
key: 'childCount', key: "childCount",
label: 'Children', label: "Children",
render: (value) => <span>{String(value)}</span>, render: (value) => <span>{String(value)}</span>,
}, },
{ {
key: 'actions', key: "actions",
label: 'Actions', label: "Actions",
render: (_, row) => ( render: (_, row) => (
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<Link href={detailHref(row.id)}> <Link href={detailHref(row.id)}>
@@ -161,7 +152,7 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
</Button> </Button>
), ),
}, },
] ];
return ( return (
<DataTable <DataTable
@@ -169,10 +160,8 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
columns={columns} columns={columns}
searchPlaceholder="Search by caption, layout or item..." searchPlaceholder="Search by caption, layout or item..."
bulkActions={ bulkActions={
canEdit canEdit ? [{ label: "Delete Selected", value: "delete", variant: "destructive" }] : undefined
? [{ 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 { FolderTree, HardHat, List, Package } from "lucide-react";
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from "next/navigation";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
interface CatalogViewToggleProps { interface CatalogViewToggleProps {
currentView: string currentView: string;
catalogType?: string catalogType?: string;
} }
export function CatalogViewToggle({ currentView, catalogType = 'normal' }: CatalogViewToggleProps) { export function CatalogViewToggle({ currentView, catalogType = "normal" }: CatalogViewToggleProps) {
const router = useRouter() const router = useRouter();
const searchParams = useSearchParams() const searchParams = useSearchParams();
function setView(view: string) { function setView(view: string) {
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString());
if (view === 'tree') { if (view === "tree") {
params.delete('view') params.delete("view");
} else { } else {
params.set('view', view) params.set("view", view);
} }
router.push(`?${params.toString()}`) router.push(`?${params.toString()}`);
} }
function setCatalog(type: string) { function setCatalog(type: string) {
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString());
if (type === 'normal') { if (type === "normal") {
params.delete('catalog') params.delete("catalog");
} else { } else {
params.set('catalog', type) params.set("catalog", type);
} }
params.delete('view') // reset to tree when switching params.delete("view"); // reset to tree when switching
router.push(`?${params.toString()}`) router.push(`?${params.toString()}`);
} }
return ( return (
@@ -42,8 +42,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn('h-8 rounded-r-none', catalogType === 'normal' && 'bg-accent')} className={cn("h-8 rounded-r-none", catalogType === "normal" && "bg-accent")}
onClick={() => setCatalog('normal')} onClick={() => setCatalog("normal")}
> >
<Package className="mr-1.5 h-4 w-4" /> <Package className="mr-1.5 h-4 w-4" />
Catalog Catalog
@@ -51,8 +51,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn('h-8 rounded-l-none', catalogType === 'bc' && 'bg-accent')} className={cn("h-8 rounded-l-none", catalogType === "bc" && "bg-accent")}
onClick={() => setCatalog('bc')} onClick={() => setCatalog("bc")}
> >
<HardHat className="mr-1.5 h-4 w-4" /> <HardHat className="mr-1.5 h-4 w-4" />
Builder Club Builder Club
@@ -64,8 +64,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn('h-8 rounded-r-none', currentView === 'tree' && 'bg-accent')} className={cn("h-8 rounded-r-none", currentView === "tree" && "bg-accent")}
onClick={() => setView('tree')} onClick={() => setView("tree")}
> >
<FolderTree className="mr-1.5 h-4 w-4" /> <FolderTree className="mr-1.5 h-4 w-4" />
Tree Tree
@@ -73,13 +73,13 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn('h-8 rounded-l-none', currentView === 'table' && 'bg-accent')} className={cn("h-8 rounded-l-none", currentView === "table" && "bg-accent")}
onClick={() => setView('table')} onClick={() => setView("table")}
> >
<List className="mr-1.5 h-4 w-4" /> <List className="mr-1.5 h-4 w-4" />
Table Table
</Button> </Button>
</div> </div>
</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() { export default function CatalogLoading() {
return ( return (
@@ -40,5 +40,5 @@ export default function CatalogLoading() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
+141 -146
View File
@@ -1,87 +1,87 @@
import dynamic from 'next/dynamic' import dynamic from "next/dynamic";
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { CatalogTree } from '@/components/admin/catalog-tree' import { CatalogTree } from "@/components/admin/catalog-tree";
import { Prisma } from '@/generated/prisma/client' import { Prisma } from "@/generated/prisma/client";
import { calcPagination, parseListParams } from '@/lib/admin-helpers' import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { CatalogActions } from './catalog-actions' import { CatalogActions } from "./catalog-actions";
import { CatalogTable } from './catalog-table' import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from './catalog-view-toggle' import { CatalogViewToggle } from "./catalog-view-toggle";
const CatalogManagerDialog = dynamic(() => 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, default: m.CatalogManagerDialog,
})), })),
) );
export default async function CatalogPagesPage({ export default async function CatalogPagesPage({
params: paramsPromise, params: paramsPromise,
searchParams, searchParams,
}: { }: {
params: Promise<{ locale: string }> params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>> searchParams: Promise<Record<string, string>>;
}) { }) {
await paramsPromise await paramsPromise;
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) { 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 params = await searchParams;
const sp = new URLSearchParams(params) const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp) const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get('view') || 'tree' const viewMode = sp.get("view") || "tree";
const catalogType = sp.get('catalog') || 'normal' const catalogType = sp.get("catalog") || "normal";
// ── Builder Club mode ────────────────────────────────────────── // ── Builder Club mode ──────────────────────────────────────────
type TreeNodeShape = { type TreeNodeShape = {
id: number id: number;
caption: string caption: string;
parentId: number parentId: number;
enabled: string enabled: string;
visible: string visible: string;
iconImage: number iconImage: number;
iconColor: number iconColor: number;
pageLayout: string pageLayout: string;
childCount: number childCount: number;
itemCount: number itemCount: number;
} };
if (catalogType === 'bc') { if (catalogType === "bc") {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([ const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalogPagesBc.count(), prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(), prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: '1' } }), prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
]) ]);
// ── BC tree view ────────────────────────────────────────────── // ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = [] let bcRootPages: TreeNodeShape[] = [];
if (viewMode === 'tree') { if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({ const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] }, where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: 'asc' }, orderBy: { orderNum: "asc" },
}) });
const bcRootIds = bcRootRaw.map((p) => p.id) const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([ const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0 bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({ ? prisma.catalogPagesBc.groupBy({
by: ['parentId'], by: ["parentId"],
where: { parentId: { in: bcRootIds } }, where: { parentId: { in: bcRootIds } },
_count: true, _count: true,
}) })
: [], : [],
bcRootIds.length > 0 bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({ ? prisma.catalogItemsBc.groupBy({
by: ['pageId'], by: ["pageId"],
where: { pageId: { in: bcRootIds } }, where: { pageId: { in: bcRootIds } },
_count: true, _count: true,
}) })
: [], : [],
]) ]);
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count])) const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count])) const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
bcRootPages = bcRootRaw.map((p) => ({ bcRootPages = bcRootRaw.map((p) => ({
id: p.id, id: p.id,
caption: p.caption, caption: p.caption,
@@ -93,45 +93,45 @@ export default async function CatalogPagesPage({
pageLayout: p.pageLayout, pageLayout: p.pageLayout,
childCount: bcChildMap[p.id] ?? 0, childCount: bcChildMap[p.id] ?? 0,
itemCount: bcItemMap[p.id] ?? 0, itemCount: bcItemMap[p.id] ?? 0,
})) }));
} }
// ── BC table view ───────────────────────────────────────────── // ── BC table view ─────────────────────────────────────────────
let bcRows: { let bcRows: {
id: number id: number;
caption: string caption: string;
parentId: number parentId: number;
parentCaption: string parentCaption: string;
pageLayout: string pageLayout: string;
enabled: string enabled: string;
visible: string visible: string;
minRank: number minRank: number;
orderNum: number orderNum: number;
itemCount: number itemCount: number;
childCount: number childCount: number;
}[] = [] }[] = [];
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 } let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === 'table') { if (viewMode === "table") {
const parentFilter = sp.get('parent') const parentFilter = sp.get("parent");
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId) const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) { if (search) {
const pattern = { contains: search } const pattern = { contains: search };
where.OR = [{ caption: pattern }] 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. // Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = { const sortKeyMap: Record<string, string> = {
parentId: 'parentId', parentId: "parentId",
pageLayout: 'pageLayout', pageLayout: "pageLayout",
orderNum: 'orderNum', orderNum: "orderNum",
} };
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'orderNum' const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
const orderBy = { [sortKey]: sort ? order : 'asc' } const orderBy = { [sortKey]: sort ? order : "asc" };
const [pages, total] = await Promise.all([ const [pages, total] = await Promise.all([
prisma.catalogPagesBc.findMany({ prisma.catalogPagesBc.findMany({
@@ -141,23 +141,23 @@ export default async function CatalogPagesPage({
take: perPage, take: perPage,
}), }),
prisma.catalogPagesBc.count({ where }), prisma.catalogPagesBc.count({ where }),
]) ]);
bcPagination = calcPagination(total, page, perPage) bcPagination = calcPagination(total, page, perPage);
const pageIds = pages.map((p) => p.id) const pageIds = pages.map((p) => p.id);
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))] const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
const [childCounts, itemCounts, parentPages] = await Promise.all([ const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0 pageIds.length > 0
? prisma.catalogPagesBc.groupBy({ ? prisma.catalogPagesBc.groupBy({
by: ['parentId'], by: ["parentId"],
where: { parentId: { in: pageIds } }, where: { parentId: { in: pageIds } },
_count: true, _count: true,
}) })
: [], : [],
pageIds.length > 0 pageIds.length > 0
? prisma.catalogItemsBc.groupBy({ ? prisma.catalogItemsBc.groupBy({
by: ['pageId'], by: ["pageId"],
where: { pageId: { in: pageIds } }, where: { pageId: { in: pageIds } },
_count: true, _count: true,
}) })
@@ -168,17 +168,16 @@ export default async function CatalogPagesPage({
select: { id: true, caption: true }, select: { id: true, caption: true },
}) })
: [], : [],
]) ]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count])) const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, 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 parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
bcRows = pages.map((p) => ({ bcRows = pages.map((p) => ({
id: p.id, id: p.id,
caption: p.caption, caption: p.caption,
parentId: p.parentId, parentId: p.parentId,
parentCaption: parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: String(p.pageLayout), pageLayout: String(p.pageLayout),
enabled: String(p.enabled), enabled: String(p.enabled),
visible: String(p.visible), visible: String(p.visible),
@@ -186,7 +185,7 @@ export default async function CatalogPagesPage({
orderNum: p.orderNum, orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0, itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0, childCount: childCountMap[p.id] ?? 0,
})) }));
} }
return ( return (
@@ -203,17 +202,13 @@ export default async function CatalogPagesPage({
{canEdit && <CatalogActions catalogType="bc" />} {canEdit && <CatalogActions catalogType="bc" />}
</div> </div>
</div> </div>
{viewMode === 'tree' ? ( {viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" /> <CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : ( ) : (
<CatalogTable <CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
data={{ rows: bcRows, ...bcPagination }}
canEdit={canEdit}
catalogType="bc"
/>
)} )}
</div> </div>
) );
} }
// ── Normal catalog mode ──────────────────────────────────────── // ── Normal catalog mode ────────────────────────────────────────
@@ -222,35 +217,35 @@ export default async function CatalogPagesPage({
const [totalPages, totalItems, enabledPages] = await Promise.all([ const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPages.count(), prisma.catalogPages.count(),
prisma.catalogItems.count(), prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: '1' } }), prisma.catalogPages.count({ where: { enabled: "1" } }),
]) ]);
// ── Tree view data ────────────────────────────────────────────── // ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = [] let rootPages: TreeNodeShape[] = [];
if (viewMode === 'tree') { if (viewMode === "tree") {
const rootPagesRaw = await prisma.catalogPages.findMany({ const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 }, where: { parentId: -1 },
orderBy: { orderNum: 'asc' }, orderBy: { orderNum: "asc" },
}) });
const rootIds = rootPagesRaw.map((p) => p.id) const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([ const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0 rootIds.length > 0
? prisma.catalogPages.groupBy({ ? prisma.catalogPages.groupBy({
by: ['parentId'], by: ["parentId"],
where: { parentId: { in: rootIds } }, where: { parentId: { in: rootIds } },
_count: true, _count: true,
}) })
: [], : [],
rootIds.length > 0 rootIds.length > 0
? prisma.catalogItems.groupBy({ ? prisma.catalogItems.groupBy({
by: ['pageId'], by: ["pageId"],
where: { pageId: { in: rootIds } }, where: { pageId: { in: rootIds } },
_count: true, _count: true,
}) })
: [], : [],
]) ]);
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count])) const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count])) const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
rootPages = rootPagesRaw.map((p) => ({ rootPages = rootPagesRaw.map((p) => ({
id: p.id, id: p.id,
caption: p.caption, caption: p.caption,
@@ -262,34 +257,34 @@ export default async function CatalogPagesPage({
pageLayout: p.pageLayout, pageLayout: p.pageLayout,
childCount: rootChildMap[p.id] ?? 0, childCount: rootChildMap[p.id] ?? 0,
itemCount: rootItemMap[p.id] ?? 0, itemCount: rootItemMap[p.id] ?? 0,
})) }));
} }
// ── Table view data (skip if tree) ──────────────────────────── // ── Table view data (skip if tree) ────────────────────────────
let rows: { let rows: {
id: number id: number;
caption: string caption: string;
parentId: number parentId: number;
parentCaption: string parentCaption: string;
pageLayout: string pageLayout: string;
enabled: string enabled: string;
visible: string visible: string;
minRank: number minRank: number;
orderNum: number orderNum: number;
itemCount: number itemCount: number;
childCount: number childCount: number;
}[] = [] }[] = [];
let pagination = { total: 0, page: 1, perPage, lastPage: 0 } let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === 'table') { if (viewMode === "table") {
const parentFilter = sp.get('parent') const parentFilter = sp.get("parent");
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId) const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) { if (search) {
const searchPattern = `%${search}%` const searchPattern = `%${search}%`;
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql` const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
SELECT DISTINCT cp.id FROM catalog_pages cp 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.public_name LIKE ${searchPattern}
OR ib.item_name LIKE ${searchPattern}) OR ib.item_name LIKE ${searchPattern})
${parentClause} ${parentClause}
`) `);
where.id = { in: matchedIds.map((r) => r.id) } where.id = { in: matchedIds.map((r) => r.id) };
} else if (hasParent) { } 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([ const [pages, total] = await Promise.all([
prisma.catalogPages.findMany({ prisma.catalogPages.findMany({
@@ -317,24 +312,24 @@ export default async function CatalogPagesPage({
take: perPage, take: perPage,
}), }),
prisma.catalogPages.count({ where }), 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 // Count children and items per page, and resolve parent names in parallel
const pageIds = pages.map((p) => p.id) const pageIds = pages.map((p) => p.id);
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))] const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
const [childCounts, itemCounts, parentPages] = await Promise.all([ const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0 pageIds.length > 0
? prisma.catalogPages.groupBy({ ? prisma.catalogPages.groupBy({
by: ['parentId'], by: ["parentId"],
where: { parentId: { in: pageIds } }, where: { parentId: { in: pageIds } },
_count: true, _count: true,
}) })
: [], : [],
pageIds.length > 0 pageIds.length > 0
? prisma.catalogItems.groupBy({ ? prisma.catalogItems.groupBy({
by: ['pageId'], by: ["pageId"],
where: { pageId: { in: pageIds } }, where: { pageId: { in: pageIds } },
_count: true, _count: true,
}) })
@@ -345,16 +340,16 @@ export default async function CatalogPagesPage({
select: { id: true, caption: true }, select: { id: true, caption: true },
}) })
: [], : [],
]) ]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count])) const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, 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 parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
rows = pages.map((p) => ({ rows = pages.map((p) => ({
id: p.id, id: p.id,
caption: p.caption, caption: p.caption,
parentId: p.parentId, 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, pageLayout: p.pageLayout,
enabled: p.enabled, enabled: p.enabled,
visible: p.visible, visible: p.visible,
@@ -362,7 +357,7 @@ export default async function CatalogPagesPage({
orderNum: p.orderNum, orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0, itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0, childCount: childCountMap[p.id] ?? 0,
})) }));
} }
return ( return (
@@ -381,11 +376,11 @@ export default async function CatalogPagesPage({
</div> </div>
</div> </div>
{viewMode === 'tree' ? ( {viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" /> <CatalogTree rootPages={rootPages} catalogType="normal" />
) : ( ) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} /> <CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)} )}
</div> </div>
) );
} }
+1 -1
View File
@@ -616,7 +616,7 @@ export default async function CommandoCentrum() {
{e.version} {e.version}
</td> </td>
<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} {snippet}
</pre> </pre>
</td> </td>
+15 -15
View File
@@ -1,32 +1,32 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
export default async function EmulatorErrorsPage({ export default async function EmulatorErrorsPage({
searchParams, 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)) { if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const params = await searchParams const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1) const page = Math.max(1, Number(params.page) || 1);
const perPage = 50 const perPage = 50;
const [errors, total] = await Promise.all([ const [errors, total] = await Promise.all([
prisma.emulatorErrors.findMany({ prisma.emulatorErrors.findMany({
orderBy: { id: 'desc' }, orderBy: { id: "desc" },
skip: (page - 1) * perPage, skip: (page - 1) * perPage,
take: perPage, take: perPage,
}), }),
prisma.emulatorErrors.count(), prisma.emulatorErrors.count(),
]) ]);
const lastPage = Math.ceil(total / perPage) const lastPage = Math.ceil(total / perPage);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -52,7 +52,7 @@ export default async function EmulatorErrorsPage({
</span> </span>
</div> </div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded"> <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> </pre>
</div> </div>
))} ))}
@@ -79,5 +79,5 @@ export default async function EmulatorErrorsPage({
</div> </div>
)} )}
</div> </div>
) );
} }
+22 -32
View File
@@ -1,55 +1,45 @@
'use client' "use client";
import { RefreshCw } from 'lucide-react' import { RefreshCw } from "lucide-react";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
export function HealthCheckClient() { export function HealthCheckClient() {
const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking') const [status, setStatus] = useState<"checking" | "online" | "offline">("checking");
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false);
async function checkEmulator() { async function checkEmulator() {
setChecking(true) setChecking(true);
setStatus('checking') setStatus("checking");
try { try {
const res = await fetch('/api/admin/devops/health') const res = await fetch("/api/admin/devops/health");
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
setStatus(data.emulator ? 'online' : 'offline') setStatus(data.emulator ? "online" : "offline");
} else { } else {
setStatus('offline') setStatus("offline");
} }
} catch { } catch {
setStatus('offline') setStatus("offline");
} finally { } finally {
setChecking(false) setChecking(false);
} }
} }
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => { useEffect(() => {
checkEmulator() checkEmulator();
}, []) }, []);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge variant={status === "online" ? "default" : status === "offline" ? "destructive" : "secondary"}>
variant={ {status === "checking" ? "Checking..." : status === "online" ? "Online" : "Offline"}
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
}
>
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
</Badge> </Badge>
<Button <Button variant="ghost" size="icon" className="h-6 w-6" onClick={checkEmulator} disabled={checking}>
variant="ghost" <RefreshCw className={`h-3 w-3 ${checking ? "animate-spin" : ""}`} />
size="icon"
className="h-6 w-6"
onClick={checkEmulator}
disabled={checking}
>
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
</Button> </Button>
</div> </div>
) );
} }
+30 -30
View File
@@ -1,61 +1,61 @@
import { AlertTriangle, Database, HeartPulse, Server } from 'lucide-react' import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { HealthCheckClient } from './health-check-client' import { HealthCheckClient } from "./health-check-client";
async function getDevOpsData() { async function getDevOpsData() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400 const dayAgo = now - 86400;
// DB health — simple count query // DB health — simple count query
let dbStatus: 'healthy' | 'error' = 'healthy' let dbStatus: "healthy" | "error" = "healthy";
let dbLatency = 0 let dbLatency = 0;
try { try {
const start = Date.now() const start = Date.now();
await prisma.user.count({ take: 1 }) await prisma.user.count({ take: 1 });
dbLatency = Date.now() - start dbLatency = Date.now() - start;
} catch { } catch {
dbStatus = 'error' dbStatus = "error";
} }
// Recent emulator errors // Recent emulator errors
const recentErrors = await prisma.emulatorErrors.findMany({ const recentErrors = await prisma.emulatorErrors.findMany({
orderBy: { id: 'desc' }, orderBy: { id: "desc" },
take: 20, take: 20,
}) });
const errorsToday = await prisma.emulatorErrors.count({ const errorsToday = await prisma.emulatorErrors.count({
where: { timestamp: { gte: dayAgo } }, where: { timestamp: { gte: dayAgo } },
}) });
// Emulator settings (version info) // Emulator settings (version info)
const emuVersion = await prisma.emulatorSettings.findFirst({ const emuVersion = await prisma.emulatorSettings.findFirst({
where: { key: 'version' }, where: { key: "version" },
}) });
// Online user count as proxy for emulator health // 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 { return {
dbStatus, dbStatus,
dbLatency, dbLatency,
recentErrors, recentErrors,
errorsToday, errorsToday,
emuVersion: emuVersion?.value ?? 'Unknown', emuVersion: emuVersion?.value ?? "Unknown",
onlineUsers, onlineUsers,
} };
} }
export default async function DevOpsPage() { export default async function DevOpsPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const data = await getDevOpsData() const data = await getDevOpsData();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -72,8 +72,8 @@ export default async function DevOpsPage() {
<Database className="h-4 w-4 text-muted-foreground" /> <Database className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Badge variant={data.dbStatus === 'healthy' ? 'default' : 'destructive'}> <Badge variant={data.dbStatus === "healthy" ? "default" : "destructive"}>
{data.dbStatus === 'healthy' ? 'Healthy' : 'Error'} {data.dbStatus === "healthy" ? "Healthy" : "Error"}
</Badge> </Badge>
<p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p> <p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p>
</CardContent> </CardContent>
@@ -134,7 +134,7 @@ export default async function DevOpsPage() {
</span> </span>
</div> </div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive"> <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> </pre>
</div> </div>
))} ))}
@@ -143,5 +143,5 @@ export default async function DevOpsPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
+39 -55
View File
@@ -1,62 +1,56 @@
'use client' "use client";
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from "lucide-react";
import { useState } from 'react' import { useState } from "react";
import { addEventPrize, deleteEventPrize } from '@/actions/events' import { addEventPrize, deleteEventPrize } from "@/actions/events";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { useServerAction } from "@/hooks/use-server-action";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
interface Prize { interface Prize {
id: number id: number;
position: number position: number;
prizeType: string prizeType: string;
badgeCode: string | null badgeCode: string | null;
credits: number credits: number;
pixels: number pixels: number;
points: number points: number;
itemId: number | null itemId: number | null;
description: string description: string;
} }
interface Props { interface Props {
eventId: number eventId: number;
prizes: Prize[] prizes: Prize[];
} }
export function EventPrizesManager({ eventId, prizes }: Props) { export function EventPrizesManager({ eventId, prizes }: Props) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
position: prizes.length + 1, position: prizes.length + 1,
prizeType: 'badge' as string, prizeType: "badge" as string,
badgeCode: '', badgeCode: "",
credits: 0, credits: 0,
pixels: 0, pixels: 0,
points: 0, points: 0,
description: '', description: "",
}) });
function handleAdd(e: React.FormEvent) { function handleAdd(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code // biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => addEventPrize({ eventId, ...form } as any), { run(() => addEventPrize({ eventId, ...form } as any), {
successMessage: 'Prize added!', successMessage: "Prize added!",
onSuccess: () => { onSuccess: () => {
setShowForm(false) setShowForm(false);
setForm({ ...form, position: form.position + 1, badgeCode: '', description: '' }) setForm({ ...form, position: form.position + 1, badgeCode: "", description: "" });
}, },
}) });
} }
return ( return (
@@ -81,10 +75,7 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Type</Label> <Label>Type</Label>
<Select <Select value={form.prizeType} onValueChange={(v) => setForm({ ...form, prizeType: v })}>
value={form.prizeType}
onValueChange={(v) => setForm({ ...form, prizeType: v })}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -133,16 +124,11 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
)} )}
{prizes.length === 0 && !showForm && ( {prizes.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">No prizes configured yet.</p>
No prizes configured yet.
</p>
)} )}
{prizes.map((prize) => ( {prizes.map((prize) => (
<div <div key={prize.id} className="flex items-center justify-between border rounded-lg px-4 py-3">
key={prize.id}
className="flex items-center justify-between border rounded-lg px-4 py-3"
>
<div className="flex items-center gap-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"> <span className="flex h-8 w-8 items-center justify-center rounded-full bg-accent font-bold text-sm">
#{prize.position} #{prize.position}
@@ -155,16 +141,14 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
{prize.pixels > 0 && <span className="text-sm">{prize.pixels} pixels</span>} {prize.pixels > 0 && <span className="text-sm">{prize.pixels} pixels</span>}
{prize.points > 0 && <span className="text-sm">{prize.points} points</span>} {prize.points > 0 && <span className="text-sm">{prize.points} points</span>}
</div> </div>
{prize.description && ( {prize.description && <p className="text-xs text-muted-foreground">{prize.description}</p>}
<p className="text-xs text-muted-foreground">{prize.description}</p>
)}
</div> </div>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => 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" /> <Trash2 className="h-4 w-4 text-destructive" />
@@ -173,5 +157,5 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
))} ))}
</CardContent> </CardContent>
</Card> </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 { interface Registration {
id: number id: number;
userId: number userId: number;
username: string username: string;
createdAt: Date createdAt: Date;
} }
interface Props { interface Props {
registrations: Registration[] registrations: Registration[];
} }
export function EventRegistrations({ registrations }: Props) { export function EventRegistrations({ registrations }: Props) {
@@ -36,5 +36,5 @@ export function EventRegistrations({ registrations }: Props) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
) );
} }
+27 -27
View File
@@ -1,43 +1,43 @@
'use client' "use client";
import { Plus } from 'lucide-react' import { Plus } from "lucide-react";
import { useState } from 'react' import { useState } from "react";
import { addEventWinner } from '@/actions/events' import { addEventWinner } from "@/actions/events";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { useServerAction } from '@/hooks/use-server-action' import { useServerAction } from "@/hooks/use-server-action";
interface Winner { interface Winner {
id: number id: number;
userId: number userId: number;
position: number position: number;
username: string username: string;
createdAt: Date createdAt: Date;
} }
interface Props { interface Props {
eventId: number eventId: number;
winners: Winner[] winners: Winner[];
} }
export function EventWinnersManager({ eventId, winners }: Props) { export function EventWinnersManager({ eventId, winners }: Props) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false);
const [userId, setUserId] = useState('') const [userId, setUserId] = useState("");
const [position, setPosition] = useState(winners.length + 1) const [position, setPosition] = useState(winners.length + 1);
function handleAdd(e: React.FormEvent) { function handleAdd(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
run(() => addEventWinner({ eventId, userId: Number(userId), position }), { run(() => addEventWinner({ eventId, userId: Number(userId), position }), {
successMessage: 'Winner added!', successMessage: "Winner added!",
onSuccess: () => { onSuccess: () => {
setShowForm(false) setShowForm(false);
setUserId('') setUserId("");
setPosition(position + 1) setPosition(position + 1);
}, },
}) });
} }
return ( return (
@@ -95,5 +95,5 @@ export function EventWinnersManager({ eventId, winners }: Props) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
+38 -47
View File
@@ -1,75 +1,70 @@
import { CalendarDays, Gift, Trophy, Users } from 'lucide-react' import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from "next/navigation";
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb' import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { EventForm } from '../event-form' import { EventForm } from "../event-form";
import { EventPrizesManager } from './event-prizes' import { EventPrizesManager } from "./event-prizes";
import { EventRegistrations } from './event-registrations' import { EventRegistrations } from "./event-registrations";
import { EventWinnersManager } from './event-winners' import { EventWinnersManager } from "./event-winners";
export default async function EventDetailPage({ export default async function EventDetailPage({
params, params,
}: { }: {
params: Promise<{ locale: string; id: string }> params: Promise<{ locale: string; id: string }>;
}) { }) {
const { id } = await params const { id } = await params;
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) { if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect('/admin/events') redirect("/admin/events");
} }
const eventId = parseInt(id, 10) const eventId = parseInt(id, 10);
if (Number.isNaN(eventId)) notFound() if (Number.isNaN(eventId)) notFound();
const event = await prisma.websiteEvent.findUnique({ const event = await prisma.websiteEvent.findUnique({
where: { id: eventId }, where: { id: eventId },
include: { include: {
type: true, type: true,
prizes: { orderBy: { position: 'asc' } }, prizes: { orderBy: { position: "asc" } },
winners: { orderBy: { position: 'asc' } }, winners: { orderBy: { position: "asc" } },
registrations: { orderBy: { createdAt: 'desc' } }, registrations: { orderBy: { createdAt: "desc" } },
}, },
}) });
if (!event) notFound() if (!event) notFound();
const eventTypes = await prisma.websiteEventType.findMany({ const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 }, where: { isActive: 1 },
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
}) });
// Fetch usernames for registrations and winners // Fetch usernames for registrations and winners
const userIds = [ const userIds = [...event.registrations.map((r) => r.userId), ...event.winners.map((w) => w.userId)];
...event.registrations.map((r) => r.userId),
...event.winners.map((w) => w.userId),
]
const users = const users =
userIds.length > 0 userIds.length > 0
? await prisma.user.findMany({ ? await prisma.user.findMany({
where: { id: { in: [...new Set(userIds)] } }, where: { id: { in: [...new Set(userIds)] } },
select: { id: true, username: true }, 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<AdminBreadcrumb <AdminBreadcrumb items={[{ label: "Events", href: "/admin/events" }, { label: event.title }]} />
items={[{ label: 'Events', href: '/admin/events' }, { label: event.title }]}
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1> <h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
<Badge <Badge
variant={ variant={
event.status === 'published' event.status === "published"
? 'default' ? "default"
: event.status === 'cancelled' : event.status === "cancelled"
? 'destructive' ? "destructive"
: 'secondary' : "secondary"
} }
> >
{event.status} {event.status}
@@ -89,9 +84,7 @@ export default async function EventDetailPage({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{event.registrations.length}</div> <div className="text-2xl font-bold">{event.registrations.length}</div>
{event.maxPlayers && ( {event.maxPlayers && <p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>}
<p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -119,12 +112,10 @@ export default async function EventDetailPage({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{new Date(event.startsAt) > new Date() ? 'Upcoming' : 'Past'} {new Date(event.startsAt) > new Date() ? "Upcoming" : "Past"}
</div> </div>
{event.endsAt && ( {event.endsAt && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">Ends: {new Date(event.endsAt).toLocaleString()}</p>
Ends: {new Date(event.endsAt).toLocaleString()}
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -144,7 +135,7 @@ export default async function EventDetailPage({
maxPlayers: event.maxPlayers, maxPlayers: event.maxPlayers,
isRecurring: event.isRecurring, isRecurring: event.isRecurring,
recurrenceRule: event.recurrenceRule, recurrenceRule: event.recurrenceRule,
status: event.status as 'draft' | 'published' | 'cancelled' | 'completed', status: event.status as "draft" | "published" | "cancelled" | "completed",
image: event.image, image: event.image,
}} }}
/> />
@@ -169,5 +160,5 @@ export default async function EventDetailPage({
}))} }))}
/> />
</div> </div>
) );
} }
+9 -9
View File
@@ -1,18 +1,18 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { EventForm } from '../event-form' import { EventForm } from "../event-form";
export default async function CreateEventPage() { export default async function CreateEventPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) { if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect('/admin/events') redirect("/admin/events");
} }
const eventTypes = await prisma.websiteEventType.findMany({ const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 }, where: { isActive: 1 },
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
}) });
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -23,5 +23,5 @@ export default async function CreateEventPage() {
<EventForm eventTypes={eventTypes} /> <EventForm eventTypes={eventTypes} />
</div> </div>
) );
} }
+46 -62
View File
@@ -1,66 +1,60 @@
'use client' "use client";
import { useForm } from 'react-hook-form' import { useForm } from "react-hook-form";
import { createEvent, updateEvent } from '@/actions/events' import { createEvent, updateEvent } from "@/actions/events";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from '@/components/ui/date-time-picker' import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Textarea } from "@/components/ui/textarea";
SelectContent, import { useServerAction } from "@/hooks/use-server-action";
SelectItem, import type { CreateEventInput } from "@/lib/validators/event";
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 { interface EventType {
id: number id: number;
name: string name: string;
slug: string slug: string;
color: string color: string;
} }
interface EventFormProps { interface EventFormProps {
eventTypes: EventType[] eventTypes: EventType[];
defaultValues?: Partial<CreateEventInput> & { id?: number } defaultValues?: Partial<CreateEventInput> & { id?: number };
} }
export function EventForm({ eventTypes, defaultValues }: EventFormProps) { export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id const isEditing = !!defaultValues?.id;
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
title: '', title: "",
description: '', description: "",
typeId: eventTypes[0]?.id, typeId: eventTypes[0]?.id,
status: 'draft', status: "draft",
isRecurring: 0, isRecurring: 0,
...defaultValues, ...defaultValues,
}, },
}) });
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation // biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
function onSubmit(data: any) { function onSubmit(data: any) {
if (isEditing && defaultValues?.id) { if (isEditing && defaultValues?.id) {
run(() => updateEvent({ id: defaultValues.id!, ...data }), { run(() => updateEvent({ id: defaultValues.id!, ...data }), {
successMessage: 'Event updated!', successMessage: "Event updated!",
}) });
} else { } else {
run(() => createEvent(data), { run(() => createEvent(data), {
successMessage: 'Event created!', successMessage: "Event created!",
onSuccess: (result) => { onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined const data = result.data as Record<string, unknown> | undefined;
if (data?.id) { 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)}> <form onSubmit={form.handleSubmit(onSubmit)}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{isEditing ? 'Edit Event' : 'Event Details'}</CardTitle> <CardTitle>{isEditing ? "Edit Event" : "Event Details"}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input id="title" {...form.register('title')} /> <Input id="title" {...form.register("title")} />
{form.formState.errors.title && ( {form.formState.errors.title && (
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p> <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"> <div className="space-y-2">
<Label htmlFor="typeId">Event Type</Label> <Label htmlFor="typeId">Event Type</Label>
<Select <Select
value={String(form.watch('typeId'))} value={String(form.watch("typeId"))}
onValueChange={(v) => form.setValue('typeId', Number(v))} onValueChange={(v) => form.setValue("typeId", Number(v))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select type" /> <SelectValue placeholder="Select type" />
@@ -102,54 +96,44 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea id="description" rows={4} {...form.register('description')} /> <Textarea id="description" rows={4} {...form.register("description")} />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Starts At</Label> <Label>Starts At</Label>
<DateTimePicker <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 // 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" placeholder="Select start date"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Ends At</Label> <Label>Ends At</Label>
<DateTimePicker <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 // 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" placeholder="Select end date"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="maxPlayers">Max Players</Label> <Label htmlFor="maxPlayers">Max Players</Label>
<Input <Input id="maxPlayers" type="number" {...form.register("maxPlayers")} placeholder="Unlimited" />
id="maxPlayers"
type="number"
{...form.register('maxPlayers')}
placeholder="Unlimited"
/>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="roomId">Room ID</Label> <Label htmlFor="roomId">Room ID</Label>
<Input <Input id="roomId" type="number" {...form.register("roomId")} placeholder="Optional" />
id="roomId"
type="number"
{...form.register('roomId')}
placeholder="Optional"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="status">Status</Label> <Label htmlFor="status">Status</Label>
<Select <Select
value={form.watch('status')} value={form.watch("status")}
onValueChange={(v) => form.setValue('status', v as CreateEventInput['status'])} onValueChange={(v) => form.setValue("status", v as CreateEventInput["status"])}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -166,16 +150,16 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="image">Image URL</Label> <Label htmlFor="image">Image URL</Label>
<Input id="image" {...form.register('image')} placeholder="https://..." /> <Input id="image" {...form.register("image")} placeholder="https://..." />
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : isEditing ? 'Update Event' : 'Create Event'} {isPending ? "Saving..." : isEditing ? "Update Event" : "Create Event"}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</form> </form>
) );
} }
+37 -37
View File
@@ -1,66 +1,66 @@
'use client' "use client";
import { Pencil } from 'lucide-react' import { Pencil } from "lucide-react";
import Link from 'next/link' import Link from "next/link";
import { DataTable } from '@/components/admin/data-table' import { DataTable } from "@/components/admin/data-table";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from '@/types' import type { DataTableColumn, PaginatedResult } from "@/types";
interface EventRow { interface EventRow {
id: number id: number;
title: string title: string;
typeName: string typeName: string;
typeColor: string typeColor: string;
startsAt: Date startsAt: Date;
endsAt: Date | null endsAt: Date | null;
status: string status: string;
hostUserId: number hostUserId: number;
} }
const statusVariants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = { const statusVariants: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
published: 'default', published: "default",
draft: 'secondary', draft: "secondary",
cancelled: 'destructive', cancelled: "destructive",
completed: 'outline', completed: "outline",
} };
function getColumns(): DataTableColumn<EventRow>[] { function getColumns(): DataTableColumn<EventRow>[] {
return [ return [
{ key: 'id', label: 'ID', sortable: true }, { key: "id", label: "ID", sortable: true },
{ {
key: 'title', key: "title",
label: 'Title', label: "Title",
sortable: true, sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>, render: (value) => <span className="font-medium">{String(value)}</span>,
}, },
{ {
key: 'typeName', key: "typeName",
label: 'Type', label: "Type",
sortable: false, sortable: false,
render: (_, row) => ( render: (_, row) => (
<Badge style={{ backgroundColor: row.typeColor, color: '#fff' }}>{row.typeName}</Badge> <Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>{row.typeName}</Badge>
), ),
}, },
{ {
key: 'startsAt', key: "startsAt",
label: 'Starts', label: "Starts",
sortable: true, sortable: true,
render: (value) => ( render: (value) => (
<span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span> <span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span>
), ),
}, },
{ {
key: 'status', key: "status",
label: 'Status', label: "Status",
sortable: true, sortable: true,
render: (value) => ( render: (value) => (
<Badge variant={statusVariants[String(value)] ?? 'secondary'}>{String(value)}</Badge> <Badge variant={statusVariants[String(value)] ?? "secondary"}>{String(value)}</Badge>
), ),
}, },
{ {
key: 'actions', key: "actions",
label: 'Actions', label: "Actions",
render: (_, row) => ( render: (_, row) => (
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}> <Link href={`/admin/events/${row.id}`}>
@@ -69,13 +69,13 @@ function getColumns(): DataTableColumn<EventRow>[] {
</Button> </Button>
), ),
}, },
] ];
} }
interface EventsTableProps { interface EventsTableProps {
data: PaginatedResult<EventRow> data: PaginatedResult<EventRow>;
} }
export function EventsTable({ data }: EventsTableProps) { 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 Link from "next/link";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { fetchAdminList } from '@/lib/admin-list' import { fetchAdminList } from "@/lib/admin-list";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { EventsTable } from './events-table' import { EventsTable } from "./events-table";
export default async function EventsPage({ export default async function EventsPage({
params: paramsPromise, params: paramsPromise,
searchParams, searchParams,
}: { }: {
params: Promise<{ locale: string }> params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>> searchParams: Promise<Record<string, string>>;
}) { }) {
const result = await fetchAdminList( const result = await fetchAdminList(
{ {
permission: PERMS.EVENTS_VIEW, permission: PERMS.EVENTS_VIEW,
model: 'websiteEvent', model: "websiteEvent",
searchFields: ['title'], searchFields: ["title"],
defaultSort: { startsAt: 'desc' }, defaultSort: { startsAt: "desc" },
include: { type: { select: { name: true, color: true } } }, include: { type: { select: { name: true, color: true } } },
mapRow: (e: { mapRow: (e: {
id: number id: number;
title: string title: string;
type: { name: string; color: string } type: { name: string; color: string };
startsAt: Date startsAt: Date;
endsAt: Date | null endsAt: Date | null;
status: string status: string;
hostUserId: number hostUserId: number;
_count?: { registrations: number } _count?: { registrations: number };
}) => ({ }) => ({
id: e.id, id: e.id,
title: e.title, title: e.title,
@@ -40,7 +40,7 @@ export default async function EventsPage({
}, },
paramsPromise, paramsPromise,
searchParams, searchParams,
) );
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -64,5 +64,5 @@ export default async function EventsPage({
}} }}
/> />
</div> </div>
) );
} }
@@ -1,47 +1,47 @@
'use client' "use client";
import { Pencil, Plus, Trash2 } from 'lucide-react' import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from 'react' import { useState } from "react";
import { createEventType, deleteEventType, updateEventType } from '@/actions/events' import { createEventType, deleteEventType, updateEventType } from "@/actions/events";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { useServerAction } from '@/hooks/use-server-action' import { useServerAction } from "@/hooks/use-server-action";
interface EventType { interface EventType {
id: number id: number;
name: string name: string;
slug: string slug: string;
description: string description: string;
color: string color: string;
icon: string icon: string;
isActive: number isActive: number;
minRank: number minRank: number;
} }
interface Props { interface Props {
eventTypes: EventType[] eventTypes: EventType[];
} }
export function EventTypesManager({ eventTypes }: Props) { export function EventTypesManager({ eventTypes }: Props) {
const { run, isPending } = useServerAction() const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: "",
slug: '', slug: "",
description: '', description: "",
color: '#3b82f6', color: "#3b82f6",
icon: 'calendar', icon: "calendar",
minRank: 0, minRank: 0,
}) });
function resetForm() { function resetForm() {
setForm({ name: '', slug: '', description: '', color: '#3b82f6', icon: 'calendar', minRank: 0 }) setForm({ name: "", slug: "", description: "", color: "#3b82f6", icon: "calendar", minRank: 0 });
setEditingId(null) setEditingId(null);
setShowForm(false) setShowForm(false);
} }
function handleEdit(et: EventType) { function handleEdit(et: EventType) {
@@ -52,30 +52,30 @@ export function EventTypesManager({ eventTypes }: Props) {
color: et.color, color: et.color,
icon: et.icon, icon: et.icon,
minRank: et.minRank, minRank: et.minRank,
}) });
setEditingId(et.id) setEditingId(et.id);
setShowForm(true) setShowForm(true);
} }
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
if (editingId) { if (editingId) {
run(() => updateEventType({ id: editingId, ...form }), { run(() => updateEventType({ id: editingId, ...form }), {
successMessage: 'Event type updated!', successMessage: "Event type updated!",
onSuccess: resetForm, onSuccess: resetForm,
}) });
} else { } else {
run(() => createEventType(form), { run(() => createEventType(form), {
successMessage: 'Event type created!', successMessage: "Event type created!",
onSuccess: resetForm, onSuccess: resetForm,
}) });
} }
} }
function handleDelete(id: number) { function handleDelete(id: number) {
run(() => deleteEventType({ id }), { run(() => deleteEventType({ id }), {
successMessage: 'Event type deleted!', successMessage: "Event type deleted!",
}) });
} }
return ( return (
@@ -90,7 +90,7 @@ export function EventTypesManager({ eventTypes }: Props) {
{showForm && ( {showForm && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{editingId ? 'Edit Event Type' : 'New Event Type'}</CardTitle> <CardTitle>{editingId ? "Edit Event Type" : "New Event Type"}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -136,13 +136,9 @@ export function EventTypesManager({ eventTypes }: Props) {
min={0} min={0}
max={7} max={7}
value={form.minRank} value={form.minRank}
onChange={(e) => onChange={(e) => setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })}
setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })
}
/> />
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+</p>
0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+
</p>
</div> </div>
</div> </div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
@@ -150,7 +146,7 @@ export function EventTypesManager({ eventTypes }: Props) {
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : editingId ? 'Update' : 'Create'} {isPending ? "Saving..." : editingId ? "Update" : "Create"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -174,8 +170,8 @@ export function EventTypesManager({ eventTypes }: Props) {
<span className="font-medium">{et.name}</span> <span className="font-medium">{et.name}</span>
<span className="ml-2 text-xs text-muted-foreground">{et.slug}</span> <span className="ml-2 text-xs text-muted-foreground">{et.slug}</span>
</div> </div>
<Badge variant={et.isActive ? 'default' : 'outline'}> <Badge variant={et.isActive ? "default" : "outline"}>
{et.isActive ? 'Active' : 'Inactive'} {et.isActive ? "Active" : "Inactive"}
</Badge> </Badge>
{et.minRank > 0 && ( {et.minRank > 0 && (
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
@@ -197,5 +193,5 @@ export function EventTypesManager({ eventTypes }: Props) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
+9 -9
View File
@@ -1,17 +1,17 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { EventTypesManager } from './event-types-manager' import { EventTypesManager } from "./event-types-manager";
export default async function EventTypesPage() { export default async function EventTypesPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) { if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect('/admin/events') redirect("/admin/events");
} }
const eventTypes = await prisma.websiteEventType.findMany({ const eventTypes = await prisma.websiteEventType.findMany({
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
}) });
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -22,5 +22,5 @@ export default async function EventTypesPage() {
<EventTypesManager eventTypes={eventTypes} /> <EventTypesManager eventTypes={eventTypes} />
</div> </div>
) );
} }
+3 -1
View File
@@ -60,7 +60,9 @@ export default async function AdminHelpQuestions() {
<tbody> <tbody>
{entries.map((e) => ( {entries.map((e) => (
<tr key={String(e.id)}> <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> <td>
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link> <Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td> </td>
@@ -1,62 +1,56 @@
'use client' "use client";
import { Loader2 } from 'lucide-react' import { Loader2 } from "lucide-react";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { getBadgeData, updateBadge } from '@/actions/badges' import { getBadgeData, updateBadge } from "@/actions/badges";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Dialog, import { Input } from "@/components/ui/input";
DialogContent, import { Label } from "@/components/ui/label";
DialogFooter, import { useServerAction } from "@/hooks/use-server-action";
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 { interface BadgeEditorDialogProps {
code: string code: string;
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
/** Called after a successful save so the parent can refresh state */ /** Called after a successful save so the parent can refresh state */
onSaved?: () => void onSaved?: () => void;
} }
export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) { export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) {
const [name, setName] = useState('') const [name, setName] = useState("");
const [desc, setDesc] = useState('') const [desc, setDesc] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const { run, isPending: saving } = useServerAction() const { run, isPending: saving } = useServerAction();
// Fetch current values when dialog opens // Fetch current values when dialog opens
useEffect(() => { useEffect(() => {
if (!open || !code) return if (!open || !code) return;
setLoading(true) setLoading(true);
getBadgeData({ code }) getBadgeData({ code })
.then((result) => { .then((result) => {
if (result.ok) { if (result.ok) {
const data = result.data as { name: string; desc: string } const data = result.data as { name: string; desc: string };
setName(data.name ?? '') setName(data.name ?? "");
setDesc(data.desc ?? '') setDesc(data.desc ?? "");
} }
}) })
.catch(() => { .catch(() => {
toast.error('Failed to load badge data') toast.error("Failed to load badge data");
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false));
}, [open, code]) }, [open, code]);
function handleSave() { function handleSave() {
run(() => updateBadge({ code, name, desc }), { run(() => updateBadge({ code, name, desc }), {
successMessage: `Badge ${code} updated`, successMessage: `Badge ${code} updated`,
errorMessage: 'Save failed', errorMessage: "Save failed",
onSuccess: () => { onSuccess: () => {
onSaved?.() onSaved?.();
onOpenChange(false) onOpenChange(false);
}, },
}) });
} }
return ( return (
@@ -104,5 +98,5 @@ export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEd
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
@@ -1,4 +1,4 @@
'use client' "use client";
import { import {
AlertCircle, AlertCircle,
@@ -12,183 +12,179 @@ import {
Pencil, Pencil,
Search, Search,
X, X,
} from 'lucide-react' } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { importBadgeFromRemote } from '@/actions/import-badges' import { importBadgeFromRemote } from "@/actions/import-badges";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { BadgeEditorDialog } from './badge-editor-dialog' import { BadgeEditorDialog } from "./badge-editor-dialog";
interface BadgeItem { interface BadgeItem {
id: number id: number;
code: string code: string;
name: string name: string;
description: string description: string;
hotel: string hotel: string;
url_habbo: string url_habbo: string;
url_habboassets: string url_habboassets: string;
alreadyImported: boolean alreadyImported: boolean;
} }
const PAGE_SIZE = 100 const PAGE_SIZE = 100;
export function ImportBadgesClient() { export function ImportBadgesClient() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState('') const [activeSearch, setActiveSearch] = useState("");
const [badges, setBadges] = useState<BadgeItem[]>([]) const [badges, setBadges] = useState<BadgeItem[]>([]);
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [importingCode, setImportingCode] = useState<string | null>(null) const [importingCode, setImportingCode] = useState<string | null>(null);
const [error, setError] = useState('') const [error, setError] = useState("");
const [allHotels, setAllHotels] = useState(true) const [allHotels, setAllHotels] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null) const [editingCode, setEditingCode] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchImporting, setBatchImporting] = useState(false) const [batchImporting, setBatchImporting] = useState(false);
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null);
const fetchBadges = useCallback(async (search: string, off: number, all: boolean) => { const fetchBadges = useCallback(async (search: string, off: number, all: boolean) => {
setLoading(true) setLoading(true);
setError('') setError("");
try { try {
const params = new URLSearchParams({ offset: String(off) }) const params = new URLSearchParams({ offset: String(off) });
if (search) params.set('search', search) if (search) params.set("search", search);
if (all) params.set('allHotels', '1') if (all) params.set("allHotels", "1");
const res = await fetch(`/api/admin/import/badges?${params}`) const res = await fetch(`/api/admin/import/badges?${params}`);
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load') setError(data.error || "Failed to load");
return return;
} }
const list = data.badges || [] const list = data.badges || [];
setBadges(list) setBadges(list);
setHasMore(list.length >= PAGE_SIZE) setHasMore(list.length >= PAGE_SIZE);
setSelected(new Set()) setSelected(new Set());
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, []) }, []);
// Auto-load on mount and when allHotels changes // Auto-load on mount and when allHotels changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => { useEffect(() => {
setOffset(0) setOffset(0);
fetchBadges(activeSearch, 0, allHotels) fetchBadges(activeSearch, 0, allHotels);
}, [fetchBadges, allHotels]) }, [fetchBadges, allHotels]);
// "/" keyboard shortcut to focus search // "/" keyboard shortcut to focus search
useEffect(() => { useEffect(() => {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault() e.preventDefault();
searchRef.current?.focus() searchRef.current?.focus();
} }
} }
document.addEventListener('keydown', onKeyDown) document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener("keydown", onKeyDown);
}, []) }, []);
function doSearch() { function doSearch() {
setActiveSearch(searchTerm) setActiveSearch(searchTerm);
setOffset(0) setOffset(0);
fetchBadges(searchTerm, 0, allHotels) fetchBadges(searchTerm, 0, allHotels);
} }
function goPage(direction: 'prev' | 'next') { function goPage(direction: "prev" | "next") {
const newOffset = direction === 'next' ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE) const newOffset = direction === "next" ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE);
setOffset(newOffset) setOffset(newOffset);
fetchBadges(activeSearch, newOffset, allHotels) fetchBadges(activeSearch, newOffset, allHotels);
} }
async function doImport(badge: BadgeItem) { async function doImport(badge: BadgeItem) {
setImportingCode(badge.code) setImportingCode(badge.code);
try { try {
const result = await importBadgeFromRemote({ const result = await importBadgeFromRemote({
code: badge.code, code: badge.code,
name: badge.name, name: badge.name,
description: badge.description, description: badge.description,
}) });
if (result.ok) { if (result.ok) {
toast.success(`${badge.code} imported`) toast.success(`${badge.code} imported`);
setBadges((prev) => setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
)
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev);
n.delete(badge.code) n.delete(badge.code);
return n return n;
}) });
} else { } else {
toast.error(result.error || `Failed to import ${badge.code}`) toast.error(result.error || `Failed to import ${badge.code}`);
} }
} catch { } catch {
toast.error(`Network error importing ${badge.code}`) toast.error(`Network error importing ${badge.code}`);
} finally { } finally {
setImportingCode(null) setImportingCode(null);
} }
} }
async function doBatchImport() { async function doBatchImport() {
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported) const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported);
if (toImport.length === 0) return if (toImport.length === 0) return;
setBatchImporting(true) setBatchImporting(true);
let ok = 0, let ok = 0,
fail = 0 fail = 0;
for (const badge of toImport) { for (const badge of toImport) {
try { try {
const result = await importBadgeFromRemote({ const result = await importBadgeFromRemote({
code: badge.code, code: badge.code,
name: badge.name, name: badge.name,
description: badge.description, description: badge.description,
}) });
if (result.ok) { if (result.ok) {
ok++ ok++;
setBadges((prev) => setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
)
} else { } else {
fail++ fail++;
} }
} catch { } catch {
fail++ fail++;
} }
} }
setBatchImporting(false) setBatchImporting(false);
setSelected(new Set()) setSelected(new Set());
if (ok > 0) toast.success(`${ok} badge(s) imported`) if (ok > 0) toast.success(`${ok} badge(s) imported`);
if (fail > 0) toast.error(`${fail} failed`) if (fail > 0) toast.error(`${fail} failed`);
} }
function toggleSelect(code: string) { function toggleSelect(code: string) {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(code)) next.delete(code) if (next.has(code)) next.delete(code);
else next.add(code) else next.add(code);
return next return next;
}) });
} }
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges]) const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges]);
function toggleSelectAll() { function toggleSelectAll() {
if (selected.size === importableBadges.length) { if (selected.size === importableBadges.length) {
setSelected(new Set()) setSelected(new Set());
} else { } else {
setSelected(new Set(importableBadges.map((b) => b.code))) setSelected(new Set(importableBadges.map((b) => b.code)));
} }
} }
// Stats // Stats
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges]) const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges]);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1 const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
@@ -226,10 +222,10 @@ export function ImportBadgesClient() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') doSearch() if (e.key === "Enter") doSearch();
if (e.key === 'Escape') { if (e.key === "Escape") {
setSearchTerm('') setSearchTerm("");
searchRef.current?.blur() searchRef.current?.blur();
} }
}} }}
className="pl-9 pr-8" className="pl-9 pr-8"
@@ -238,9 +234,9 @@ export function ImportBadgesClient() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
fetchBadges('', 0, allHotels) fetchBadges("", 0, allHotels);
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 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> </div>
<Button onClick={doSearch} size="sm" disabled={loading}> <Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? ( {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button> </Button>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant={allHotels ? 'default' : 'outline'} variant={allHotels ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setAllHotels((v) => !v)} onClick={() => setAllHotels((v) => !v)}
> >
<Globe className="w-4 h-4 mr-1" /> <Globe className="w-4 h-4 mr-1" />
{allHotels ? 'All' : 'IT'} {allHotels ? "All" : "IT"}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>
{allHotels {allHotels
? 'Showing all hotels — click for Italian only' ? "Showing all hotels — click for Italian only"
: 'Showing Italian only — click for all hotels'} : "Showing Italian only — click for all hotels"}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -287,7 +279,7 @@ export function ImportBadgesClient() {
disabled={batchImporting} disabled={batchImporting}
> >
{selected.size === importableBadges.length {selected.size === importableBadges.length
? 'Deselect All' ? "Deselect All"
: `Select All (${importableBadges.length})`} : `Select All (${importableBadges.length})`}
</Button> </Button>
{selected.size > 0 && ( {selected.size > 0 && (
@@ -330,7 +322,7 @@ export function ImportBadgesClient() {
{!loading && badges.length === 0 && !error && ( {!loading && badges.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" /> <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>} {!activeSearch && <p className="text-xs mt-1">Try searching by badge name or code</p>}
</div> </div>
)} )}
@@ -338,20 +330,17 @@ export function ImportBadgesClient() {
{/* Badge grid */} {/* Badge grid */}
{badges.length > 0 && ( {badges.length > 0 && (
<div <div
className={cn( className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
loading && 'opacity-50',
)}
> >
{badges.map((badge) => { {badges.map((badge) => {
const isSelected = selected.has(badge.code) const isSelected = selected.has(badge.code);
return ( return (
<div <div
key={badge.code} key={badge.code}
className={cn( className={cn(
'rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all', "rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all",
badge.alreadyImported && 'ring-1 ring-[var(--admin-success)]/30', badge.alreadyImported && "ring-1 ring-[var(--admin-success)]/30",
isSelected && 'ring-2 ring-primary border-primary', isSelected && "ring-2 ring-primary border-primary",
)} )}
> >
{/* Checkbox for non-imported */} {/* Checkbox for non-imported */}
@@ -360,10 +349,10 @@ export function ImportBadgesClient() {
type="button" type="button"
onClick={() => toggleSelect(badge.code)} onClick={() => toggleSelect(badge.code)}
className={cn( 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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground', : "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)} )}
> >
{isSelected && <Check className="w-3 h-3" />} {isSelected && <Check className="w-3 h-3" />}
@@ -378,12 +367,12 @@ export function ImportBadgesClient() {
className="image-pixelated drop-shadow-sm" className="image-pixelated drop-shadow-sm"
style={{ maxHeight: 48, maxWidth: 48 }} style={{ maxHeight: 48, maxWidth: 48 }}
onError={(e) => { onError={(e) => {
const img = e.target as HTMLImageElement const img = e.target as HTMLImageElement;
if (!img.dataset.fallback) { if (!img.dataset.fallback) {
img.dataset.fallback = '1' img.dataset.fallback = "1";
img.src = badge.url_habboassets img.src = badge.url_habboassets;
} else { } 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"> <TooltipContent side="bottom" className="max-w-64">
<p className="font-medium">{badge.name}</p> <p className="font-medium">{badge.name}</p>
{badge.description && ( {badge.description && (
<p className="text-muted-foreground text-xs mt-1"> <p className="text-muted-foreground text-xs mt-1">{badge.description}</p>
{badge.description}
</p>
)} )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -452,7 +439,7 @@ export function ImportBadgesClient() {
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
@@ -466,7 +453,7 @@ export function ImportBadgesClient() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={offset <= 0 || loading} disabled={offset <= 0 || loading}
onClick={() => goPage('prev')} onClick={() => goPage("prev")}
> >
&larr; Prev &larr; Prev
</Button> </Button>
@@ -474,7 +461,7 @@ export function ImportBadgesClient() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!hasMore || loading} disabled={!hasMore || loading}
onClick={() => goPage('next')} onClick={() => goPage("next")}
> >
Next &rarr; Next &rarr;
</Button> </Button>
@@ -484,13 +471,13 @@ export function ImportBadgesClient() {
{/* Badge Editor Dialog */} {/* Badge Editor Dialog */}
<BadgeEditorDialog <BadgeEditorDialog
code={editingCode ?? ''} code={editingCode ?? ""}
open={!!editingCode} open={!!editingCode}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setEditingCode(null) if (!open) setEditingCode(null);
}} }}
/> />
</div> </div>
</TooltipProvider> </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() { export default function ImportBadgesLoading() {
return ( return (
@@ -17,5 +17,5 @@ export default function ImportBadgesLoading() {
<Skeleton className="h-10 w-24 mt-4" /> <Skeleton className="h-10 w-24 mt-4" />
</div> </div>
</div> </div>
) );
} }
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from './import-badges-client' import { ImportBadgesClient } from "./import-badges-client";
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) { 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)) { 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 { import {
AlertCircle, AlertCircle,
@@ -12,46 +12,46 @@ import {
Search, Search,
Trash2, Trash2,
X, X,
} from 'lucide-react' } from "lucide-react";
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
interface CloneSource { interface CloneSource {
id: string id: string;
name: string name: string;
furnidataUrl: string furnidataUrl: string;
nitroBaseUrl: string nitroBaseUrl: string;
iconBaseUrl: string iconBaseUrl: string;
} }
interface FurniItem { interface FurniItem {
classname: string classname: string;
name: string name: string;
description?: string description?: string;
revision?: number revision?: number;
category?: string category?: string;
itemType: 's' | 'i' itemType: "s" | "i";
xdim: number xdim: number;
ydim: number ydim: number;
present: boolean present: boolean;
} }
interface CloneStats { interface CloneStats {
total: number total: number;
present: number present: number;
clonable: number clonable: number;
} }
interface PageMeta { interface PageMeta {
page: number page: number;
perPage: number perPage: number;
total: number total: number;
} }
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ──────────────────── // ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
@@ -63,63 +63,63 @@ async function runSseImport(
onComplete: (succeeded: number, failed: number) => void, onComplete: (succeeded: number, failed: number) => void,
): Promise<void> { ): Promise<void> {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}) });
if (!res.body) { if (!res.body) {
toast.error('No response stream') toast.error("No response stream");
return return;
} }
const reader = res.body.getReader() const reader = res.body.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
let buf = '' let buf = "";
let succeeded = 0 let succeeded = 0;
let failed = 0 let failed = 0;
while (true) { while (true) {
const { value, done: streamDone } = await reader.read() const { value, done: streamDone } = await reader.read();
if (streamDone) break if (streamDone) break;
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n') const parts = buf.split("\n\n");
buf = parts.pop() ?? '' buf = parts.pop() ?? "";
for (const part of parts) { for (const part of parts) {
if (!part.startsWith('data: ')) continue if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6)) const evt = JSON.parse(part.slice(6));
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) { if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
onDone(evt.classname) onDone(evt.classname);
} }
if (evt.type === 'batch_complete') { if (evt.type === "batch_complete") {
succeeded = evt.succeeded succeeded = evt.succeeded;
failed = evt.failed failed = evt.failed;
} }
} }
} }
onComplete(succeeded, failed) onComplete(succeeded, failed);
} }
// ── Source form ─────────────────────────────────────────────────────────────── // ── Source form ───────────────────────────────────────────────────────────────
interface SourceFormProps { interface SourceFormProps {
initial?: Partial<CloneSource> initial?: Partial<CloneSource>;
onSave: (src: Partial<CloneSource>) => Promise<void> onSave: (src: Partial<CloneSource>) => Promise<void>;
onCancel: () => void onCancel: () => void;
saving: boolean saving: boolean;
} }
function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) { function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
const [name, setName] = useState(initial?.name ?? '') const [name, setName] = useState(initial?.name ?? "");
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? '') const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? "");
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? '') const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? '') const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl }) await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl });
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border p-4 bg-card"> <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"> <div className="grid gap-2">
<Input <Input
placeholder="Name (e.g. HotelX)" placeholder="Name (e.g. HotelX)"
@@ -156,61 +156,61 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
</Button> </Button>
</div> </div>
</form> </form>
) );
} }
// ── Sources manager ─────────────────────────────────────────────────────────── // ── Sources manager ───────────────────────────────────────────────────────────
interface SourcesManagerProps { interface SourcesManagerProps {
sources: CloneSource[] sources: CloneSource[];
activeSourceId: string | null activeSourceId: string | null;
onSelect: (src: CloneSource) => void onSelect: (src: CloneSource) => void;
onRefresh: () => Promise<void> onRefresh: () => Promise<void>;
} }
function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) { function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) {
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false);
const [editSrc, setEditSrc] = useState<CloneSource | null>(null) const [editSrc, setEditSrc] = useState<CloneSource | null>(null);
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null) const [deletingId, setDeletingId] = useState<string | null>(null);
async function handleSave(src: Partial<CloneSource>) { async function handleSave(src: Partial<CloneSource>) {
setSaving(true) setSaving(true);
try { try {
const res = await fetch('/api/admin/import/clone', { const res = await fetch("/api/admin/import/clone", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(src), body: JSON.stringify(src),
}) });
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error || 'Save failed') toast.error(data.error || "Save failed");
return return;
} }
toast.success(src.id ? 'Source updated' : 'Source added') toast.success(src.id ? "Source updated" : "Source added");
setShowForm(false) setShowForm(false);
setEditSrc(null) setEditSrc(null);
await onRefresh() await onRefresh();
} finally { } finally {
setSaving(false) setSaving(false);
} }
} }
async function handleDelete(src: CloneSource) { async function handleDelete(src: CloneSource) {
setDeletingId(src.id) setDeletingId(src.id);
try { try {
const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, { const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, {
method: 'DELETE', method: "DELETE",
}) });
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error || 'Delete failed') toast.error(data.error || "Delete failed");
return return;
} }
toast.success(`${src.name} removed`) toast.success(`${src.name} removed`);
await onRefresh() await onRefresh();
} finally { } finally {
setDeletingId(null) setDeletingId(null);
} }
} }
@@ -223,8 +223,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
variant="outline" variant="outline"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => { onClick={() => {
setEditSrc(null) setEditSrc(null);
setShowForm(true) setShowForm(true);
}} }}
> >
<Plus className="w-3 h-3 mr-1" /> <Plus className="w-3 h-3 mr-1" />
@@ -246,17 +246,12 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
{sources.map((src) => ( {sources.map((src) => (
<div key={src.id}> <div key={src.id}>
{editSrc?.id === src.id ? ( {editSrc?.id === src.id ? (
<SourceForm <SourceForm initial={src} onSave={handleSave} onCancel={() => setEditSrc(null)} saving={saving} />
initial={src}
onSave={handleSave}
onCancel={() => setEditSrc(null)}
saving={saving}
/>
) : ( ) : (
<div <div
className={cn( className={cn(
'w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border', "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', activeSourceId === src.id && "border-primary ring-1 ring-primary bg-primary/5",
)} )}
> >
<button <button
@@ -277,8 +272,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
title="Edit source" title="Edit source"
onClick={() => { onClick={() => {
setEditSrc(src) setEditSrc(src);
setShowForm(false) setShowForm(false);
}} }}
> >
<Pencil className="w-3 h-3" /> <Pencil className="w-3 h-3" />
@@ -303,7 +298,7 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
</div> </div>
))} ))}
</div> </div>
) );
} }
// ── Furni icon with fallback ────────────────────────────────────────────────── // ── Furni icon with fallback ──────────────────────────────────────────────────
@@ -313,20 +308,20 @@ function FurniIcon({
iconBaseUrl, iconBaseUrl,
classname, classname,
}: { }: {
sourceId: string sourceId: string;
iconBaseUrl: string iconBaseUrl: string;
classname: string classname: string;
}) { }) {
// 0 = source's standalone icon, 1 = icon extracted from the .nitro (our // 0 = source's standalone icon, 1 = icon extracted from the .nitro (our
// endpoint), 2 = give up and show a placeholder. // endpoint), 2 = give up and show a placeholder.
const [stage, setStage] = useState(0) const [stage, setStage] = useState(0);
if (stage >= 2) { 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 = const src =
stage === 0 stage === 0
? `${iconBaseUrl}/${classname}_icon.png` ? `${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 ( return (
// biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset // biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset
<img <img
@@ -336,203 +331,197 @@ function FurniIcon({
className="max-h-full max-w-full object-contain image-pixelated" className="max-h-full max-w-full object-contain image-pixelated"
onError={() => setStage((s) => s + 1)} onError={() => setStage((s) => s + 1)}
/> />
) );
} }
// ── Furni grid view ─────────────────────────────────────────────────────────── // ── Furni grid view ───────────────────────────────────────────────────────────
interface FurniGridProps { interface FurniGridProps {
source: CloneSource source: CloneSource;
} }
const PER_PAGE = 50 const PER_PAGE = 50;
function FurniGrid({ source }: FurniGridProps) { function FurniGrid({ source }: FurniGridProps) {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState('') const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FurniItem[]>([]) const [items, setItems] = useState<FurniItem[]>([]);
const [stats, setStats] = useState<CloneStats | null>(null) const [stats, setStats] = useState<CloneStats | null>(null);
const [meta, setMeta] = useState<PageMeta | null>(null) const [meta, setMeta] = useState<PageMeta | null>(null);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyClassname, setBusyClassname] = useState<string | null>(null) const [busyClassname, setBusyClassname] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'missing' | 'present'>('all') const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
const fetchItems = useCallback( const fetchItems = useCallback(
async (search: string, pageNum: number, filterVal: 'all' | 'missing' | 'present' = 'all') => { async (search: string, pageNum: number, filterVal: "all" | "missing" | "present" = "all") => {
setLoading(true) setLoading(true);
setError('') setError("");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
source: source.id, source: source.id,
page: String(pageNum), page: String(pageNum),
perPage: String(PER_PAGE), perPage: String(PER_PAGE),
}) });
if (search) params.set('search', search) if (search) params.set("search", search);
if (filterVal !== 'all') params.set('filter', filterVal) if (filterVal !== "all") params.set("filter", filterVal);
const res = await fetch(`/api/admin/import/clone?${params}`) const res = await fetch(`/api/admin/import/clone?${params}`);
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load') setError(data.error || "Failed to load");
return return;
} }
setItems(data.item || []) setItems(data.item || []);
setMeta(data.meta || null) setMeta(data.meta || null);
setStats(data.stats || null) setStats(data.stats || null);
setSelected(new Set()) setSelected(new Set());
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, },
[source.id], [source.id],
) );
useEffect(() => { useEffect(() => {
setPage(1) setPage(1);
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setItems([]) setItems([]);
setStats(null) setStats(null);
setMeta(null) setMeta(null);
setFilter('all') setFilter("all");
fetchItems('', 1, 'all') fetchItems("", 1, "all");
}, [fetchItems]) }, [fetchItems]);
function doSearch() { function doSearch() {
setActiveSearch(searchTerm) setActiveSearch(searchTerm);
setPage(1) setPage(1);
fetchItems(searchTerm, 1, filter) fetchItems(searchTerm, 1, filter);
} }
function goPage(dir: 'prev' | 'next') { function goPage(dir: "prev" | "next") {
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1) const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next) setPage(next);
fetchItems(activeSearch, next, filter) fetchItems(activeSearch, next, filter);
} }
function changeFilter(f: 'all' | 'missing' | 'present') { function changeFilter(f: "all" | "missing" | "present") {
setFilter(f) setFilter(f);
setPage(1) setPage(1);
fetchItems(activeSearch, 1, f) fetchItems(activeSearch, 1, f);
} }
const importable = items.filter((it) => !it.present) const importable = items.filter((it) => !it.present);
const total = meta?.total ?? 0 const total = meta?.total ?? 0;
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1) const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(classname: string) { function toggle(classname: string) {
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev);
if (n.has(classname)) n.delete(classname) if (n.has(classname)) n.delete(classname);
else n.add(classname) else n.add(classname);
return n return n;
}) });
} }
function markDone(classname: string) { function markDone(classname: string) {
setItems((prev) => setItems((prev) => prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)));
prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)),
)
} }
async function cloneViaSse(toClone: FurniItem[]) { async function cloneViaSse(toClone: FurniItem[]) {
setBatchProgress({ done: 0, total: toClone.length }) setBatchProgress({ done: 0, total: toClone.length });
let done = 0 let done = 0;
await runSseImport( await runSseImport(
'/api/admin/import/clone/batch', "/api/admin/import/clone/batch",
{ sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2 }, { sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2 },
(classname) => { (classname) => {
done++ done++;
markDone(classname) markDone(classname);
setBatchProgress({ done, total: toClone.length }) setBatchProgress({ done, total: toClone.length });
}, },
(succeeded, failed) => { (succeeded, failed) => {
setBatchProgress(null) setBatchProgress(null);
setSelected(new Set()) setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} furni cloned`) if (succeeded > 0) toast.success(`${succeeded} furni cloned`);
if (failed > 0) toast.error(`${failed} failed`) if (failed > 0) toast.error(`${failed} failed`);
// Refresh stats after batch // Refresh stats after batch
fetchItems(activeSearch, page, filter) fetchItems(activeSearch, page, filter);
}, },
) );
setBatchProgress(null) setBatchProgress(null);
} }
async function cloneOne(it: FurniItem) { async function cloneOne(it: FurniItem) {
setBusyClassname(it.classname) setBusyClassname(it.classname);
try { try {
await cloneViaSse([it]) await cloneViaSse([it]);
} finally { } finally {
setBusyClassname(null) setBusyClassname(null);
} }
} }
async function cloneSelected() { async function cloneSelected() {
const toClone = items.filter((it) => selected.has(it.classname) && !it.present) const toClone = items.filter((it) => selected.has(it.classname) && !it.present);
if (toClone.length === 0) return if (toClone.length === 0) return;
await cloneViaSse(toClone) await cloneViaSse(toClone);
} }
// Clone EVERY not-yet-present furni from the source (across all pages), // 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. // in chunks of 400 to stay under the batch endpoint's 500 cap.
async function cloneAll() { async function cloneAll() {
let names: string[] = [] let names: string[] = [];
try { try {
const res = await fetch( const res = await fetch(
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`, `/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
) );
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error || 'Failed to load clonable list') toast.error(data.error || "Failed to load clonable list");
return return;
} }
names = data.classnames || [] names = data.classnames || [];
} catch { } catch {
toast.error('Network error') toast.error("Network error");
return return;
} }
if (names.length === 0) { if (names.length === 0) {
toast.info('Nothing to clone — everything is already present') toast.info("Nothing to clone — everything is already present");
return return;
} }
if ( if (!window.confirm(`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`)) {
!window.confirm( return;
`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`,
)
) {
return
} }
const CHUNK = 400 const CHUNK = 400;
setBatchProgress({ done: 0, total: names.length }) setBatchProgress({ done: 0, total: names.length });
let done = 0 let done = 0;
let okCount = 0 let okCount = 0;
let failCount = 0 let failCount = 0;
for (let i = 0; i < names.length; i += CHUNK) { 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( await runSseImport(
'/api/admin/import/clone/batch', "/api/admin/import/clone/batch",
{ sourceId: source.id, classnames: chunk, concurrency: 2 }, { sourceId: source.id, classnames: chunk, concurrency: 2 },
(classname) => { (classname) => {
done++ done++;
markDone(classname) markDone(classname);
setBatchProgress({ done, total: names.length }) setBatchProgress({ done, total: names.length });
}, },
(succeeded, failed) => { (succeeded, failed) => {
okCount += succeeded okCount += succeeded;
failCount += failed failCount += failed;
}, },
) );
} }
setBatchProgress(null) setBatchProgress(null);
setSelected(new Set()) setSelected(new Set());
if (okCount > 0) toast.success(`${okCount} furni cloned`) if (okCount > 0) toast.success(`${okCount} furni cloned`);
if (failCount > 0) toast.error(`${failCount} failed`) if (failCount > 0) toast.error(`${failCount} failed`);
fetchItems(activeSearch, page, filter) fetchItems(activeSearch, page, filter);
} }
return ( return (
@@ -567,12 +556,12 @@ function FurniGrid({ source }: FurniGridProps) {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') doSearch() if (e.key === "Enter") doSearch();
if (e.key === 'Escape') { if (e.key === "Escape") {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setPage(1) setPage(1);
fetchItems('', 1, filter) fetchItems("", 1, filter);
} }
}} }}
className="pl-9 pr-8" className="pl-9 pr-8"
@@ -581,10 +570,10 @@ function FurniGrid({ source }: FurniGridProps) {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setPage(1) setPage(1);
fetchItems('', 1, filter) fetchItems("", 1, filter);
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 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"> <div className="flex items-center gap-1 flex-wrap">
{( {(
[ [
{ key: 'all', label: 'All', count: stats?.total }, { key: "all", label: "All", count: stats?.total },
{ key: 'missing', label: "Not present (don't have)", count: stats?.clonable }, { key: "missing", label: "Not present (don't have)", count: stats?.clonable },
{ key: 'present', label: 'Present', count: stats?.present }, { key: "present", label: "Present", count: stats?.present },
] as const ] as const
).map((f) => ( ).map((f) => (
<Button <Button
key={f.key} key={f.key}
type="button" type="button"
size="sm" size="sm"
variant={filter === f.key ? 'default' : 'outline'} variant={filter === f.key ? "default" : "outline"}
className="h-7 text-xs" className="h-7 text-xs"
disabled={loading || !!batchProgress} disabled={loading || !!batchProgress}
onClick={() => changeFilter(f.key)} onClick={() => changeFilter(f.key)}
> >
{f.label} {f.label}
{typeof f.count === 'number' ? ` (${f.count})` : ''} {typeof f.count === "number" ? ` (${f.count})` : ""}
</Button> </Button>
))} ))}
</div> </div>
@@ -632,15 +621,11 @@ function FurniGrid({ source }: FurniGridProps) {
disabled={!!batchProgress} disabled={!!batchProgress}
onClick={() => onClick={() =>
setSelected((prev) => setSelected((prev) =>
prev.size === importable.length prev.size === importable.length ? new Set() : new Set(importable.map((it) => it.classname)),
? new Set()
: new Set(importable.map((it) => it.classname)),
) )
} }
> >
{selected.size === importable.length {selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
? 'Deselect page'
: `Select page (${importable.length})`}
</Button> </Button>
)} )}
{selected.size > 0 && ( {selected.size > 0 && (
@@ -689,27 +674,22 @@ function FurniGrid({ source }: FurniGridProps) {
{!loading && items.length === 0 && !error && ( {!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<CloudDownload className="w-12 h-12 mx-auto mb-3 opacity-50" /> <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> </div>
)} )}
{/* Grid */} {/* Grid */}
{items.length > 0 && ( {items.length > 0 && (
<div <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',
)}
>
{items.map((it) => { {items.map((it) => {
const isSelected = selected.has(it.classname) const isSelected = selected.has(it.classname);
return ( return (
<div <div
key={it.classname} key={it.classname}
className={cn( className={cn(
'group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all', "group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
it.present && 'ring-1 ring-[var(--admin-success)]/30', it.present && "ring-1 ring-[var(--admin-success)]/30",
isSelected && 'ring-2 ring-primary border-primary', isSelected && "ring-2 ring-primary border-primary",
)} )}
> >
{!it.present && ( {!it.present && (
@@ -717,10 +697,10 @@ function FurniGrid({ source }: FurniGridProps) {
type="button" type="button"
onClick={() => toggle(it.classname)} onClick={() => toggle(it.classname)}
className={cn( 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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground', : "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)} )}
> >
{isSelected && <Check className="w-3 h-3" />} {isSelected && <Check className="w-3 h-3" />}
@@ -729,23 +709,19 @@ function FurniGrid({ source }: FurniGridProps) {
{/* Type badge */} {/* Type badge */}
<div className="absolute top-1.5 right-1.5 z-10"> <div className="absolute top-1.5 right-1.5 z-10">
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80"> <Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80">
{it.itemType === 's' ? 'floor' : 'wall'} {it.itemType === "s" ? "floor" : "wall"}
</Badge> </Badge>
</div> </div>
<div className="relative flex items-center justify-center h-28 bg-accent/20"> <div className="relative flex items-center justify-center h-28 bg-accent/20">
<FurniIcon <FurniIcon sourceId={source.id} iconBaseUrl={source.iconBaseUrl} classname={it.classname} />
sourceId={source.id}
iconBaseUrl={source.iconBaseUrl}
classname={it.classname}
/>
{/* Hover detail tooltip */} {/* 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="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"> <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} {it.description ? <p className="truncate">{it.description}</p> : null}
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70"> <div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
{it.revision ? <span>Rev: {it.revision}</span> : null} {it.revision ? <span>Rev: {it.revision}</span> : null}
<span>{it.itemType === 's' ? 'Floor' : 'Wall'}</span> <span>{it.itemType === "s" ? "Floor" : "Wall"}</span>
<span> <span>
{it.xdim}×{it.ydim} {it.xdim}×{it.ydim}
</span> </span>
@@ -762,7 +738,7 @@ function FurniGrid({ source }: FurniGridProps) {
{it.classname} {it.classname}
</div> </div>
<div className="text-[10px] text-muted-foreground truncate"> <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>
<div className="pt-1"> <div className="pt-1">
{it.present ? ( {it.present ? (
@@ -789,7 +765,7 @@ function FurniGrid({ source }: FurniGridProps) {
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
@@ -805,7 +781,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page <= 1 || loading} disabled={page <= 1 || loading}
onClick={() => goPage('prev')} onClick={() => goPage("prev")}
> >
&larr; Prev &larr; Prev
</Button> </Button>
@@ -813,7 +789,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page >= lastPage || loading} disabled={page >= lastPage || loading}
onClick={() => goPage('next')} onClick={() => goPage("next")}
> >
Next &rarr; Next &rarr;
</Button> </Button>
@@ -821,33 +797,33 @@ function FurniGrid({ source }: FurniGridProps) {
</div> </div>
)} )}
</div> </div>
) );
} }
// ── Root component ──────────────────────────────────────────────────────────── // ── Root component ────────────────────────────────────────────────────────────
export function ImportCloneClient() { export function ImportCloneClient() {
const [sources, setSources] = useState<CloneSource[]>([]) const [sources, setSources] = useState<CloneSource[]>([]);
const [activeSource, setActiveSource] = useState<CloneSource | null>(null) const [activeSource, setActiveSource] = useState<CloneSource | null>(null);
const [loadingSources, setLoadingSources] = useState(true) const [loadingSources, setLoadingSources] = useState(true);
const fetchSources = useCallback(async () => { const fetchSources = useCallback(async () => {
try { try {
const res = await fetch('/api/admin/import/clone?action=sources') const res = await fetch("/api/admin/import/clone?action=sources");
const data = await res.json() const data = await res.json();
if (res.ok) { if (res.ok) {
setSources(data.sources || []) setSources(data.sources || []);
} }
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
setLoadingSources(false) setLoadingSources(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchSources() fetchSources();
}, [fetchSources]) }, [fetchSources]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -883,5 +859,5 @@ export function ImportCloneClient() {
</div> </div>
)} )}
</div> </div>
) );
} }
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from './import-clone-client' import { ImportCloneClient } from "./import-clone-client";
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) { 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)) { 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 { import { AlertCircle, Check, Download, Loader2, Package, Search, Shirt, Trash2, X } from "lucide-react";
AlertCircle, import { useCallback, useEffect, useMemo, useState } from "react";
Check, import { toast } from "sonner";
Download, import { Badge } from "@/components/ui/badge";
Loader2, import { Button } from "@/components/ui/button";
Package, import { Input } from "@/components/ui/input";
Search, import { cn } from "@/lib/utils";
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 ───────────────────────────────────────────────────────────── // ── Shared types ─────────────────────────────────────────────────────────────
interface FigureItem { interface FigureItem {
lib: string lib: string;
revision: number revision: number;
nitroExists: boolean nitroExists: boolean;
previewType?: string previewType?: string;
previewId?: number previewId?: number;
} }
interface ClothingSetItem { interface ClothingSetItem {
setType: string setType: string;
setId: number setId: number;
gender: string gender: string;
club: number club: number;
sellable: boolean sellable: boolean;
present: boolean present: boolean;
} }
type Mode = 'libs' | 'sets' type Mode = "libs" | "sets";
const PER_PAGE = 100 const PER_PAGE = 100;
// ── Shared SSE reader ───────────────────────────────────────────────────────── // ── Shared SSE reader ─────────────────────────────────────────────────────────
@@ -50,38 +40,38 @@ async function runSseImport(
onComplete: (succeeded: number, failed: number) => void, onComplete: (succeeded: number, failed: number) => void,
): Promise<void> { ): Promise<void> {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}) });
if (!res.body) { if (!res.body) {
toast.error('No response stream') toast.error("No response stream");
return return;
} }
const reader = res.body.getReader() const reader = res.body.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
let buf = '' let buf = "";
let succeeded = 0 let succeeded = 0;
let failed = 0 let failed = 0;
while (true) { while (true) {
const { value, done: streamDone } = await reader.read() const { value, done: streamDone } = await reader.read();
if (streamDone) break if (streamDone) break;
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n') const parts = buf.split("\n\n");
buf = parts.pop() ?? '' buf = parts.pop() ?? "";
for (const part of parts) { for (const part of parts) {
if (!part.startsWith('data: ')) continue if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6)) const evt = JSON.parse(part.slice(6));
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) { if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
onDone(evt.classname) onDone(evt.classname);
} }
if (evt.type === 'batch_complete') { if (evt.type === "batch_complete") {
succeeded = evt.succeeded succeeded = evt.succeeded;
failed = evt.failed failed = evt.failed;
} }
} }
} }
onComplete(succeeded, failed) onComplete(succeeded, failed);
} }
// ── Avatar preview (renders a mini avatar wearing the item) ─────────────────── // ── 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 // A default dressed avatar; the previewed part overrides/adds its slot so the
// item is shown on a complete figure. // item is shown on a complete figure.
const BASE_FIGURE: Record<string, string> = { const BASE_FIGURE: Record<string, string> = {
hd: 'hd-180-1', hd: "hd-180-1",
hr: 'hr-828-45', hr: "hr-828-45",
ch: 'ch-665-92', ch: "ch-665-92",
lg: 'lg-700-82', lg: "lg-700-82",
sh: 'sh-705-62', sh: "sh-705-62",
} };
// Head-worn slots → render head-only (zoomed) so the item fills the frame; // Head-worn slots → render head-only (zoomed) so the item fills the frame;
// everything else renders the full body. // 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 { function buildPreviewFigure(type: string, id: number): string {
const parts: Record<string, string> = { ...BASE_FIGURE } const parts: Record<string, string> = { ...BASE_FIGURE };
parts[type] = `${type}-${id}` // default color parts[type] = `${type}-${id}`; // default color
return Object.values(parts).join('.') return Object.values(parts).join(".");
} }
function AvatarPreview({ type, id }: { type?: string; id?: number }) { function AvatarPreview({ type, id }: { type?: string; id?: number }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false);
if (failed || !type || id === undefined) { 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 figure = buildPreviewFigure(type, id);
const headOnly = HEAD_TYPES.has(type) ? '&headonly=1' : '' const headOnly = HEAD_TYPES.has(type) ? "&headonly=1" : "";
return ( return (
// biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset // biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset
<img <img
@@ -121,129 +111,129 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) {
className="max-h-full object-contain image-pixelated" className="max-h-full object-contain image-pixelated"
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
) );
} }
// ── Librerie (v1) view ──────────────────────────────────────────────────────── // ── Librerie (v1) view ────────────────────────────────────────────────────────
function LibsView() { function LibsView() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState('') const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FigureItem[]>([]) const [items, setItems] = useState<FigureItem[]>([]);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null) const [busyLib, setBusyLib] = useState<string | null>(null);
const fetchItems = useCallback(async (search: string, pageNum: number) => { const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true) setLoading(true);
setError('') setError("");
try { try {
const params = new URLSearchParams({ page: String(pageNum) }) const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set('search', search) if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/clothing?${params}`) const res = await fetch(`/api/admin/import/clothing?${params}`);
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load') setError(data.error || "Failed to load");
return return;
} }
setItems(data.item || []) setItems(data.item || []);
setTotal(data.meta?.total || 0) setTotal(data.meta?.total || 0);
setSelected(new Set()) setSelected(new Set());
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchItems('', 1) fetchItems("", 1);
}, [fetchItems]) }, [fetchItems]);
function doSearch() { function doSearch() {
setActiveSearch(searchTerm) setActiveSearch(searchTerm);
setPage(1) setPage(1);
fetchItems(searchTerm, 1) fetchItems(searchTerm, 1);
} }
function goPage(dir: 'prev' | 'next') { function goPage(dir: "prev" | "next") {
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1) const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next) setPage(next);
fetchItems(activeSearch, next) fetchItems(activeSearch, next);
} }
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items]) const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items]);
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items]) const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items]);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1) const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(lib: string) { function toggle(lib: string) {
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev);
if (n.has(lib)) n.delete(lib) if (n.has(lib)) n.delete(lib);
else n.add(lib) else n.add(lib);
return n return n;
}) });
} }
function markDone(lib: string) { 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[]) { async function importViaSse(toImport: FigureItem[]) {
setBatchProgress({ done: 0, total: toImport.length }) setBatchProgress({ done: 0, total: toImport.length });
let done = 0 let done = 0;
await runSseImport( await runSseImport(
'/api/admin/import/clothing/batch', "/api/admin/import/clothing/batch",
{ items: toImport, concurrency: 3 }, { items: toImport, concurrency: 3 },
(label) => { (label) => {
done++ done++;
markDone(label) markDone(label);
setBatchProgress({ done, total: toImport.length }) setBatchProgress({ done, total: toImport.length });
}, },
(succeeded, failed) => { (succeeded, failed) => {
setBatchProgress(null) setBatchProgress(null);
setSelected(new Set()) setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} libraries imported`) if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
if (failed > 0) toast.error(`${failed} failed`) if (failed > 0) toast.error(`${failed} failed`);
}, },
) );
setBatchProgress(null) setBatchProgress(null);
} }
async function importOne(p: FigureItem) { async function importOne(p: FigureItem) {
setBusyLib(p.lib) setBusyLib(p.lib);
try { try {
await importViaSse([p]) await importViaSse([p]);
} finally { } finally {
setBusyLib(null) setBusyLib(null);
} }
} }
async function importSelected() { async function importSelected() {
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists) const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (toImport.length === 0) return if (toImport.length === 0) return;
await importViaSse(toImport) await importViaSse(toImport);
} }
async function remove(p: FigureItem) { async function remove(p: FigureItem) {
setBusyLib(p.lib) setBusyLib(p.lib);
try { try {
const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, { const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, {
method: 'DELETE', method: "DELETE",
}) });
const data = await res.json() const data = await res.json();
if (res.ok) { if (res.ok) {
toast.success(`${p.lib} removed`) toast.success(`${p.lib} removed`);
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x))) setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
} else { } else {
toast.error(data.error || 'Delete failed') toast.error(data.error || "Delete failed");
} }
} finally { } finally {
setBusyLib(null) setBusyLib(null);
} }
} }
@@ -277,12 +267,12 @@ function LibsView() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') doSearch() if (e.key === "Enter") doSearch();
if (e.key === 'Escape') { if (e.key === "Escape") {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setPage(1) setPage(1);
fetchItems('', 1) fetchItems("", 1);
} }
}} }}
className="pl-9 pr-8" className="pl-9 pr-8"
@@ -291,10 +281,10 @@ function LibsView() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setPage(1) setPage(1);
fetchItems('', 1) fetchItems("", 1);
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 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 {selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
? 'Deselect page'
: `Select page (${importable.length})`}
</Button> </Button>
{selected.size > 0 && ( {selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}> <Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -352,28 +340,21 @@ function LibsView() {
{!loading && items.length === 0 && !error && ( {!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" /> <Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm"> <p className="text-sm">No libraries found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
No libraries found{activeSearch ? ` for "${activeSearch}"` : ''}
</p>
</div> </div>
)} )}
{items.length > 0 && ( {items.length > 0 && (
<div <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',
)}
>
{items.map((p) => { {items.map((p) => {
const isSelected = selected.has(p.lib) const isSelected = selected.has(p.lib);
return ( return (
<div <div
key={p.lib} key={p.lib}
className={cn( className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all', "rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30', p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && 'ring-2 ring-primary border-primary', isSelected && "ring-2 ring-primary border-primary",
)} )}
> >
{!p.nitroExists && ( {!p.nitroExists && (
@@ -381,10 +362,10 @@ function LibsView() {
type="button" type="button"
onClick={() => toggle(p.lib)} onClick={() => toggle(p.lib)}
className={cn( 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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground', : "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)} )}
> >
{isSelected && <Check className="w-3 h-3" />} {isSelected && <Check className="w-3 h-3" />}
@@ -434,7 +415,7 @@ function LibsView() {
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
@@ -449,7 +430,7 @@ function LibsView() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page <= 1 || loading} disabled={page <= 1 || loading}
onClick={() => goPage('prev')} onClick={() => goPage("prev")}
> >
&larr; Prev &larr; Prev
</Button> </Button>
@@ -457,7 +438,7 @@ function LibsView() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page >= lastPage || loading} disabled={page >= lastPage || loading}
onClick={() => goPage('next')} onClick={() => goPage("next")}
> >
Next &rarr; Next &rarr;
</Button> </Button>
@@ -465,120 +446,120 @@ function LibsView() {
</div> </div>
)} )}
</div> </div>
) );
} }
// ── Vestiti / Sets (v2) view ────────────────────────────────────────────────── // ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
function SetsView() { function SetsView() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState('') const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<ClothingSetItem[]>([]) const [items, setItems] = useState<ClothingSetItem[]>([]);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null) const [busyKey, setBusyKey] = useState<string | null>(null);
function itemKey(it: ClothingSetItem): string { function itemKey(it: ClothingSetItem): string {
return `${it.setType}-${it.setId}` return `${it.setType}-${it.setId}`;
} }
const fetchItems = useCallback(async (search: string, pageNum: number) => { const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true) setLoading(true);
setError('') setError("");
try { try {
const params = new URLSearchParams({ page: String(pageNum) }) const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set('search', search) if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/clothing/sets?${params}`) const res = await fetch(`/api/admin/import/clothing/sets?${params}`);
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load') setError(data.error || "Failed to load");
return return;
} }
setItems(data.item || []) setItems(data.item || []);
setTotal(data.meta?.total || 0) setTotal(data.meta?.total || 0);
setSelected(new Set()) setSelected(new Set());
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchItems('', 1) fetchItems("", 1);
}, [fetchItems]) }, [fetchItems]);
function doSearch() { function doSearch() {
setActiveSearch(searchTerm) setActiveSearch(searchTerm);
setPage(1) setPage(1);
fetchItems(searchTerm, 1) fetchItems(searchTerm, 1);
} }
function goPage(dir: 'prev' | 'next') { function goPage(dir: "prev" | "next") {
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1) const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next) setPage(next);
fetchItems(activeSearch, next) fetchItems(activeSearch, next);
} }
const importable = useMemo(() => items.filter((it) => !it.present), [items]) const importable = useMemo(() => items.filter((it) => !it.present), [items]);
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items]) const presentCount = useMemo(() => items.filter((it) => it.present).length, [items]);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1) const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(key: string) { function toggle(key: string) {
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev);
if (n.has(key)) n.delete(key) if (n.has(key)) n.delete(key);
else n.add(key) else n.add(key);
return n return n;
}) });
} }
function markDone(label: string) { 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[]) { async function importViaSse(toImport: ClothingSetItem[]) {
setBatchProgress({ done: 0, total: toImport.length }) setBatchProgress({ done: 0, total: toImport.length });
let done = 0 let done = 0;
await runSseImport( await runSseImport(
'/api/admin/import/clothing/sets/batch', "/api/admin/import/clothing/sets/batch",
{ {
items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })), items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })),
concurrency: 3, concurrency: 3,
}, },
(label) => { (label) => {
done++ done++;
markDone(label) markDone(label);
setBatchProgress({ done, total: toImport.length }) setBatchProgress({ done, total: toImport.length });
}, },
(succeeded, failed) => { (succeeded, failed) => {
setBatchProgress(null) setBatchProgress(null);
setSelected(new Set()) setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} sets imported`) if (succeeded > 0) toast.success(`${succeeded} sets imported`);
if (failed > 0) toast.error(`${failed} failed`) if (failed > 0) toast.error(`${failed} failed`);
}, },
) );
setBatchProgress(null) setBatchProgress(null);
} }
async function importOne(it: ClothingSetItem) { async function importOne(it: ClothingSetItem) {
const key = itemKey(it) const key = itemKey(it);
setBusyKey(key) setBusyKey(key);
try { try {
await importViaSse([it]) await importViaSse([it]);
} finally { } finally {
setBusyKey(null) setBusyKey(null);
} }
} }
async function importSelected() { async function importSelected() {
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present) const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present);
if (toImport.length === 0) return if (toImport.length === 0) return;
await importViaSse(toImport) await importViaSse(toImport);
} }
return ( return (
@@ -611,12 +592,12 @@ function SetsView() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') doSearch() if (e.key === "Enter") doSearch();
if (e.key === 'Escape') { if (e.key === "Escape") {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setPage(1) setPage(1);
fetchItems('', 1) fetchItems("", 1);
} }
}} }}
className="pl-9 pr-8" className="pl-9 pr-8"
@@ -625,10 +606,10 @@ function SetsView() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSearchTerm('') setSearchTerm("");
setActiveSearch('') setActiveSearch("");
setPage(1) setPage(1);
fetchItems('', 1) fetchItems("", 1);
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 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} disabled={!!batchProgress}
onClick={() => onClick={() =>
setSelected((prev) => setSelected((prev) =>
prev.size === importable.length prev.size === importable.length ? new Set() : new Set(importable.map((it) => itemKey(it))),
? new Set()
: new Set(importable.map((it) => itemKey(it))),
) )
} }
> >
{selected.size === importable.length {selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
? 'Deselect page'
: `Select page (${importable.length})`}
</Button> </Button>
{selected.size > 0 && ( {selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}> <Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -688,27 +665,22 @@ function SetsView() {
{!loading && items.length === 0 && !error && ( {!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" /> <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> </div>
)} )}
{items.length > 0 && ( {items.length > 0 && (
<div <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',
)}
>
{items.map((it) => { {items.map((it) => {
const key = itemKey(it) const key = itemKey(it);
const isSelected = selected.has(key) const isSelected = selected.has(key);
return ( return (
<div <div
key={key} key={key}
className={cn( className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all', "rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
it.present && 'ring-1 ring-[var(--admin-success)]/30', it.present && "ring-1 ring-[var(--admin-success)]/30",
isSelected && 'ring-2 ring-primary border-primary', isSelected && "ring-2 ring-primary border-primary",
)} )}
> >
{!it.present && ( {!it.present && (
@@ -716,10 +688,10 @@ function SetsView() {
type="button" type="button"
onClick={() => toggle(key)} onClick={() => toggle(key)}
className={cn( 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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground', : "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)} )}
> >
{isSelected && <Check className="w-3 h-3" />} {isSelected && <Check className="w-3 h-3" />}
@@ -772,7 +744,7 @@ function SetsView() {
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
@@ -787,7 +759,7 @@ function SetsView() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page <= 1 || loading} disabled={page <= 1 || loading}
onClick={() => goPage('prev')} onClick={() => goPage("prev")}
> >
&larr; Prev &larr; Prev
</Button> </Button>
@@ -795,7 +767,7 @@ function SetsView() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page >= lastPage || loading} disabled={page >= lastPage || loading}
onClick={() => goPage('next')} onClick={() => goPage("next")}
> >
Next &rarr; Next &rarr;
</Button> </Button>
@@ -803,36 +775,36 @@ function SetsView() {
</div> </div>
)} )}
</div> </div>
) );
} }
// ── Root component with mode toggle ────────────────────────────────────────── // ── Root component with mode toggle ──────────────────────────────────────────
export function ImportClothingClient() { export function ImportClothingClient() {
const [mode, setMode] = useState<Mode>('libs') const [mode, setMode] = useState<Mode>("libs");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-1 rounded-lg border p-1 w-fit"> <div className="flex gap-1 rounded-lg border p-1 w-fit">
<Button <Button
size="sm" size="sm"
variant={mode === 'libs' ? 'default' : 'ghost'} variant={mode === "libs" ? "default" : "ghost"}
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setMode('libs')} onClick={() => setMode("libs")}
> >
Librerie Librerie
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={mode === 'sets' ? 'default' : 'ghost'} variant={mode === "sets" ? "default" : "ghost"}
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => setMode('sets')} onClick={() => setMode("sets")}
> >
Vestiti (set) Vestiti (set)
</Button> </Button>
</div> </div>
{mode === 'libs' ? <LibsView /> : <SetsView />} {mode === "libs" ? <LibsView /> : <SetsView />}
</div> </div>
) );
} }
+1 -1
View File
@@ -9,5 +9,5 @@ export default function Loading() {
</div> </div>
))} ))}
</div> </div>
) );
} }
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from './import-clothing-client' import { ImportClothingClient } from "./import-clothing-client";
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) { 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)) { 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 // src/app/[locale]/admin/import/effects/import-effects-client.tsx
'use client' "use client";
import { import {
AlertCircle, AlertCircle,
@@ -12,165 +12,160 @@ import {
Sparkles, Sparkles,
Trash2, Trash2,
X, X,
} from 'lucide-react' } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
interface EffectItem { interface EffectItem {
id: string id: string;
lib: string lib: string;
type: string type: string;
revision: number revision: number;
present: boolean present: boolean;
nitroExists: boolean nitroExists: boolean;
} }
type Filter = 'all' | 'fx' | 'dance' type Filter = "all" | "fx" | "dance";
export function ImportEffectsClient() { export function ImportEffectsClient() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [effects, setEffects] = useState<EffectItem[]>([]) const [effects, setEffects] = useState<EffectItem[]>([]);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [filter, setFilter] = useState<Filter>('all') const [filter, setFilter] = useState<Filter>("all");
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null) const [busyLib, setBusyLib] = useState<string | null>(null);
const fetchEffects = useCallback(async (search: string) => { const fetchEffects = useCallback(async (search: string) => {
setLoading(true) setLoading(true);
setError('') setError("");
try { try {
const params = new URLSearchParams() const params = new URLSearchParams();
if (search) params.set('search', search) if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/effects?${params}`) const res = await fetch(`/api/admin/import/effects?${params}`);
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load') setError(data.error || "Failed to load");
return return;
} }
setEffects(data.effects || []) setEffects(data.effects || []);
setSelected(new Set()) setSelected(new Set());
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchEffects('') fetchEffects("");
}, [fetchEffects]) }, [fetchEffects]);
const visible = useMemo( const visible = useMemo(
() => effects.filter((e) => filter === 'all' || e.type === filter), () => effects.filter((e) => filter === "all" || e.type === filter),
[effects, filter], [effects, filter],
) );
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible]) const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible]);
const importedCount = useMemo( const importedCount = useMemo(() => visible.filter((e) => e.present && e.nitroExists).length, [visible]);
() => visible.filter((e) => e.present && e.nitroExists).length,
[visible],
)
function toggle(lib: string) { function toggle(lib: string) {
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev);
if (n.has(lib)) n.delete(lib) if (n.has(lib)) n.delete(lib);
else n.add(lib) else n.add(lib);
return n return n;
}) });
} }
function markDone(lib: string) { function markDone(lib: string) {
setEffects((prev) => setEffects((prev) => prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)));
prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)),
)
} }
async function importViaSse(items: EffectItem[]) { async function importViaSse(items: EffectItem[]) {
setBatchProgress({ done: 0, total: items.length }) setBatchProgress({ done: 0, total: items.length });
const res = await fetch('/api/admin/import/effects/batch', { const res = await fetch("/api/admin/import/effects/batch", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }), body: JSON.stringify({ items, concurrency: 3 }),
}) });
if (!res.body) { if (!res.body) {
toast.error('No response stream') toast.error("No response stream");
setBatchProgress(null) setBatchProgress(null);
return return;
} }
const reader = res.body.getReader() const reader = res.body.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
let buf = '' let buf = "";
let done = 0 let done = 0;
let succeeded = 0 let succeeded = 0;
let failed = 0 let failed = 0;
while (true) { while (true) {
const { value, done: streamDone } = await reader.read() const { value, done: streamDone } = await reader.read();
if (streamDone) break if (streamDone) break;
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n') const parts = buf.split("\n\n");
buf = parts.pop() ?? '' buf = parts.pop() ?? "";
for (const part of parts) { for (const part of parts) {
if (!part.startsWith('data: ')) continue if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6)) const evt = JSON.parse(part.slice(6));
if (evt.type === 'item_progress' && evt.status === 'done') { if (evt.type === "item_progress" && evt.status === "done") {
done++ done++;
succeeded++ succeeded++;
markDone(evt.classname) markDone(evt.classname);
setBatchProgress({ done, total: items.length }) setBatchProgress({ done, total: items.length });
} else if (evt.type === 'item_progress' && evt.status === 'failed') { } else if (evt.type === "item_progress" && evt.status === "failed") {
done++ done++;
failed++ failed++;
setBatchProgress({ done, total: items.length }) setBatchProgress({ done, total: items.length });
} else if (evt.type === 'batch_complete') { } else if (evt.type === "batch_complete") {
succeeded = evt.succeeded succeeded = evt.succeeded;
failed = evt.failed failed = evt.failed;
} }
} }
} }
setBatchProgress(null) setBatchProgress(null);
setSelected(new Set()) setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`) if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
if (failed > 0) toast.error(`${failed} failed`) if (failed > 0) toast.error(`${failed} failed`);
} }
async function importOne(e: EffectItem) { async function importOne(e: EffectItem) {
setBusyLib(e.lib) setBusyLib(e.lib);
try { try {
await importViaSse([e]) await importViaSse([e]);
} finally { } finally {
setBusyLib(null) setBusyLib(null);
} }
} }
async function importSelected() { async function importSelected() {
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists)) const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists));
if (items.length === 0) return if (items.length === 0) return;
await importViaSse(items) await importViaSse(items);
} }
async function remove(e: EffectItem) { async function remove(e: EffectItem) {
setBusyLib(e.lib) setBusyLib(e.lib);
try { try {
const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, { const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, {
method: 'DELETE', method: "DELETE",
}) });
const data = await res.json() const data = await res.json();
if (res.ok) { if (res.ok) {
toast.success(`${e.lib} removed`) toast.success(`${e.lib} removed`);
setEffects((prev) => setEffects((prev) =>
prev.map((x) => (x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x)), prev.map((x) => (x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x)),
) );
} else { } else {
toast.error(data.error || 'Delete failed') toast.error(data.error || "Delete failed");
} }
} finally { } finally {
setBusyLib(null) setBusyLib(null);
} }
} }
@@ -206,10 +201,10 @@ export function ImportEffectsClient() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') fetchEffects(searchTerm) if (e.key === "Enter") fetchEffects(searchTerm);
if (e.key === 'Escape') { if (e.key === "Escape") {
setSearchTerm('') setSearchTerm("");
fetchEffects('') fetchEffects("");
} }
}} }}
className="pl-9 pr-8" className="pl-9 pr-8"
@@ -218,8 +213,8 @@ export function ImportEffectsClient() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSearchTerm('') setSearchTerm("");
fetchEffects('') fetchEffects("");
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 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}> <Button onClick={() => fetchEffects(searchTerm)} 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> </Button>
{(['all', 'fx', 'dance'] as Filter[]).map((f) => ( {(["all", "fx", "dance"] as Filter[]).map((f) => (
<Button <Button
key={f} key={f}
size="sm" size="sm"
variant={filter === f ? 'default' : 'outline'} variant={filter === f ? "default" : "outline"}
onClick={() => setFilter(f)} onClick={() => setFilter(f)}
> >
{f.toUpperCase()} {f.toUpperCase()}
@@ -256,9 +251,7 @@ export function ImportEffectsClient() {
) )
} }
> >
{selected.size === importable.length {selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
? 'Deselect All'
: `Select All (${importable.length})`}
</Button> </Button>
{selected.size > 0 && ( {selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}> <Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -296,22 +289,17 @@ export function ImportEffectsClient() {
{/* Grid */} {/* Grid */}
{visible.length > 0 && ( {visible.length > 0 && (
<div <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',
)}
>
{visible.map((e) => { {visible.map((e) => {
const imported = e.present && e.nitroExists const imported = e.present && e.nitroExists;
const isSelected = selected.has(e.lib) const isSelected = selected.has(e.lib);
return ( return (
<div <div
key={e.lib} key={e.lib}
className={cn( className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all', "rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
imported && 'ring-1 ring-[var(--admin-success)]/30', imported && "ring-1 ring-[var(--admin-success)]/30",
isSelected && 'ring-2 ring-primary border-primary', isSelected && "ring-2 ring-primary border-primary",
)} )}
> >
{!imported && ( {!imported && (
@@ -319,10 +307,10 @@ export function ImportEffectsClient() {
type="button" type="button"
onClick={() => toggle(e.lib)} onClick={() => toggle(e.lib)}
className={cn( 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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground', : "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)} )}
> >
{isSelected && <Check className="w-3 h-3" />} {isSelected && <Check className="w-3 h-3" />}
@@ -330,13 +318,13 @@ export function ImportEffectsClient() {
)} )}
<div <div
className={cn( className={cn(
'flex flex-col items-center justify-center h-20 gap-1', "flex flex-col items-center justify-center h-20 gap-1",
e.type === 'dance' e.type === "dance"
? 'bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10' ? "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', : "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" /> <Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
) : ( ) : (
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" /> <Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
@@ -385,10 +373,10 @@ export function ImportEffectsClient() {
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
</div> </div>
) );
} }
+1 -1
View File
@@ -10,5 +10,5 @@ export default function Loading() {
</div> </div>
))} ))}
</div> </div>
) );
} }
+6 -6
View File
@@ -1,12 +1,12 @@
// src/app/[locale]/admin/import/effects/page.tsx // src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportEffectsClient } from './import-effects-client' import { ImportEffectsClient } from "./import-effects-client";
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) { 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)) { 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() { export default function ImportFurniLoading() {
return ( return (
@@ -17,5 +17,5 @@ export default function ImportFurniLoading() {
<Skeleton className="h-10 w-24 mt-4" /> <Skeleton className="h-10 w-24 mt-4" />
</div> </div>
</div> </div>
) );
} }
+184 -215
View File
@@ -1,4 +1,4 @@
'use client' "use client";
import { import {
Box, Box,
@@ -14,71 +14,65 @@ import {
Ruler, Ruler,
Save, Save,
Sparkles, Sparkles,
} from 'lucide-react' } from "lucide-react";
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Dialog, import { Input } from "@/components/ui/input";
DialogContent, import { Label } from "@/components/ui/label";
DialogFooter, import { Switch } from "@/components/ui/switch";
DialogHeader, import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
DialogTitle, import { Textarea } from "@/components/ui/textarea";
} 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 { interface NitroEditorDialogProps {
classname: string classname: string;
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
} }
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code // biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function updateNested(obj: any, path: string, value: unknown): any { function updateNested(obj: any, path: string, value: unknown): any {
const clone = JSON.parse(JSON.stringify(obj)) const clone = JSON.parse(JSON.stringify(obj));
const keys = path.split('.') const keys = path.split(".");
let current = clone let current = clone;
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {} if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]] current = current[keys[i]];
} }
current[keys[keys.length - 1]] = value current[keys[keys.length - 1]] = value;
return clone return clone;
} }
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code // biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function getNested(obj: any, path: string, fallback?: unknown): any { function getNested(obj: any, path: string, fallback?: unknown): any {
const keys = path.split('.') const keys = path.split(".");
let current = obj let current = obj;
for (const key of keys) { for (const key of keys) {
if (current == null || typeof current !== 'object') return fallback if (current == null || typeof current !== "object") return fallback;
current = current[key] current = current[key];
} }
return current ?? fallback return current ?? fallback;
} }
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7] const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7];
const DIR_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
function hexToColor(hex: string): string { function hexToColor(hex: string): string {
return `#${hex.padStart(6, '0')}` return `#${hex.padStart(6, "0")}`;
} }
function CopyClassname({ value }: { value: string }) { function CopyClassname({ value }: { value: string }) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false);
return ( return (
<button <button
type="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" className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(value) navigator.clipboard.writeText(value);
setCopied(true) setCopied(true);
setTimeout(() => setCopied(false), 1500) setTimeout(() => setCopied(false), 1500);
}} }}
> >
<span className="truncate">{value}</span> <span className="truncate">{value}</span>
@@ -88,178 +82,177 @@ function CopyClassname({ value }: { value: string }) {
<Copy className="w-3 h-3 shrink-0" /> <Copy className="w-3 h-3 shrink-0" />
)} )}
</button> </button>
) );
} }
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) { export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code // 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 // biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const [originalMetadata, setOriginalMetadata] = useState<any>(null) const [originalMetadata, setOriginalMetadata] = useState<any>(null);
const [jsonText, setJsonText] = useState('') const [jsonText, setJsonText] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<string>('visual') const [activeTab, setActiveTab] = useState<string>("visual");
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false }) const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false });
const [originalFlags, setOriginalFlags] = useState({ const [originalFlags, setOriginalFlags] = useState({
canstandon: false, canstandon: false,
cansiton: false, cansiton: false,
canlayon: false, canlayon: false,
}) });
const [jsonError, setJsonError] = useState<string | null>(null) const [jsonError, setJsonError] = useState<string | null>(null);
const fetchMetadata = useCallback(async () => { const fetchMetadata = useCallback(async () => {
if (!classname) return if (!classname) return;
setLoading(true) setLoading(true);
setJsonError(null) setJsonError(null);
try { try {
const res = await fetch( const res = await fetch(
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`, `/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
) );
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}));
toast.error(err.error || 'Failed to load .nitro metadata') toast.error(err.error || "Failed to load .nitro metadata");
onOpenChange(false) onOpenChange(false);
return return;
} }
const data = await res.json() const data = await res.json();
setMetadata(data.metadata) setMetadata(data.metadata);
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata))) setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)));
setJsonText(JSON.stringify(data.metadata, null, 2)) setJsonText(JSON.stringify(data.metadata, null, 2));
setFlags(data.flags) setFlags(data.flags);
setOriginalFlags({ ...data.flags }) setOriginalFlags({ ...data.flags });
} catch { } catch {
toast.error('Network error loading .nitro metadata') toast.error("Network error loading .nitro metadata");
onOpenChange(false) onOpenChange(false);
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, [classname, onOpenChange]) }, [classname, onOpenChange]);
useEffect(() => { useEffect(() => {
if (open && classname) { if (open && classname) {
setActiveTab('visual') setActiveTab("visual");
fetchMetadata() fetchMetadata();
} }
}, [open, classname, fetchMetadata]) }, [open, classname, fetchMetadata]);
const handleTabChange = (newTab: string) => { const handleTabChange = (newTab: string) => {
if (activeTab === 'json' && newTab === 'visual') { if (activeTab === "json" && newTab === "visual") {
try { try {
const parsed = JSON.parse(jsonText) const parsed = JSON.parse(jsonText);
setMetadata(parsed) setMetadata(parsed);
setJsonError(null) setJsonError(null);
} catch { } catch {
toast.error('Invalid JSON — fix it before switching to Visual tab') toast.error("Invalid JSON — fix it before switching to Visual tab");
return return;
} }
} else if (activeTab === 'visual' && newTab === 'json') { } else if (activeTab === "visual" && newTab === "json") {
setJsonText(JSON.stringify(metadata, null, 2)) setJsonText(JSON.stringify(metadata, null, 2));
} }
setActiveTab(newTab) setActiveTab(newTab);
} };
const handleReset = () => { const handleReset = () => {
if (!originalMetadata) return if (!originalMetadata) return;
setMetadata(JSON.parse(JSON.stringify(originalMetadata))) setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
setFlags({ ...originalFlags }) setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2)) setJsonText(JSON.stringify(originalMetadata, null, 2));
toast.info('Reset to original values') toast.info("Reset to original values");
} };
const handleSave = async () => { const handleSave = async () => {
let metadataToSave = metadata let metadataToSave = metadata;
if (activeTab === 'json') { if (activeTab === "json") {
try { try {
metadataToSave = JSON.parse(jsonText) metadataToSave = JSON.parse(jsonText);
} catch { } catch {
toast.error('Invalid JSON') toast.error("Invalid JSON");
return return;
} }
} }
setSaving(true) setSaving(true);
try { try {
const res = await fetch('/api/admin/import/furni/nitro-editor', { const res = await fetch("/api/admin/import/furni/nitro-editor", {
method: 'PUT', method: "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
classname, classname,
metadata: metadataToSave, metadata: metadataToSave,
...flags, ...flags,
}), }),
}) });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}));
toast.error(err.error || 'Save failed') toast.error(err.error || "Save failed");
return return;
} }
toast.success('Nitro metadata salvato') toast.success("Nitro metadata salvato");
setMetadata(metadataToSave) setMetadata(metadataToSave);
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave))) setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)));
setOriginalFlags({ ...flags }) setOriginalFlags({ ...flags });
setJsonText(JSON.stringify(metadataToSave, null, 2)) setJsonText(JSON.stringify(metadataToSave, null, 2));
} catch { } catch {
toast.error('Network error') toast.error("Network error");
} finally { } finally {
setSaving(false) setSaving(false);
} }
} };
// ── Visual tab helpers ── // ── Visual tab helpers ──
const dims = metadata const dims = metadata
? getNested(metadata, 'logic.model.dimensions', { x: 1, y: 1, z: 0 }) ? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 })
: { x: 1, y: 1, z: 0 } : { x: 1, y: 1, z: 0 };
const directions: number[] = metadata ? getNested(metadata, 'logic.model.directions', [0]) : [0] const directions: number[] = metadata ? getNested(metadata, "logic.model.directions", [0]) : [0];
const setDim = (axis: 'x' | 'y' | 'z', value: string) => { const setDim = (axis: "x" | "y" | "z", value: string) => {
const num = axis === 'z' ? parseFloat(value) || 0 : parseInt(value, 10) || 0 const num = axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0;
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num)) setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num));
} };
const toggleDirection = (dir: number) => { 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) const next = current.includes(dir)
? current.filter((d: number) => d !== dir) ? current.filter((d: number) => d !== dir)
: [...current, dir].sort((a: number, b: number) => a - b) : [...current, dir].sort((a: number, b: number) => a - b);
if (next.length === 0) return if (next.length === 0) return;
setMetadata((prev: unknown) => updateNested(prev, 'logic.model.directions', next)) setMetadata((prev: unknown) => updateNested(prev, "logic.model.directions", next));
} };
// Get visualization with size=64 for layer/color editing // Get visualization with size=64 for layer/color editing
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code // biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64) const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64);
const viz64Index = const viz64Index = metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1;
metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0 const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0;
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0 const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0;
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0 const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0;
const handleJsonTextChange = (value: string) => { const handleJsonTextChange = (value: string) => {
setJsonText(value) setJsonText(value);
try { try {
JSON.parse(value) JSON.parse(value);
setJsonError(null) setJsonError(null);
} catch (e) { } catch (e) {
setJsonError((e as Error).message) setJsonError((e as Error).message);
} }
} };
// Check if there are unsaved changes // Check if there are unsaved changes
const hasChanges = const hasChanges =
metadata && metadata &&
originalMetadata && originalMetadata &&
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) || (JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
JSON.stringify(flags) !== JSON.stringify(originalFlags)) JSON.stringify(flags) !== JSON.stringify(originalFlags));
// Furni preview URL // Furni preview URL
const starIdx = classname.indexOf('*') const starIdx = classname.indexOf("*");
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -271,18 +264,18 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<div <div
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden" className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
style={{ style={{
backgroundImage: 'url(/images/furni_floor.png)', backgroundImage: "url(/images/furni_floor.png)",
backgroundRepeat: 'repeat', backgroundRepeat: "repeat",
backgroundPosition: 'center', backgroundPosition: "center",
}} }}
> >
<img <img
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`} src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
alt={classname} alt={classname}
className="max-w-[60px] max-h-[60px] drop-shadow-md" className="max-w-[60px] max-h-[60px] drop-shadow-md"
style={{ imageRendering: 'pixelated' }} style={{ imageRendering: "pixelated" }}
onError={(e) => { onError={(e) => {
;(e.target as HTMLImageElement).style.opacity = '0' (e.target as HTMLImageElement).style.opacity = "0";
}} }}
/> />
</div> </div>
@@ -318,7 +311,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
)} )}
<Badge variant="outline" className="text-[10px] gap-1"> <Badge variant="outline" className="text-[10px] gap-1">
<Eye className="w-2.5 h-2.5" /> <Eye className="w-2.5 h-2.5" />
{metadata.visualizationType || '?'} {metadata.visualizationType || "?"}
</Badge> </Badge>
</div> </div>
)} )}
@@ -331,11 +324,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div> </div>
) : metadata ? ( ) : metadata ? (
<Tabs <Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
<div className="px-6 pt-3 pb-0"> <div className="px-6 pt-3 pb-0">
<TabsList className="w-fit"> <TabsList className="w-fit">
<TabsTrigger value="visual" className="gap-1.5"> <TabsTrigger value="visual" className="gap-1.5">
@@ -343,7 +332,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
Visual Visual
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="json" className="gap-1.5"> <TabsTrigger value="json" className="gap-1.5">
{'{ }'} {"{ }"}
JSON JSON
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -369,7 +358,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
type="number" type="number"
min={1} min={1}
value={dims.x} value={dims.x}
onChange={(e) => setDim('x', e.target.value)} onChange={(e) => setDim("x", e.target.value)}
className="mt-1" className="mt-1"
/> />
</div> </div>
@@ -382,7 +371,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
type="number" type="number"
min={1} min={1}
value={dims.y} value={dims.y}
onChange={(e) => setDim('y', e.target.value)} onChange={(e) => setDim("y", e.target.value)}
className="mt-1" className="mt-1"
/> />
</div> </div>
@@ -396,7 +385,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
step={0.01} step={0.01}
min={0} min={0}
value={dims.z} value={dims.z}
onChange={(e) => setDim('z', e.target.value)} onChange={(e) => setDim("z", e.target.value)}
className="mt-1" className="mt-1"
/> />
</div> </div>
@@ -440,9 +429,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" /> <Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Direzioni</h3> <h3 className="text-sm font-medium">Direzioni</h3>
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">{directions.length} attive</span>
{directions.length} attive
</span>
</div> </div>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<div className="relative w-48 h-48"> <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" /> <div className="absolute inset-4 rounded-full border border-muted/50" />
{/* Direction buttons arranged in circle */} {/* Direction buttons arranged in circle */}
{ALL_DIRECTIONS.map((dir) => { {ALL_DIRECTIONS.map((dir) => {
const angle = (dir * 45 - 90) * (Math.PI / 180) const angle = (dir * 45 - 90) * (Math.PI / 180);
const r = 80 const r = 80;
const x = 96 + r * Math.cos(angle) - 18 const x = 96 + r * Math.cos(angle) - 18;
const y = 96 + r * Math.sin(angle) - 18 const y = 96 + r * Math.sin(angle) - 18;
const isActive = directions.includes(dir) const isActive = directions.includes(dir);
return ( return (
<button <button
type="button" type="button"
key={dir} 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 ${ className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
isActive isActive
? 'bg-primary text-primary-foreground border-primary shadow-md scale-110' ? "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-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
}`} }`}
style={{ left: x, top: y }} style={{ left: x, top: y }}
onClick={() => toggleDirection(dir)} 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-[9px] leading-none">{DIR_LABELS[dir]}</span>
<span className="text-[8px] leading-none opacity-60">{dir}</span> <span className="text-[8px] leading-none opacity-60">{dir}</span>
</button> </button>
) );
})} })}
{/* Center label */} {/* Center label */}
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground font-medium"> <span className="text-xs text-muted-foreground font-medium">{directions.length}/8</span>
{directions.length}/8
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -496,10 +481,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</Label> </Label>
<Input <Input
id="viz-type" id="viz-type"
value={metadata.visualizationType || ''} value={metadata.visualizationType || ""}
onChange={(e) => onChange={(e) =>
setMetadata((prev: unknown) => setMetadata((prev: unknown) =>
updateNested(prev, 'visualizationType', e.target.value), updateNested(prev, "visualizationType", e.target.value),
) )
} }
className="mt-1 font-mono text-sm" className="mt-1 font-mono text-sm"
@@ -511,11 +496,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</Label> </Label>
<Input <Input
id="logic-type" id="logic-type"
value={metadata.logicType || ''} value={metadata.logicType || ""}
onChange={(e) => onChange={(e) =>
setMetadata((prev: unknown) => setMetadata((prev: unknown) => updateNested(prev, "logicType", e.target.value))
updateNested(prev, 'logicType', e.target.value),
)
} }
className="mt-1 font-mono text-sm" className="mt-1 font-mono text-sm"
/> />
@@ -542,12 +525,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<span>Ink</span> <span>Ink</span>
</div> </div>
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => { {Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
const layer = layerData as Record<string, unknown> const layer = layerData as Record<string, unknown>;
return ( return (
<div <div
key={layerId} key={layerId}
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${ 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"> <span className="font-mono text-xs">
@@ -566,7 +549,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.z`, `visualizations.${viz64Index}.layers.${layerId}.z`,
parseInt(e.target.value, 10) || 0, parseInt(e.target.value, 10) || 0,
), ),
) );
}} }}
/> />
<Input <Input
@@ -582,12 +565,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.alpha`, `visualizations.${viz64Index}.layers.${layerId}.alpha`,
parseInt(e.target.value, 10) || 0, parseInt(e.target.value, 10) || 0,
), ),
) );
}} }}
/> />
<Input <Input
className="h-7 text-xs font-mono" className="h-7 text-xs font-mono"
value={(layer.ink as string) ?? ''} value={(layer.ink as string) ?? ""}
placeholder="—" placeholder="—"
onChange={(e) => { onChange={(e) => {
setMetadata((prev: unknown) => setMetadata((prev: unknown) =>
@@ -596,11 +579,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.ink`, `visualizations.${viz64Index}.layers.${layerId}.ink`,
e.target.value || undefined, e.target.value || undefined,
), ),
) );
}} }}
/> />
</div> </div>
) );
})} })}
</div> </div>
</section> </section>
@@ -623,22 +606,20 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<span className="text-xs font-medium">Variante #{colorId}</span> <span className="text-xs font-medium">Variante #{colorId}</span>
{/* Color preview dots */} {/* Color preview dots */}
<div className="flex gap-1 ml-auto"> <div className="flex gap-1 ml-auto">
{Object.values(colorLayers as Record<string, { color: number }>).map( {Object.values(colorLayers as Record<string, { color: number }>).map((lc, i) => (
(lc, i) => ( <div
<div // biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity key={i}
key={i} className="w-4 h-4 rounded-full border border-border shadow-sm"
className="w-4 h-4 rounded-full border border-border shadow-sm" style={{
style={{ backgroundColor: hexToColor(
backgroundColor: hexToColor( typeof lc.color === "number"
typeof lc.color === 'number' ? lc.color.toString(16).padStart(6, "0")
? lc.color.toString(16).padStart(6, '0') : "000000",
: '000000', ),
), }}
}} />
/> ))}
),
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
@@ -652,25 +633,20 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Input <Input
className="h-7 text-xs font-mono pl-8" className="h-7 text-xs font-mono pl-8"
value={ value={
typeof layerColor.color === 'number' typeof layerColor.color === "number"
? layerColor.color ? layerColor.color.toString(16).padStart(6, "0").toUpperCase()
.toString(16)
.padStart(6, '0')
.toUpperCase()
: String(layerColor.color) : String(layerColor.color)
} }
onChange={(e) => { onChange={(e) => {
const hex = e.target.value const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
.replace(/[^0-9a-fA-F]/g, '') const num = parseInt(hex, 16) || 0;
.slice(0, 6)
const num = parseInt(hex, 16) || 0
setMetadata((prev: unknown) => setMetadata((prev: unknown) =>
updateNested( updateNested(
prev, prev,
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`, `visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
num, num,
), ),
) );
}} }}
/> />
{/* Color swatch inside input */} {/* 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" className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
style={{ style={{
backgroundColor: hexToColor( backgroundColor: hexToColor(
typeof layerColor.color === 'number' typeof layerColor.color === "number"
? layerColor.color.toString(16).padStart(6, '0') ? layerColor.color.toString(16).padStart(6, "0")
: '000000', : "000000",
), ),
}} }}
/> />
@@ -718,10 +694,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<DialogFooter className="px-6 py-4 border-t bg-muted/30"> <DialogFooter className="px-6 py-4 border-t bg-muted/30">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
{hasChanges && ( {hasChanges && (
<Badge <Badge variant="secondary" className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]">
variant="secondary"
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
>
Modifiche non salvate Modifiche non salvate
</Badge> </Badge>
)} )}
@@ -739,11 +712,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Annulla Annulla
</Button> </Button>
<Button <Button onClick={handleSave} disabled={saving || loading || !metadata} className="gap-1.5">
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" />} {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Salva Salva
</Button> </Button>
@@ -751,5 +720,5 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportFurniClient } from './import-furni-client' import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) { 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)) { 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 { import { AlertCircle, Check, Download, Loader2, Package, PawPrint, Search, Trash2, X } from "lucide-react";
AlertCircle, import { useCallback, useEffect, useMemo, useState } from "react";
Check, import { toast } from "sonner";
Download, import { Badge } from "@/components/ui/badge";
Loader2, import { Button } from "@/components/ui/button";
Package, import { Input } from "@/components/ui/input";
PawPrint, import { cn } from "@/lib/utils";
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 { interface PetItem {
lib: string lib: string;
nitroExists: boolean nitroExists: boolean;
} }
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */ /** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
function PetIcon({ lib }: { lib: string }) { function PetIcon({ lib }: { lib: string }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false);
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" /> if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
return ( return (
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset // biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
<img <img
@@ -36,135 +26,135 @@ function PetIcon({ lib }: { lib: string }) {
style={{ maxHeight: 56, maxWidth: 56 }} style={{ maxHeight: 56, maxWidth: 56 }}
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
) );
} }
export function ImportPetsClient() { export function ImportPetsClient() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [pets, setPets] = useState<PetItem[]>([]) const [pets, setPets] = useState<PetItem[]>([]);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null) const [busyLib, setBusyLib] = useState<string | null>(null);
const fetchPets = useCallback(async (search: string) => { const fetchPets = useCallback(async (search: string) => {
setLoading(true) setLoading(true);
setError('') setError("");
try { try {
const params = new URLSearchParams() const params = new URLSearchParams();
if (search) params.set('search', search) if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/pets?${params}`) const res = await fetch(`/api/admin/import/pets?${params}`);
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to load') setError(data.error || "Failed to load");
return return;
} }
setPets(data.pets || []) setPets(data.pets || []);
setSelected(new Set()) setSelected(new Set());
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchPets('') fetchPets("");
}, [fetchPets]) }, [fetchPets]);
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]) const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]) const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]);
function toggle(lib: string) { function toggle(lib: string) {
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev);
if (n.has(lib)) n.delete(lib) if (n.has(lib)) n.delete(lib);
else n.add(lib) else n.add(lib);
return n return n;
}) });
} }
function markDone(lib: string) { 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[]) { async function importViaSse(items: PetItem[]) {
setBatchProgress({ done: 0, total: items.length }) setBatchProgress({ done: 0, total: items.length });
const res = await fetch('/api/admin/import/pets/batch', { const res = await fetch("/api/admin/import/pets/batch", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }), body: JSON.stringify({ items, concurrency: 3 }),
}) });
if (!res.body) { if (!res.body) {
toast.error('No response stream') toast.error("No response stream");
setBatchProgress(null) setBatchProgress(null);
return return;
} }
const reader = res.body.getReader() const reader = res.body.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
let buf = '' let buf = "";
let done = 0 let done = 0;
let succeeded = 0 let succeeded = 0;
let failed = 0 let failed = 0;
while (true) { while (true) {
const { value, done: streamDone } = await reader.read() const { value, done: streamDone } = await reader.read();
if (streamDone) break if (streamDone) break;
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n') const parts = buf.split("\n\n");
buf = parts.pop() ?? '' buf = parts.pop() ?? "";
for (const part of parts) { for (const part of parts) {
if (!part.startsWith('data: ')) continue if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6)) const evt = JSON.parse(part.slice(6));
if (evt.type === 'item_progress' && evt.status === 'done') { if (evt.type === "item_progress" && evt.status === "done") {
done++ done++;
markDone(evt.classname) markDone(evt.classname);
setBatchProgress({ done, total: items.length }) setBatchProgress({ done, total: items.length });
} else if (evt.type === 'item_progress' && evt.status === 'failed') { } else if (evt.type === "item_progress" && evt.status === "failed") {
done++ done++;
setBatchProgress({ done, total: items.length }) setBatchProgress({ done, total: items.length });
} else if (evt.type === 'batch_complete') { } else if (evt.type === "batch_complete") {
succeeded = evt.succeeded succeeded = evt.succeeded;
failed = evt.failed failed = evt.failed;
} }
} }
} }
setBatchProgress(null) setBatchProgress(null);
setSelected(new Set()) setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`) if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
if (failed > 0) toast.error(`${failed} failed`) if (failed > 0) toast.error(`${failed} failed`);
} }
async function importOne(p: PetItem) { async function importOne(p: PetItem) {
setBusyLib(p.lib) setBusyLib(p.lib);
try { try {
await importViaSse([p]) await importViaSse([p]);
} finally { } finally {
setBusyLib(null) setBusyLib(null);
} }
} }
async function importSelected() { async function importSelected() {
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists) const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (items.length === 0) return if (items.length === 0) return;
await importViaSse(items) await importViaSse(items);
} }
async function remove(p: PetItem) { async function remove(p: PetItem) {
setBusyLib(p.lib) setBusyLib(p.lib);
try { try {
const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, { const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, {
method: 'DELETE', method: "DELETE",
}) });
const data = await res.json() const data = await res.json();
if (res.ok) { if (res.ok) {
toast.success(`${p.lib} removed`) toast.success(`${p.lib} removed`);
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x))) setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
} else { } else {
toast.error(data.error || 'Delete failed') toast.error(data.error || "Delete failed");
} }
} finally { } finally {
setBusyLib(null) setBusyLib(null);
} }
} }
@@ -198,10 +188,10 @@ export function ImportPetsClient() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') fetchPets(searchTerm) if (e.key === "Enter") fetchPets(searchTerm);
if (e.key === 'Escape') { if (e.key === "Escape") {
setSearchTerm('') setSearchTerm("");
fetchPets('') fetchPets("");
} }
}} }}
className="pl-9 pr-8" className="pl-9 pr-8"
@@ -210,8 +200,8 @@ export function ImportPetsClient() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSearchTerm('') setSearchTerm("");
fetchPets('') fetchPets("");
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 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 {selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
? 'Deselect All'
: `Select All (${importable.length})`}
</Button> </Button>
{selected.size > 0 && ( {selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}> <Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -274,21 +262,16 @@ export function ImportPetsClient() {
)} )}
{pets.length > 0 && ( {pets.length > 0 && (
<div <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',
)}
>
{pets.map((p) => { {pets.map((p) => {
const isSelected = selected.has(p.lib) const isSelected = selected.has(p.lib);
return ( return (
<div <div
key={p.lib} key={p.lib}
className={cn( className={cn(
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all', "rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30', p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && 'ring-2 ring-primary border-primary', isSelected && "ring-2 ring-primary border-primary",
)} )}
> >
{!p.nitroExists && ( {!p.nitroExists && (
@@ -296,10 +279,10 @@ export function ImportPetsClient() {
type="button" type="button"
onClick={() => toggle(p.lib)} onClick={() => toggle(p.lib)}
className={cn( 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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground', : "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)} )}
> >
{isSelected && <Check className="w-3 h-3" />} {isSelected && <Check className="w-3 h-3" />}
@@ -347,10 +330,10 @@ export function ImportPetsClient() {
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
</div> </div>
) );
} }
+1 -1
View File
@@ -9,5 +9,5 @@ export default function Loading() {
</div> </div>
))} ))}
</div> </div>
) );
} }
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportPetsClient } from './import-pets-client' import { ImportPetsClient } from "./import-pets-client";
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) { 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)) { 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 { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairClient } from './repair-client' import { RepairClient } from "./repair-client";
export default async function RepairImportedPage() { export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { 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 { import {
AlertCircle, AlertCircle,
@@ -10,13 +10,13 @@ import {
Trash2, Trash2,
Wrench, Wrench,
XCircle, XCircle,
} from 'lucide-react' } from "lucide-react";
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
import { deleteImportedFurni } from '@/actions/import-furni' import { deleteImportedFurni } from "@/actions/import-furni";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -24,245 +24,238 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
Table, import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { interface MissingItem {
id: number id: number;
classname: string classname: string;
baseClassname: string baseClassname: string;
revision: number revision: number;
type: 's' | 'i' type: "s" | "i";
publicName: string publicName: string;
hasSwf: boolean hasSwf: boolean;
hasIcon: boolean hasIcon: boolean;
hasNitro: boolean hasNitro: boolean;
missing: MissingKind missing: MissingKind;
unrepairable: boolean unrepairable: boolean;
} }
interface AuditSummary { interface AuditSummary {
total: number total: number;
missingBoth: number missingBoth: number;
missingNitro: number missingNitro: number;
missingIcon: number missingIcon: number;
unrepairable: number unrepairable: number;
items: MissingItem[] items: MissingItem[];
} }
type ItemStatus = 'idle' | 'working' | 'done' | 'partial' | 'failed' type ItemStatus = "idle" | "working" | "done" | "partial" | "failed";
interface ItemProgress { interface ItemProgress {
status: ItemStatus status: ItemStatus;
message?: string message?: string;
nitroRepaired?: boolean nitroRepaired?: boolean;
iconRepaired?: boolean iconRepaired?: boolean;
unrepairable?: boolean unrepairable?: boolean;
} }
interface RepairSummary { interface RepairSummary {
succeeded: number succeeded: number;
partial: number partial: number;
failed: number failed: number;
unrepairable: number unrepairable: number;
duration: number duration: number;
} }
const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = { const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = {
all: () => true, all: () => true,
both: (m) => m.missing === 'both', both: (m) => m.missing === "both",
nitro: (m) => m.missing === 'nitro', nitro: (m) => m.missing === "nitro",
icon: (m) => m.missing === 'icon', icon: (m) => m.missing === "icon",
unrepairable: (m) => m.unrepairable, unrepairable: (m) => m.unrepairable,
} };
export function RepairClient() { export function RepairClient() {
const [summary, setSummary] = useState<AuditSummary | null>(null) const [summary, setSummary] = useState<AuditSummary | null>(null);
const [auditing, setAuditing] = useState(false) const [auditing, setAuditing] = useState(false);
const [repairing, setRepairing] = useState(false) const [repairing, setRepairing] = useState(false);
const [search, setSearch] = useState('') const [search, setSearch] = useState("");
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>('all') const [tab, setTab] = useState<keyof typeof TAB_FILTERS>("all");
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set());
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map()) const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map());
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null) const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null);
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false) const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
// ── Audit ────────────────────────────────────────────────────────── // ── Audit ──────────────────────────────────────────────────────────
const runAudit = useCallback(async () => { const runAudit = useCallback(async () => {
setAuditing(true) setAuditing(true);
setLastSummary(null) setLastSummary(null);
try { 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) { if (!res.ok) {
toast.error('Audit failed') toast.error("Audit failed");
return return;
} }
const data = await res.json() const data = await res.json();
setSummary(data) setSummary(data);
setSelected(new Set()) setSelected(new Set());
setProgress(new Map()) setProgress(new Map());
toast.success( 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 { } catch {
toast.error('Audit network error') toast.error("Audit network error");
} finally { } finally {
setAuditing(false) setAuditing(false);
} }
}, []) }, []);
// ── Filters ──────────────────────────────────────────────────────── // ── Filters ────────────────────────────────────────────────────────
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!summary) return [] if (!summary) return [];
const s = search.trim().toLowerCase() const s = search.trim().toLowerCase();
return summary.items.filter((it) => { return summary.items.filter((it) => {
if (!TAB_FILTERS[tab](it)) return false if (!TAB_FILTERS[tab](it)) return false;
if (!s) return true if (!s) return true;
return ( return (
it.classname.toLowerCase().includes(s) || it.classname.toLowerCase().includes(s) ||
it.publicName.toLowerCase().includes(s) || it.publicName.toLowerCase().includes(s) ||
String(it.id).includes(s) String(it.id).includes(s)
) );
}) });
}, [summary, search, tab]) }, [summary, search, tab]);
// ── Selection ────────────────────────────────────────────────────── // ── 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 = () => { const toggleAll = () => {
setSelected((prev) => { setSelected((prev) => {
if (allSelected) { if (allSelected) {
const next = new Set(prev) const next = new Set(prev);
for (const i of filtered) { 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) { for (const i of filtered) {
next.add(i.classname) next.add(i.classname);
} }
return next return next;
}) });
} };
const toggleOne = (cn: string) => { const toggleOne = (cn: string) => {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(cn)) next.delete(cn) if (next.has(cn)) next.delete(cn);
else next.add(cn) else next.add(cn);
return next return next;
}) });
} };
// ── Repair ───────────────────────────────────────────────────────── // ── Repair ─────────────────────────────────────────────────────────
const runRepair = useCallback(async (classnames: string[]) => { const runRepair = useCallback(async (classnames: string[]) => {
if (classnames.length === 0) return if (classnames.length === 0) return;
setRepairing(true) setRepairing(true);
setLastSummary(null) setLastSummary(null);
// Init progress for these items // Init progress for these items
setProgress((prev) => { setProgress((prev) => {
const next = new Map(prev) const next = new Map(prev);
for (const cn of classnames) { for (const cn of classnames) {
next.set(cn, { status: 'working' }) next.set(cn, { status: "working" });
} }
return next return next;
}) });
try { try {
const res = await fetch('/api/admin/import/repair', { const res = await fetch("/api/admin/import/repair", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classnames, concurrency: 6 }), body: JSON.stringify({ classnames, concurrency: 6 }),
}) });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
toast.error('Failed to start repair') toast.error("Failed to start repair");
setRepairing(false) setRepairing(false);
return return;
} }
const reader = res.body.getReader() const reader = res.body.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
let buffer = '' let buffer = "";
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read();
if (done) break if (done) break;
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n') const lines = buffer.split("\n");
buffer = lines.pop() || '' buffer = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data: ')) continue if (!line.startsWith("data: ")) continue;
try { try {
const event = JSON.parse(line.slice(6)) const event = JSON.parse(line.slice(6));
if (event.type === 'repair_progress') { if (event.type === "repair_progress") {
setProgress((prev) => { setProgress((prev) => {
const next = new Map(prev) const next = new Map(prev);
next.set(event.classname, { next.set(event.classname, {
status: event.status as ItemStatus, status: event.status as ItemStatus,
message: event.error || (event.warnings?.[0] ?? undefined), message: event.error || (event.warnings?.[0] ?? undefined),
nitroRepaired: event.nitroRepaired, nitroRepaired: event.nitroRepaired,
iconRepaired: event.iconRepaired, iconRepaired: event.iconRepaired,
unrepairable: event.unrepairable, unrepairable: event.unrepairable,
}) });
return next return next;
}) });
// Promote newly-discovered unrepairable flags onto the audit list // Promote newly-discovered unrepairable flags onto the audit list
if (event.unrepairable) { if (event.unrepairable) {
setSummary((prev) => { setSummary((prev) => {
if (!prev) return prev if (!prev) return prev;
const item = prev.items.find((i) => i.classname === event.classname) const item = prev.items.find((i) => i.classname === event.classname);
if (!item || item.unrepairable) return prev if (!item || item.unrepairable) return prev;
return { return {
...prev, ...prev,
unrepairable: prev.unrepairable + 1, unrepairable: prev.unrepairable + 1,
items: prev.items.map((i) => items: prev.items.map((i) =>
i.classname === event.classname ? { ...i, unrepairable: true } : i, i.classname === event.classname ? { ...i, unrepairable: true } : i,
), ),
} };
}) });
} }
// Remove repaired items from the audit list on "done" // Remove repaired items from the audit list on "done"
if (event.status === 'done') { if (event.status === "done") {
setSummary((prev) => { setSummary((prev) => {
if (!prev) return prev if (!prev) return prev;
const items = prev.items.filter((i) => i.classname !== event.classname) const items = prev.items.filter((i) => i.classname !== event.classname);
const diff = prev.items.length - items.length const diff = prev.items.length - items.length;
if (diff === 0) return prev if (diff === 0) return prev;
const removed = prev.items.find((i) => i.classname === event.classname) const removed = prev.items.find((i) => i.classname === event.classname);
return { return {
...prev, ...prev,
items, items,
missingBoth: prev.missingBoth - (removed?.missing === 'both' ? 1 : 0), missingBoth: prev.missingBoth - (removed?.missing === "both" ? 1 : 0),
missingNitro: prev.missingNitro - (removed?.missing === 'nitro' ? 1 : 0), missingNitro: prev.missingNitro - (removed?.missing === "nitro" ? 1 : 0),
missingIcon: prev.missingIcon - (removed?.missing === 'icon' ? 1 : 0), missingIcon: prev.missingIcon - (removed?.missing === "icon" ? 1 : 0),
} };
}) });
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev);
next.delete(event.classname) next.delete(event.classname);
return next return next;
}) });
} }
} else if (event.type === 'repair_complete') { } else if (event.type === "repair_complete") {
setLastSummary({ setLastSummary({
succeeded: event.succeeded, succeeded: event.succeeded,
partial: event.partial, partial: event.partial,
failed: event.failed, failed: event.failed,
unrepairable: event.unrepairable ?? 0, unrepairable: event.unrepairable ?? 0,
duration: event.duration, duration: event.duration,
}) });
} }
} catch { } catch {
/* skip malformed */ /* skip malformed */
@@ -270,72 +263,72 @@ export function RepairClient() {
} }
} }
} catch { } catch {
toast.error('Connection lost during repair') toast.error("Connection lost during repair");
} finally { } finally {
setRepairing(false) setRepairing(false);
} }
}, []) }, []);
// ── Delete unrepairable items ────────────────────────────────────── // ── Delete unrepairable items ──────────────────────────────────────
const deleteSelected = useCallback(async () => { const deleteSelected = useCallback(async () => {
const toDelete = Array.from(selected) const toDelete = Array.from(selected);
if (toDelete.length === 0) return if (toDelete.length === 0) return;
setConfirmDeleteAll(false) setConfirmDeleteAll(false);
let ok = 0 let ok = 0;
let fail = 0 let fail = 0;
for (const cn of toDelete) { for (const cn of toDelete) {
setProgress((prev) => { setProgress((prev) => {
const next = new Map(prev) const next = new Map(prev);
next.set(cn, { status: 'working', message: 'Deleting…' }) next.set(cn, { status: "working", message: "Deleting…" });
return next return next;
}) });
try { try {
const result = await deleteImportedFurni({ classname: cn }) const result = await deleteImportedFurni({ classname: cn });
if (result.ok) { if (result.ok) {
ok++ ok++;
setSummary((prev) => { setSummary((prev) => {
if (!prev) return prev if (!prev) return prev;
const removed = prev.items.find((i) => i.classname === cn) const removed = prev.items.find((i) => i.classname === cn);
if (!removed) return prev if (!removed) return prev;
return { return {
...prev, ...prev,
total: prev.total - 1, total: prev.total - 1,
items: prev.items.filter((i) => i.classname !== cn), items: prev.items.filter((i) => i.classname !== cn),
missingBoth: prev.missingBoth - (removed.missing === 'both' ? 1 : 0), missingBoth: prev.missingBoth - (removed.missing === "both" ? 1 : 0),
missingNitro: prev.missingNitro - (removed.missing === 'nitro' ? 1 : 0), missingNitro: prev.missingNitro - (removed.missing === "nitro" ? 1 : 0),
missingIcon: prev.missingIcon - (removed.missing === 'icon' ? 1 : 0), missingIcon: prev.missingIcon - (removed.missing === "icon" ? 1 : 0),
} };
}) });
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev);
next.delete(cn) next.delete(cn);
return next return next;
}) });
} else { } else {
fail++ fail++;
setProgress((prev) => { setProgress((prev) => {
const next = new Map(prev) const next = new Map(prev);
next.set(cn, { status: 'failed', message: 'Delete failed' }) next.set(cn, { status: "failed", message: "Delete failed" });
return next return next;
}) });
} }
} catch { } catch {
fail++ fail++;
setProgress((prev) => { setProgress((prev) => {
const next = new Map(prev) const next = new Map(prev);
next.set(cn, { status: 'failed', message: 'Delete error' }) next.set(cn, { status: "failed", message: "Delete error" });
return next return next;
}) });
} }
} }
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ''}`) toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`);
}, [selected]) }, [selected]);
// ── Render ───────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────
const selectedArray = Array.from(selected) const selectedArray = Array.from(selected);
const selectedCount = selectedArray.length const selectedCount = selectedArray.length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -344,9 +337,8 @@ export function RepairClient() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1> <h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and icons for
icons for items already imported. Items with no public asset on habbo.com can be deleted items already imported. Items with no public asset on habbo.com can be deleted from here.
from here.
</p> </p>
</div> </div>
<div className="flex gap-2"> <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> </Button>
@@ -368,12 +360,10 @@ export function RepairClient() {
{!summary && !auditing && ( {!summary && !auditing && (
<div className="rounded-md border bg-muted/30 p-10 text-center"> <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" /> <Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="font-medium"> <p className="font-medium">Click &ldquo;Scan Assets&rdquo; to audit imported furniture.</p>
Click &ldquo;Scan Assets&rdquo; to audit imported furniture.
</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k This checks every row in <code>items_base</code> against files on disk. Scanning ~33k items takes
items takes a few seconds. a few seconds.
</p> </p>
</div> </div>
)} )}
@@ -388,12 +378,7 @@ export function RepairClient() {
tone="danger" tone="danger"
sub="no .nitro · no icon" sub="no .nitro · no icon"
/> />
<StatCard <StatCard label="Missing .nitro" value={summary.missingNitro} tone="warn" sub="shows black cube" />
label="Missing .nitro"
value={summary.missingNitro}
tone="warn"
sub="shows black cube"
/>
<StatCard <StatCard
label="Missing icon" label="Missing icon"
value={summary.missingIcon} value={summary.missingIcon}
@@ -437,9 +422,7 @@ export function RepairClient() {
<div className="rounded-md border bg-muted/30 p-10 text-center"> <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)]" /> <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="font-medium">All imported items have their assets on disk.</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{summary.total} items scanned, nothing to repair.</p>
{summary.total} items scanned, nothing to repair.
</p>
</div> </div>
)} )}
@@ -461,16 +444,15 @@ export function RepairClient() {
variant="default" variant="default"
onClick={() => { onClick={() => {
// Skip unrepairable items — no point paying the network cost for them // Skip unrepairable items — no point paying the network cost for them
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname) const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname);
const unrep = const unrep = summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? [];
summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? [] const unrepSet = new Set(unrep);
const unrepSet = new Set(unrep) const targets = pool.filter((cn) => !unrepSet.has(cn));
const targets = pool.filter((cn) => !unrepSet.has(cn))
if (targets.length === 0) { if (targets.length === 0) {
toast.info('Nothing to repair — selected items are all flagged unrepairable.') toast.info("Nothing to repair — selected items are all flagged unrepairable.");
return return;
} }
runRepair(targets) runRepair(targets);
}} }}
disabled={repairing || filtered.length === 0} disabled={repairing || filtered.length === 0}
> >
@@ -502,9 +484,9 @@ export function RepairClient() {
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
// Auto-select every unrepairable item, then open the same confirm dialog // Auto-select every unrepairable item, then open the same confirm dialog
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname) const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname);
setSelected(new Set(unrep)) setSelected(new Set(unrep));
setConfirmDeleteAll(true) setConfirmDeleteAll(true);
}} }}
disabled={repairing} disabled={repairing}
> >
@@ -529,11 +511,7 @@ export function RepairClient() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[40px]"> <TableHead className="w-[40px]">
<Checkbox <Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead> </TableHead>
<TableHead className="w-[80px]">ID</TableHead> <TableHead className="w-[80px]">ID</TableHead>
<TableHead>Classname</TableHead> <TableHead>Classname</TableHead>
@@ -576,13 +554,12 @@ export function RepairClient() {
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Delete {selectedCount} imported item{selectedCount === 1 ? '' : 's'}? Delete {selectedCount} imported item{selectedCount === 1 ? "" : "s"}?
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the entry in{" "}
entry in <code>FurnitureData.json</code>, and any remaining asset files on disk. Use <code>FurnitureData.json</code>, and any remaining asset files on disk. Use this for items that
this for items that have no public asset on habbo.com and therefore cannot be have no public asset on habbo.com and therefore cannot be repaired. This cannot be undone.
repaired. This cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -596,7 +573,7 @@ export function RepairClient() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }
// ── Small cell helpers ──────────────────────────────────────────────── // ── Small cell helpers ────────────────────────────────────────────────
@@ -607,24 +584,24 @@ function StatCard({
tone, tone,
sub, sub,
}: { }: {
label: string label: string;
value: number value: number;
tone: 'default' | 'danger' | 'warn' | 'info' tone: "default" | "danger" | "warn" | "info";
sub?: string sub?: string;
}) { }) {
const tones = { const tones = {
default: 'border-border', default: "border-border",
danger: 'border-destructive/40 bg-destructive/5', danger: "border-destructive/40 bg-destructive/5",
warn: 'border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]', warn: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
info: 'border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5', info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5",
} };
return ( return (
<div className={`rounded-md border p-4 ${tones[tone]}`}> <div className={`rounded-md border p-4 ${tones[tone]}`}>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div> <div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</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>} {sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</div> </div>
) );
} }
function FileBadge({ present }: { present: boolean }) { 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"> <Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
<XCircle className="mr-1 h-3 w-3" /> Missing <XCircle className="mr-1 h-3 w-3" /> Missing
</Badge> </Badge>
) );
} }
function StatusCell({ progress }: { progress?: ItemProgress }) { function StatusCell({ progress }: { progress?: ItemProgress }) {
if (!progress || progress.status === 'idle') if (!progress || progress.status === "idle")
return <span className="text-muted-foreground text-xs">—</span> return <span className="text-muted-foreground text-xs">—</span>;
if (progress.status === 'working') { if (progress.status === "working") {
return ( return (
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
{progress.message || 'Working…'} {progress.message || "Working…"}
</span> </span>
) );
} }
if (progress.status === 'done') { if (progress.status === "done") {
const parts = [ const parts = [
progress.nitroRepaired ? '.nitro ✓' : null, progress.nitroRepaired ? ".nitro ✓" : null,
progress.iconRepaired ? 'icon ✓' : null, progress.iconRepaired ? "icon ✓" : null,
].filter(Boolean) ].filter(Boolean);
return ( return (
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]"> <span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
<CheckCircle2 className="h-3 w-3" /> <CheckCircle2 className="h-3 w-3" />
{parts.length > 0 ? parts.join(' · ') : 'Repaired'} {parts.length > 0 ? parts.join(" · ") : "Repaired"}
</span> </span>
) );
} }
if (progress.status === 'partial') { if (progress.status === "partial") {
return ( return (
<span <span
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]" className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
title={progress.message} title={progress.message}
> >
<AlertCircle className="h-3 w-3" /> <AlertCircle className="h-3 w-3" />
Partial · {progress.message || 'check logs'} Partial · {progress.message || "check logs"}
</span> </span>
) );
} }
return ( return (
<span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}> <span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}>
<XCircle className="h-3 w-3" /> <XCircle className="h-3 w-3" />
{progress.message || 'Failed'} {progress.message || "Failed"}
</span> </span>
) );
} }
function ItemRow({ function ItemRow({
@@ -692,39 +669,32 @@ function ItemRow({
onRepair, onRepair,
disabled, disabled,
}: { }: {
item: MissingItem item: MissingItem;
selected: boolean selected: boolean;
onToggle: () => void onToggle: () => void;
progress?: ItemProgress progress?: ItemProgress;
onRepair: () => void onRepair: () => void;
disabled: boolean disabled: boolean;
}) { }) {
const isUnrepairable = item.unrepairable || progress?.unrepairable const isUnrepairable = item.unrepairable || progress?.unrepairable;
return ( return (
<TableRow className={isUnrepairable ? 'bg-destructive/5' : undefined}> <TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
<TableCell> <TableCell>
<Checkbox <Checkbox checked={selected} onCheckedChange={onToggle} aria-label={`Select ${item.classname}`} />
checked={selected}
onCheckedChange={onToggle}
aria-label={`Select ${item.classname}`}
/>
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell> <TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm">{item.classname}</span> <span className="font-mono text-sm">{item.classname}</span>
{isUnrepairable && ( {isUnrepairable && (
<Badge <Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
variant="secondary"
className="border-destructive/40 bg-destructive/10 text-destructive"
>
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable <AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
</Badge> </Badge>
)} )}
</div> </div>
<div className="text-xs text-muted-foreground">{item.publicName}</div> <div className="text-xs text-muted-foreground">{item.publicName}</div>
</TableCell> </TableCell>
<TableCell className="text-xs">{item.revision || '—'}</TableCell> <TableCell className="text-xs">{item.revision || "—"}</TableCell>
<TableCell> <TableCell>
<FileBadge present={item.hasNitro} /> <FileBadge present={item.hasNitro} />
</TableCell> </TableCell>
@@ -742,10 +712,10 @@ function ItemRow({
size="sm" size="sm"
variant="outline" variant="outline"
onClick={onRepair} onClick={onRepair}
disabled={disabled || progress?.status === 'working' || isUnrepairable} disabled={disabled || progress?.status === "working" || isUnrepairable}
title={isUnrepairable ? 'habbo.com does not have this asset' : 'Retry download + convert'} 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" /> <Loader2 className="h-3 w-3 animate-spin" />
) : ( ) : (
<Wrench className="h-3 w-3" /> <Wrench className="h-3 w-3" />
@@ -753,5 +723,5 @@ function ItemRow({
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
) );
} }
+3 -1
View File
@@ -188,7 +188,9 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
{initial} {initial}
</span> </span>
<div className="min-w-0 flex-1"> <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)]"> <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 })} {t("rankLabel", { rank: staff.rank })}
</span> </span>
+46 -46
View File
@@ -1,39 +1,39 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { DataTable } from '@/components/admin/data-table' import { DataTable } from "@/components/admin/data-table";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from '@/types' import type { DataTableColumn, PaginatedResult } from "@/types";
interface AuditRow { interface AuditRow {
id: number id: number;
username: string username: string;
action: string action: string;
target: string target: string;
targetId: number | null targetId: number | null;
diff: string | null diff: string | null;
createdAt: string createdAt: string;
} }
function DiffCell({ diff }: { diff: string | null }) { function DiffCell({ diff }: { diff: string | null }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
if (!diff) return <span className="text-muted-foreground">-</span> 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 { try {
parsed = JSON.parse(diff) parsed = JSON.parse(diff);
} catch { } catch {
return <span className="text-muted-foreground">-</span> return <span className="text-muted-foreground">-</span>;
} }
const keys = Object.keys(parsed) const keys = Object.keys(parsed);
if (keys.length === 0) return <span className="text-muted-foreground">-</span> if (keys.length === 0) return <span className="text-muted-foreground">-</span>;
const summary = const summary =
keys.length === 1 keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? '∅')} → ${String(parsed[keys[0]].to ?? '∅')}` ? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: `${keys.length} changes` : `${keys.length} changes`;
return ( return (
<div> <div>
@@ -50,53 +50,53 @@ function DiffCell({ diff }: { diff: string | null }) {
{keys.map((k) => ( {keys.map((k) => (
<div key={k} className="flex gap-1"> <div key={k} className="flex gap-1">
<span className="font-medium">{k}:</span> <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>→</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> </div>
)} )}
</div> </div>
) );
} }
const columns: DataTableColumn<AuditRow>[] = [ const columns: DataTableColumn<AuditRow>[] = [
{ key: 'id', label: 'ID', sortable: true }, { key: "id", label: "ID", sortable: true },
{ key: 'username', label: 'User', sortable: true }, { key: "username", label: "User", sortable: true },
{ {
key: 'action', key: "action",
label: 'Action', label: "Action",
sortable: true, sortable: true,
render: (value) => { render: (value) => {
const v = String(value) const v = String(value);
let variant: 'default' | 'secondary' | 'destructive' = 'secondary' let variant: "default" | "secondary" | "destructive" = "secondary";
if (v.includes('create') || v.includes('add')) variant = 'default' if (v.includes("create") || v.includes("add")) variant = "default";
if (v.includes('delete') || v.includes('remove') || v.includes('ban')) variant = 'destructive' if (v.includes("delete") || v.includes("remove") || v.includes("ban")) variant = "destructive";
return <Badge variant={variant}>{v}</Badge> return <Badge variant={variant}>{v}</Badge>;
}, },
}, },
{ key: 'target', label: 'Target', sortable: true }, { key: "target", label: "Target", sortable: true },
{ {
key: 'targetId', key: "targetId",
label: 'Target ID', label: "Target ID",
render: (value) => <span>{value != null ? String(value) : '-'}</span>, render: (value) => <span>{value != null ? String(value) : "-"}</span>,
}, },
{ {
key: 'diff', key: "diff",
label: 'Details', label: "Details",
render: (_value, row) => <DiffCell diff={row.diff} />, render: (_value, row) => <DiffCell diff={row.diff} />,
}, },
{ {
key: 'createdAt', key: "createdAt",
label: 'Date', label: "Date",
sortable: true, sortable: true,
render: (value) => <span>{String(value) || 'N/A'}</span>, render: (value) => <span>{String(value) || "N/A"}</span>,
}, },
] ];
interface AuditTableProps { interface AuditTableProps {
data: PaginatedResult<AuditRow> data: PaginatedResult<AuditRow>;
} }
export function AuditTable({ data }: AuditTableProps) { export function AuditTable({ data }: AuditTableProps) {
@@ -107,5 +107,5 @@ export function AuditTable({ data }: AuditTableProps) {
searchPlaceholder="Search by action or target..." searchPlaceholder="Search by action or target..."
exportUrl="/api/admin/export?type=audit" 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() { export default function AuditLogsLoading() {
return ( return (
@@ -24,5 +24,5 @@ export default function AuditLogsLoading() {
))} ))}
</div> </div>
</div> </div>
) );
} }
+17 -17
View File
@@ -1,29 +1,29 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from '@/lib/admin-helpers' import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getAuditLogs } from '@/lib/services/audit' import { getAuditLogs } from "@/lib/services/audit";
import { AuditTable } from './audit-table' import { AuditTable } from "./audit-table";
export default async function AuditLogsPage({ export default async function AuditLogsPage({
params: paramsPromise, params: paramsPromise,
searchParams, searchParams,
}: { }: {
params: Promise<{ locale: string }> params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>> searchParams: Promise<Record<string, string>>;
}) { }) {
await paramsPromise await paramsPromise;
const { session, permissions } = await getAdminContext() const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect('/admin') redirect("/admin");
} }
const params = await searchParams const params = await searchParams;
const sp = new URLSearchParams(params) const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp) 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) => ({ const rows = result.rows.map((r) => ({
id: r.id, id: r.id,
@@ -33,7 +33,7 @@ export default async function AuditLogsPage({
targetId: r.targetId, targetId: r.targetId,
diff: r.diff, diff: r.diff,
createdAt: r.createdAt, createdAt: r.createdAt,
})) }));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -44,5 +44,5 @@ export default async function AuditLogsPage({
<AuditTable data={{ rows, ...pagination }} /> <AuditTable data={{ rows, ...pagination }} />
</div> </div>
) );
} }
+19 -19
View File
@@ -1,36 +1,36 @@
'use client' "use client";
import { DataTable } from '@/components/admin/data-table' import { DataTable } from "@/components/admin/data-table";
import { formatTimestamp } from '@/lib/admin-helpers' import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from '@/types' import type { DataTableColumn, PaginatedResult } from "@/types";
interface ChatLogRow { interface ChatLogRow {
id: number id: number;
username: string username: string;
message: string message: string;
timestamp: number timestamp: number;
roomId: number roomId: number;
} }
const columns: DataTableColumn<ChatLogRow>[] = [ const columns: DataTableColumn<ChatLogRow>[] = [
{ key: 'id', label: 'ID', sortable: true }, { key: "id", label: "ID", sortable: true },
{ key: 'username', label: 'User' }, { key: "username", label: "User" },
{ {
key: 'message', key: "message",
label: 'Message', label: "Message",
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>, render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
}, },
{ {
key: 'timestamp', key: "timestamp",
label: 'Time', label: "Time",
sortable: true, sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>, render: (value) => <span>{formatTimestamp(Number(value))}</span>,
}, },
{ key: 'roomId', label: 'Room ID', sortable: true }, { key: "roomId", label: "Room ID", sortable: true },
] ];
interface ChatTableProps { interface ChatTableProps {
data: PaginatedResult<ChatLogRow> data: PaginatedResult<ChatLogRow>;
} }
export function ChatTable({ data }: ChatTableProps) { export function ChatTable({ data }: ChatTableProps) {
@@ -41,5 +41,5 @@ export function ChatTable({ data }: ChatTableProps) {
searchPlaceholder="Search by username or message..." searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs" 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() { export default function ChatLogsLoading() {
return ( return (
@@ -24,5 +24,5 @@ export default function ChatLogsLoading() {
))} ))}
</div> </div>
</div> </div>
) );
} }
+9 -9
View File
@@ -1,19 +1,19 @@
import { fetchAdminList } from '@/lib/admin-list' import { fetchAdminList } from "@/lib/admin-list";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { ChatTable } from './chat-table' import { ChatTable } from "./chat-table";
export default async function ChatLogsPage({ export default async function ChatLogsPage({
params: paramsPromise, params: paramsPromise,
searchParams, searchParams,
}: { }: {
params: Promise<{ locale: string }> params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>> searchParams: Promise<Record<string, string>>;
}) { }) {
const result = await fetchAdminList( const result = await fetchAdminList(
{ {
permission: PERMS.LOGS_VIEW, permission: PERMS.LOGS_VIEW,
model: 'chatLog', model: "chatLog",
searchFields: ['message', 'user.username'], searchFields: ["message", "user.username"],
defaultPerPage: 50, defaultPerPage: 50,
include: { user: { select: { username: true } } }, include: { user: { select: { username: true } } },
mapRow: (l) => ({ mapRow: (l) => ({
@@ -26,7 +26,7 @@ export default async function ChatLogsPage({
}, },
paramsPromise, paramsPromise,
searchParams, searchParams,
) );
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -45,5 +45,5 @@ export default async function ChatLogsPage({
}} }}
/> />
</div> </div>
) );
} }
+20 -20
View File
@@ -1,38 +1,38 @@
'use client' "use client";
import { DataTable } from '@/components/admin/data-table' import { DataTable } from "@/components/admin/data-table";
import { formatTimestamp } from '@/lib/admin-helpers' import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from '@/types' import type { DataTableColumn, PaginatedResult } from "@/types";
interface CommandLogRow { interface CommandLogRow {
id: number id: number;
username: string username: string;
command: string command: string;
params: string params: string;
timestamp: number timestamp: number;
} }
const columns: DataTableColumn<CommandLogRow>[] = [ const columns: DataTableColumn<CommandLogRow>[] = [
{ key: 'id', label: 'ID', sortable: true }, { key: "id", label: "ID", sortable: true },
{ key: 'username', label: 'User' }, { key: "username", label: "User" },
{ key: 'command', label: 'Command', sortable: true }, { key: "command", label: "Command", sortable: true },
{ {
key: 'params', key: "params",
label: 'Params', label: "Params",
render: (value) => ( 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', key: "timestamp",
label: 'Time', label: "Time",
sortable: true, sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>, render: (value) => <span>{formatTimestamp(Number(value))}</span>,
}, },
] ];
interface CommandsTableProps { interface CommandsTableProps {
data: PaginatedResult<CommandLogRow> data: PaginatedResult<CommandLogRow>;
} }
export function CommandsTable({ data }: CommandsTableProps) { export function CommandsTable({ data }: CommandsTableProps) {
@@ -43,5 +43,5 @@ export function CommandsTable({ data }: CommandsTableProps) {
searchPlaceholder="Search by username, command, or params..." searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands" 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() { export default function CommandLogsLoading() {
return ( return (
@@ -24,5 +24,5 @@ export default function CommandLogsLoading() {
))} ))}
</div> </div>
</div> </div>
) );
} }
+9 -9
View File
@@ -1,19 +1,19 @@
import { fetchAdminList } from '@/lib/admin-list' import { fetchAdminList } from "@/lib/admin-list";
import { PERMS } from '@/lib/permissions' import { PERMS } from "@/lib/permissions";
import { CommandsTable } from './commands-table' import { CommandsTable } from "./commands-table";
export default async function CommandLogsPage({ export default async function CommandLogsPage({
params: paramsPromise, params: paramsPromise,
searchParams, searchParams,
}: { }: {
params: Promise<{ locale: string }> params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>> searchParams: Promise<Record<string, string>>;
}) { }) {
const result = await fetchAdminList( const result = await fetchAdminList(
{ {
permission: PERMS.LOGS_VIEW, permission: PERMS.LOGS_VIEW,
model: 'commandLog', model: "commandLog",
searchFields: ['command', 'params', 'user.username'], searchFields: ["command", "params", "user.username"],
defaultPerPage: 50, defaultPerPage: 50,
include: { user: { select: { username: true } } }, include: { user: { select: { username: true } } },
mapRow: (l) => ({ mapRow: (l) => ({
@@ -26,7 +26,7 @@ export default async function CommandLogsPage({
}, },
paramsPromise, paramsPromise,
searchParams, searchParams,
) );
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -45,5 +45,5 @@ export default async function CommandLogsPage({
}} }}
/> />
</div> </div>
) );
} }
+7 -1
View File
@@ -15,7 +15,13 @@ function fromDate(d: Date | null | undefined): string {
export default async function AdminStaffActivities({ export default async function AdminStaffActivities({
searchParams, 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"); 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