revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s
Local Build and Deploy / deploy (push) Successful in 1m11s
revert style: clean up code with prettier and eslint
This commit is contained in:
1 parent
27de078f54
commit
e85e4d74ea
378 files changed
+20710
-22428
No files matched your search
@@ -19,13 +19,7 @@ export async function updateBadge({ code, name, desc }: { code: string; name: st
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: { badgeKey: code, badgeName: name, badgeDescription: desc, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
revalidatePath("/admin/import/badges");
|
||||
}
|
||||
+34
-34
@@ -1,70 +1,70 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
|
||||
const bannerSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
subtitle: z.string().max(500).optional().default(""),
|
||||
subtitle: z.string().max(500).optional().default(''),
|
||||
image: z.string().max(500),
|
||||
link: z.string().max(500).optional().default(""),
|
||||
color: z.string().max(20).optional().default(""),
|
||||
link: z.string().max(500).optional().default(''),
|
||||
color: z.string().max(20).optional().default(''),
|
||||
isActive: z.coerce.number().int().min(0).max(1).default(1),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
startDate: z.string().max(50).nullable().optional(),
|
||||
endDate: z.string().max(50).nullable().optional(),
|
||||
});
|
||||
})
|
||||
|
||||
export const createBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||
async (ctx) => {
|
||||
const banner = await prisma.websiteBanner.create({ data: ctx.data });
|
||||
const banner = await prisma.websiteBanner.create({ data: ctx.data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_create",
|
||||
target: "WebsiteBanner",
|
||||
action: 'banner_create',
|
||||
target: 'WebsiteBanner',
|
||||
targetId: banner.id,
|
||||
after: { title: banner.title },
|
||||
});
|
||||
return actionOk({ id: banner.id });
|
||||
})
|
||||
return actionOk({ id: banner.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() })
|
||||
|
||||
export const updateBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteBanner.findUnique({ where: { id } });
|
||||
if (!existing) throw new ActionError("Banner not found");
|
||||
await prisma.websiteBanner.update({ where: { id }, data });
|
||||
const { id, ...data } = ctx.data
|
||||
const existing = await prisma.websiteBanner.findUnique({ where: { id } })
|
||||
if (!existing) throw new ActionError('Banner not found')
|
||||
await prisma.websiteBanner.update({ where: { id }, data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_update",
|
||||
target: "WebsiteBanner",
|
||||
action: 'banner_update',
|
||||
target: 'WebsiteBanner',
|
||||
targetId: id,
|
||||
});
|
||||
return actionOk({ id });
|
||||
})
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() })
|
||||
|
||||
export const deleteBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
|
||||
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_delete",
|
||||
target: "WebsiteBanner",
|
||||
action: 'banner_delete',
|
||||
target: 'WebsiteBanner',
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
return actionOk();
|
||||
})
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
@@ -7,11 +7,7 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export async function bulkUnban({
|
||||
userIds,
|
||||
}: {
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
const staff = await requireStaff();
|
||||
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await logStaffActivity({
|
||||
@@ -23,15 +19,7 @@ export async function bulkUnban({
|
||||
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
|
||||
}
|
||||
|
||||
export async function bulkBan({
|
||||
userIds,
|
||||
reason,
|
||||
duration,
|
||||
}: {
|
||||
userIds: number[];
|
||||
reason: string;
|
||||
duration: number;
|
||||
}): Promise<ActionResult<{ banned: number }>> {
|
||||
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }): Promise<ActionResult<{ banned: number }>> {
|
||||
const staff = await requireStaff();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let banned = 0;
|
||||
@@ -65,17 +53,7 @@ export async function bulkBan({
|
||||
return { ok: true as const, data: { banned } };
|
||||
}
|
||||
|
||||
export async function bulkGiveCurrency({
|
||||
userIds,
|
||||
amount,
|
||||
type,
|
||||
}: {
|
||||
userIds: number[];
|
||||
amount: number;
|
||||
type: "credits" | "pixels" | "points";
|
||||
}): Promise<
|
||||
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
|
||||
> {
|
||||
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
|
||||
const staff = await requireStaff();
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
@@ -115,15 +93,7 @@ export async function bulkGiveCurrency({
|
||||
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
|
||||
}
|
||||
|
||||
export async function bulkGiveBadge({
|
||||
userIds,
|
||||
badgeCode,
|
||||
}: {
|
||||
userIds: number[];
|
||||
badgeCode: string;
|
||||
}): Promise<
|
||||
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
|
||||
> {
|
||||
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
|
||||
const staff = await requireStaff();
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
@@ -33,16 +33,7 @@ export async function deleteBcItem({ id }: { id: number }) {
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function updateBcItem({
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: number;
|
||||
itemIds?: string;
|
||||
catalogName?: string;
|
||||
orderNumber?: number;
|
||||
extradata?: string;
|
||||
}) {
|
||||
export async function updateBcItem({ id, ...data }: { id: number; itemIds?: string; catalogName?: string; orderNumber?: number; extradata?: string }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
@@ -55,16 +46,7 @@ export async function updateBcItem({
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function createBcItem({
|
||||
pageId,
|
||||
...data
|
||||
}: {
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
orderNumber: number;
|
||||
extradata: string;
|
||||
}) {
|
||||
export async function createBcItem({ pageId, ...data }: { pageId: number; itemIds: string; catalogName: string; orderNumber: number; extradata: string }) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogItemsBc.create({
|
||||
data: { pageId, ...data },
|
||||
@@ -81,10 +63,7 @@ export async function createBcItem({
|
||||
|
||||
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPagesBc.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
const page = await prisma.catalogPagesBc.findUnique({ where: { id }, select: { enabled: true, visible: true } });
|
||||
if (!page) return;
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
|
||||
@@ -62,11 +62,7 @@ export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; t
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function reorderCatalogItems({
|
||||
orders,
|
||||
}: {
|
||||
orders: Array<{ id: number; orderNumber: number }>;
|
||||
}) {
|
||||
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
|
||||
await requireStaff();
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
||||
@@ -76,15 +72,7 @@ export async function reorderCatalogItems({
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function updateCatalogItem({
|
||||
id,
|
||||
catalogFields,
|
||||
baseItem,
|
||||
}: {
|
||||
id: number;
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
}) {
|
||||
export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: number; catalogFields: Record<string, unknown>; baseItem?: { id: number; fields: Record<string, unknown> } }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
|
||||
if (baseItem) {
|
||||
@@ -102,22 +90,15 @@ export async function updateCatalogItem({
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function translateCatalogItems({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ id: number; publicName: string; description: string }>;
|
||||
}) {
|
||||
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
|
||||
await requireStaff();
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
let furniDataUpdated = 0;
|
||||
const furniDataInserted = 0;
|
||||
let furniDataInserted = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const existing = await prisma.catalogItems.findUnique({
|
||||
where: { id: item.id },
|
||||
select: { catalogName: true },
|
||||
});
|
||||
const existing = await prisma.catalogItems.findUnique({ where: { id: item.id }, select: { catalogName: true } });
|
||||
if (!existing) continue;
|
||||
|
||||
if (item.publicName && item.publicName !== existing.catalogName) {
|
||||
@@ -146,14 +127,5 @@ export async function translateCatalogItems({
|
||||
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated,
|
||||
furniDataInserted: 0,
|
||||
updated: items.length,
|
||||
},
|
||||
};
|
||||
return { ok: true as const, data: { namesUpdated, descriptionsUpdated, furniDataUpdated, furniDataInserted: 0, updated: items.length } };
|
||||
}
|
||||
+9
-27
@@ -7,10 +7,7 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
@@ -40,18 +37,9 @@ export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function toggleCatalogPage({
|
||||
id,
|
||||
action,
|
||||
}: {
|
||||
id: number;
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
export async function toggleCatalogPage({ id, action }: { id: number; action: "toggleEnabled" | "toggleVisible" }) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true } });
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
@@ -63,17 +51,7 @@ export async function toggleCatalogPage({
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function createCatalogPage(input: {
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout?: string;
|
||||
iconImage?: number;
|
||||
iconColor?: number;
|
||||
enabled?: "0" | "1";
|
||||
visible?: "0" | "1";
|
||||
minRank?: number;
|
||||
orderNum?: number;
|
||||
}): Promise<ActionResult<{ id: number }>> {
|
||||
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout?: string; iconImage?: number; iconColor?: number; enabled?: "0" | "1"; visible?: "0" | "1"; minRank?: number; orderNum?: number }): Promise<ActionResult<{ id: number }>> {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
@@ -105,7 +83,11 @@ export async function createCatalogPage(input: {
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
}
|
||||
|
||||
export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) {
|
||||
export async function reorderTreePage(input: {
|
||||
pageId: number;
|
||||
newParentId?: number;
|
||||
newOrderNum: number;
|
||||
}) {
|
||||
await requireStaff();
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: input.pageId },
|
||||
|
||||
+17
-17
@@ -1,39 +1,39 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import { rcon } from '@/lib/services/rcon'
|
||||
|
||||
const saveEmulatorSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
})
|
||||
|
||||
export const saveEmulatorSettings = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||
async (ctx) => {
|
||||
const entries = Object.entries(ctx.data.settings);
|
||||
const entries = Object.entries(ctx.data.settings)
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
await rcon.updateConfig();
|
||||
await rcon.updateConfig()
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "emulator_settings_update",
|
||||
target: "EmulatorSettings",
|
||||
action: 'emulator_settings_update',
|
||||
target: 'EmulatorSettings',
|
||||
after: ctx.data.settings,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+71
-71
@@ -1,18 +1,18 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import {
|
||||
createEventSchema,
|
||||
eventPrizeSchema,
|
||||
eventTypeSchema,
|
||||
eventWinnerSchema,
|
||||
updateEventSchema,
|
||||
} from "@/lib/validators/event";
|
||||
} from '@/lib/validators/event'
|
||||
|
||||
// ── Event Types ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,63 +21,63 @@ export const createEventType = adminAction(
|
||||
async (ctx) => {
|
||||
const eventType = await prisma.websiteEventType.create({
|
||||
data: ctx.data,
|
||||
});
|
||||
})
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_create",
|
||||
target: "WebsiteEventType",
|
||||
action: 'event_type_create',
|
||||
target: 'WebsiteEventType',
|
||||
targetId: eventType.id,
|
||||
after: { name: eventType.name },
|
||||
});
|
||||
return actionOk({ id: eventType.id });
|
||||
})
|
||||
return actionOk({ id: eventType.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updateEventTypeInput = eventTypeSchema.partial().extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const updateEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteEventType.findUnique({ where: { id } });
|
||||
if (!existing) throw new ActionError("Event type not found");
|
||||
const { id, ...data } = ctx.data
|
||||
const existing = await prisma.websiteEventType.findUnique({ where: { id } })
|
||||
if (!existing) throw new ActionError('Event type not found')
|
||||
|
||||
await prisma.websiteEventType.update({ where: { id }, data });
|
||||
await prisma.websiteEventType.update({ where: { id }, data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_update",
|
||||
target: "WebsiteEventType",
|
||||
action: 'event_type_update',
|
||||
target: 'WebsiteEventType',
|
||||
targetId: id,
|
||||
before: { name: existing.name },
|
||||
after: data,
|
||||
});
|
||||
return actionOk({ id });
|
||||
})
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deleteEventTypeInput = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const deleteEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||
async (ctx) => {
|
||||
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } });
|
||||
if (!existing) throw new ActionError("Event type not found");
|
||||
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } })
|
||||
if (!existing) throw new ActionError('Event type not found')
|
||||
|
||||
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
|
||||
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_delete",
|
||||
target: "WebsiteEventType",
|
||||
action: 'event_type_delete',
|
||||
target: 'WebsiteEventType',
|
||||
targetId: ctx.data.id,
|
||||
before: { name: existing.name },
|
||||
});
|
||||
return actionOk();
|
||||
})
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Events ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,97 +89,97 @@ export const createEvent = adminAction(
|
||||
...ctx.data,
|
||||
hostUserId: Number(ctx.session.user.id),
|
||||
},
|
||||
});
|
||||
})
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_create",
|
||||
target: "WebsiteEvent",
|
||||
action: 'event_create',
|
||||
target: 'WebsiteEvent',
|
||||
targetId: event.id,
|
||||
after: { title: event.title },
|
||||
});
|
||||
return actionOk({ id: event.id });
|
||||
})
|
||||
return actionOk({ id: event.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updateEventInput = updateEventSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const updateEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteEvent.findUnique({ where: { id } });
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
const { id, ...data } = ctx.data
|
||||
const existing = await prisma.websiteEvent.findUnique({ where: { id } })
|
||||
if (!existing) throw new ActionError('Event not found')
|
||||
|
||||
await prisma.websiteEvent.update({ where: { id }, data });
|
||||
await prisma.websiteEvent.update({ where: { id }, data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_update",
|
||||
target: "WebsiteEvent",
|
||||
action: 'event_update',
|
||||
target: 'WebsiteEvent',
|
||||
targetId: id,
|
||||
before: { title: existing.title, status: existing.status },
|
||||
after: data,
|
||||
});
|
||||
return actionOk({ id });
|
||||
})
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deleteEventInput = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const deleteEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||
async (ctx) => {
|
||||
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } });
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } })
|
||||
if (!existing) throw new ActionError('Event not found')
|
||||
|
||||
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
|
||||
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_delete",
|
||||
target: "WebsiteEvent",
|
||||
action: 'event_delete',
|
||||
target: 'WebsiteEvent',
|
||||
targetId: ctx.data.id,
|
||||
before: { title: existing.title },
|
||||
});
|
||||
return actionOk();
|
||||
})
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Prizes ──────────────────────────────────────────────────────────
|
||||
|
||||
export const addEventPrize = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||
async (ctx) => {
|
||||
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
|
||||
return actionOk({ id: prize.id });
|
||||
const prize = await prisma.websiteEventPrize.create({ data: ctx.data })
|
||||
return actionOk({ id: prize.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() })
|
||||
|
||||
export const deleteEventPrize = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
|
||||
return actionOk();
|
||||
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } })
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Winners ─────────────────────────────────────────────────────────
|
||||
|
||||
export const addEventWinner = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||
async (ctx) => {
|
||||
const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
|
||||
const winner = await prisma.websiteEventWinner.create({ data: ctx.data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_winner_add",
|
||||
target: "WebsiteEventWinner",
|
||||
action: 'event_winner_add',
|
||||
target: 'WebsiteEventWinner',
|
||||
targetId: winner.id,
|
||||
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position },
|
||||
});
|
||||
return actionOk({ id: winner.id });
|
||||
})
|
||||
return actionOk({ id: winner.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
@@ -3,27 +3,13 @@
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function importBadgeFromRemote({
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}) {
|
||||
export async function importBadgeFromRemote({ code, name, description }: { code: string; name: string; description: string }) {
|
||||
await requireStaff();
|
||||
try {
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: { badgeKey: code, badgeName: name, badgeDescription: description, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
|
||||
+12
-13
@@ -1,20 +1,19 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { actionOk } from '@/lib/safe-action-shared'
|
||||
import { cleanupConvertedSwfs, deleteImportedItem } from '@/lib/services/furni-import'
|
||||
|
||||
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const result = await cleanupConvertedSwfs();
|
||||
return actionOk(result as unknown as Record<string, unknown>);
|
||||
});
|
||||
const result = await cleanupConvertedSwfs()
|
||||
return actionOk(result as unknown as Record<string, unknown>)
|
||||
})
|
||||
|
||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) })
|
||||
|
||||
export const deleteImportedFurni = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||
async (ctx) =>
|
||||
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
|
||||
);
|
||||
async (ctx) => actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
|
||||
)
|
||||
+75
-75
@@ -1,67 +1,67 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import { rcon } from '@/lib/services/rcon'
|
||||
|
||||
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
||||
|
||||
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
||||
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() })
|
||||
|
||||
export const assignCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { modId: ctx.session.user.id, state: 1 },
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_assign",
|
||||
target: "support_tickets",
|
||||
action: 'cfh_assign',
|
||||
target: 'support_tickets',
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const cfhStateSchema = z.object({
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
state: z.coerce.number().int().min(0).max(3),
|
||||
});
|
||||
})
|
||||
|
||||
export const updateCfhState = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { state: ctx.data.state, modId: ctx.session.user.id },
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_state_change",
|
||||
target: "support_tickets",
|
||||
action: 'cfh_state_change',
|
||||
target: 'support_tickets',
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { state: ticket.state },
|
||||
after: { state: ctx.data.state },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export const closeCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
@@ -69,138 +69,138 @@ export const closeCfhTicket = adminAction(
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { state: 2, modId: ctx.session.user.id },
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_close",
|
||||
target: "support_tickets",
|
||||
action: 'cfh_close',
|
||||
target: 'support_tickets',
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Quick Mod Actions ────────────────────────────────────────────────
|
||||
|
||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() })
|
||||
|
||||
export const quickKick = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.disconnectUser(ctx.data.userId);
|
||||
await rcon.disconnectUser(ctx.data.userId)
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_kick",
|
||||
target: "User",
|
||||
action: 'mod_kick',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
|
||||
});
|
||||
})
|
||||
|
||||
export const quickMute = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration)
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_mute",
|
||||
target: "User",
|
||||
action: 'mod_mute',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export const quickUnmute = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.unmuteUser(ctx.data.userId);
|
||||
await rcon.unmuteUser(ctx.data.userId)
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_unmute",
|
||||
target: "User",
|
||||
action: 'mod_unmute',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const alertSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
})
|
||||
|
||||
export const quickAlert = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
||||
async (ctx) => {
|
||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
await rcon.alertUser(ctx.data.userId, ctx.data.message)
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_alert",
|
||||
target: "User",
|
||||
action: 'mod_alert',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
after: { message: ctx.data.message },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() })
|
||||
|
||||
export const quickRoomKick = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.kickAll(ctx.data.roomId);
|
||||
await rcon.kickAll(ctx.data.roomId)
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_room_kick",
|
||||
target: "Room",
|
||||
action: 'mod_room_kick',
|
||||
target: 'Room',
|
||||
targetId: ctx.data.roomId,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const broadcastSchema = z.object({
|
||||
message: z.string().min(1).max(500),
|
||||
type: z.enum(["hotel", "staff"]),
|
||||
});
|
||||
type: z.enum(['hotel', 'staff']),
|
||||
})
|
||||
|
||||
export const broadcastAlert = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.type === "hotel") {
|
||||
await rcon.hotelAlert(ctx.data.message);
|
||||
if (ctx.data.type === 'hotel') {
|
||||
await rcon.hotelAlert(ctx.data.message)
|
||||
} else {
|
||||
await rcon.staffAlert(ctx.data.message);
|
||||
await rcon.staffAlert(ctx.data.message)
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: `mod_broadcast_${ctx.data.type}`,
|
||||
target: "broadcast",
|
||||
target: 'broadcast',
|
||||
after: { message: ctx.data.message },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+52
-90
@@ -1,140 +1,102 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permission-slugs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from '@/lib/services/permission-ranks'
|
||||
import { rcon } from '@/lib/services/rcon'
|
||||
import { logStaffActivity } from '@/lib/services/staff-activity'
|
||||
|
||||
const createRankSchema = z.object({
|
||||
rank_name: z.string().trim().min(1).max(25),
|
||||
level: z.coerce.number().int().min(1),
|
||||
});
|
||||
})
|
||||
|
||||
export const createRank = adminAction(
|
||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const id = await createEmulatorRank(prisma, ctx.data);
|
||||
const id = await createEmulatorRank(prisma, ctx.data)
|
||||
await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${id}` },
|
||||
create: {
|
||||
slug: `rank_${id}`,
|
||||
title: ctx.data.rank_name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
},
|
||||
create: { slug: `rank_${id}`, title: ctx.data.rank_name, description: 'CMS role synchronized from permission_ranks' },
|
||||
update: { title: ctx.data.rank_name },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_create",
|
||||
description: `Created rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk({ id });
|
||||
})
|
||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_create', description: `Created rank #${id}`, targetType: 'rank', targetId: id })
|
||||
await rcon.send('updatepermissions')
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() })
|
||||
|
||||
export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } });
|
||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } })
|
||||
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`)
|
||||
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } })
|
||||
await deleteEmulatorRank(prisma, ctx.data.id)
|
||||
if (role) {
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }),
|
||||
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } }),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
||||
]);
|
||||
])
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_delete', description: `Deleted rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id })
|
||||
await rcon.send('updatepermissions')
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const saveRankSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.union([z.string(), z.number()])),
|
||||
});
|
||||
})
|
||||
|
||||
export const saveRank = adminAction(
|
||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||
if (typeof ctx.data.fields.rank_name === "string") {
|
||||
await prisma.aclRole.updateMany({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
data: { title: ctx.data.fields.rank_name },
|
||||
});
|
||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields)
|
||||
if (typeof ctx.data.fields.rank_name === 'string') {
|
||||
await prisma.aclRole.updateMany({ where: { slug: `rank_${ctx.data.id}` }, data: { title: ctx.data.fields.rank_name } })
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_update",
|
||||
description: `Updated rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_update', description: `Updated rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id })
|
||||
await rcon.send('updatepermissions')
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const setCmsPermsSchema = z.object({
|
||||
roleId: z.coerce.number().int().positive(),
|
||||
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
|
||||
});
|
||||
})
|
||||
|
||||
export const setCmsPermissions = adminAction(
|
||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const role = await prisma.aclRole.findUnique({
|
||||
where: { id: ctx.data.roleId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!role) throw new ActionError("Role not found");
|
||||
const role = await prisma.aclRole.findUnique({ where: { id: ctx.data.roleId }, select: { id: true, slug: true } })
|
||||
if (!role) throw new ActionError('Role not found')
|
||||
const permissions = await prisma.aclPermission.findMany({
|
||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||
select: { id: true },
|
||||
});
|
||||
})
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } });
|
||||
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } })
|
||||
if (permissions.length) {
|
||||
await tx.aclModelPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
modelId: role.id,
|
||||
modelType: "Role",
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
});
|
||||
data: permissions.map((permission) => ({ modelId: role.id, modelType: 'Role', permissionId: permission.id })),
|
||||
})
|
||||
}
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "acl_role_permissions_update",
|
||||
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
||||
targetType: "acl_role",
|
||||
targetId: role.id,
|
||||
});
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
})
|
||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'acl_role_permissions_update', description: `Updated ${permissions.length} permissions for ${role.slug}`, targetType: 'acl_role', targetId: role.id })
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+45
-45
@@ -1,107 +1,107 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import { createPollSchema, pollQuestionSchema, updatePollSchema } from '@/lib/validators/poll'
|
||||
|
||||
// ── Polls ───────────────────────────────────────────────────────────
|
||||
|
||||
export const createPoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
||||
async (ctx) => {
|
||||
const poll = await prisma.websitePoll.create({ data: ctx.data });
|
||||
const poll = await prisma.websitePoll.create({ data: ctx.data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "poll_create",
|
||||
target: "WebsitePoll",
|
||||
action: 'poll_create',
|
||||
target: 'WebsitePoll',
|
||||
targetId: poll.id,
|
||||
after: { title: poll.title },
|
||||
});
|
||||
return actionOk({ id: poll.id });
|
||||
})
|
||||
return actionOk({ id: poll.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updatePollInput = updatePollSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const updatePoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websitePoll.findUnique({ where: { id } });
|
||||
if (!existing) throw new ActionError("Poll not found");
|
||||
const { id, ...data } = ctx.data
|
||||
const existing = await prisma.websitePoll.findUnique({ where: { id } })
|
||||
if (!existing) throw new ActionError('Poll not found')
|
||||
|
||||
await prisma.websitePoll.update({ where: { id }, data });
|
||||
await prisma.websitePoll.update({ where: { id }, data })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "poll_update",
|
||||
target: "WebsitePoll",
|
||||
action: 'poll_update',
|
||||
target: 'WebsitePoll',
|
||||
targetId: id,
|
||||
before: { title: existing.title, status: existing.status },
|
||||
after: data,
|
||||
});
|
||||
return actionOk({ id });
|
||||
})
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deletePollInput = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const deletePoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
||||
async (ctx) => {
|
||||
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } });
|
||||
if (!existing) throw new ActionError("Poll not found");
|
||||
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } })
|
||||
if (!existing) throw new ActionError('Poll not found')
|
||||
|
||||
await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
|
||||
await prisma.websitePoll.delete({ where: { id: ctx.data.id } })
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "poll_delete",
|
||||
target: "WebsitePoll",
|
||||
action: 'poll_delete',
|
||||
target: 'WebsitePoll',
|
||||
targetId: ctx.data.id,
|
||||
before: { title: existing.title },
|
||||
});
|
||||
return actionOk();
|
||||
})
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Questions ───────────────────────────────────────────────────────
|
||||
|
||||
export const addPollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
||||
async (ctx) => {
|
||||
const question = await prisma.websitePollQuestion.create({ data: ctx.data });
|
||||
return actionOk({ id: question.id });
|
||||
const question = await prisma.websitePollQuestion.create({ data: ctx.data })
|
||||
return actionOk({ id: question.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updateQuestionInput = pollQuestionSchema.partial().extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const updatePollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
await prisma.websitePollQuestion.update({ where: { id }, data });
|
||||
return actionOk({ id });
|
||||
const { id, ...data } = ctx.data
|
||||
await prisma.websitePollQuestion.update({ where: { id }, data })
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deleteQuestionInput = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const deletePollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
||||
async (ctx) => {
|
||||
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
|
||||
return actionOk();
|
||||
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } })
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+45
-45
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
|
||||
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
|
||||
// are not represented in prisma/schema.prisma yet — we use raw queries with
|
||||
@@ -19,26 +19,26 @@ const createPrefixSchema = z.object({
|
||||
icon: z.string().optional(),
|
||||
effect: z.string().optional(),
|
||||
active: z.coerce.number().int().min(0).max(1).default(1),
|
||||
});
|
||||
})
|
||||
|
||||
export const createPrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||
async (ctx) => {
|
||||
const { username, text, color, icon, effect, active } = ctx.data;
|
||||
const { username, text, color, icon, effect, active } = ctx.data
|
||||
|
||||
const users = await prisma.$queryRaw<{ id: number }[]>`
|
||||
SELECT id FROM users WHERE username = ${username} LIMIT 1
|
||||
`;
|
||||
if (users.length === 0) throw new ActionError("User not found");
|
||||
`
|
||||
if (users.length === 0) throw new ActionError('User not found')
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
|
||||
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
|
||||
`;
|
||||
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ''}, ${effect || ''}, ${active})
|
||||
`
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Update prefix ───────────────────────────────────────────────────
|
||||
|
||||
@@ -49,94 +49,94 @@ const updatePrefixSchema = z.object({
|
||||
icon: z.string().optional(),
|
||||
effect: z.string().optional(),
|
||||
active: z.coerce.number().int().min(0).max(1).optional(),
|
||||
});
|
||||
})
|
||||
|
||||
export const updatePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||
async (ctx) => {
|
||||
const { id, text, color, icon, effect, active } = ctx.data;
|
||||
const { id, text, color, icon, effect, active } = ctx.data
|
||||
|
||||
await prisma.$executeRaw`
|
||||
UPDATE custom_prefixes
|
||||
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
|
||||
SET text = ${text}, color = ${color}, icon = ${icon || ''}, effect = ${effect || ''}, active = ${active ?? 1}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
`
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Delete prefix ───────────────────────────────────────────────────
|
||||
|
||||
const deletePrefixSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const deletePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
||||
async (ctx) => {
|
||||
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`;
|
||||
return actionOk();
|
||||
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Add blacklist word ──────────────────────────────────────────────
|
||||
|
||||
const addBlacklistWordSchema = z.object({
|
||||
word: z.string().min(1).max(100),
|
||||
});
|
||||
})
|
||||
|
||||
export const addBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||
async (ctx) => {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
||||
`;
|
||||
return actionOk();
|
||||
`
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Remove blacklist word ───────────────────────────────────────────
|
||||
|
||||
const removeBlacklistWordSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const removeBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
||||
async (ctx) => {
|
||||
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`;
|
||||
return actionOk();
|
||||
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Update prefix settings ──────────────────────────────────────────
|
||||
|
||||
const SETTINGS_WHITELIST = new Set([
|
||||
"enabled",
|
||||
"max_length",
|
||||
"min_rank",
|
||||
"allow_colors",
|
||||
"allow_bold",
|
||||
"allow_italic",
|
||||
"default_color",
|
||||
]);
|
||||
'enabled',
|
||||
'max_length',
|
||||
'min_rank',
|
||||
'allow_colors',
|
||||
'allow_bold',
|
||||
'allow_italic',
|
||||
'default_color',
|
||||
])
|
||||
|
||||
const updatePrefixSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
})
|
||||
|
||||
export const updatePrefixSettings = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
||||
async (ctx) => {
|
||||
for (const [key, value] of Object.entries(ctx.data.settings)) {
|
||||
if (!SETTINGS_WHITELIST.has(key)) continue;
|
||||
if (!SETTINGS_WHITELIST.has(key)) continue
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
|
||||
VALUES (${key}, ${value})
|
||||
ON DUPLICATE KEY UPDATE \`value\` = ${value}
|
||||
`;
|
||||
`
|
||||
}
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+1
-10
@@ -71,16 +71,7 @@ export async function deleteRoom({ id }: { id: number }) {
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom({
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
usersMax?: number;
|
||||
}) {
|
||||
export async function updateRoom({ id, ...data }: { id: number; name?: string; description?: string; state?: string; usersMax?: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -10,19 +10,7 @@ export async function deleteSoundtrack({ id }: { id: number }) {
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
|
||||
export async function updateSoundtrack({
|
||||
id,
|
||||
name,
|
||||
author,
|
||||
track,
|
||||
length,
|
||||
}: {
|
||||
id: number;
|
||||
name: string;
|
||||
author: string;
|
||||
track: string;
|
||||
length: number;
|
||||
}) {
|
||||
export async function updateSoundtrack({ id, name, author, track, length }: { id: number; name: string; author: string; track: string; length: number }) {
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.update({
|
||||
where: { id },
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
|
||||
const templateSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
category: z.string().max(50).optional().default("general"),
|
||||
category: z.string().max(50).optional().default('general'),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
})
|
||||
|
||||
export const createTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||
async (ctx) => {
|
||||
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
|
||||
return actionOk({ id: tpl.id });
|
||||
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data })
|
||||
return actionOk({ id: tpl.id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||
const updateTemplateInput = templateSchema
|
||||
.partial()
|
||||
.extend({ id: z.coerce.number().int().positive() })
|
||||
|
||||
export const updateTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } });
|
||||
if (!existing) throw new ActionError("Template not found");
|
||||
await prisma.websiteTicketTemplate.update({ where: { id }, data });
|
||||
return actionOk({ id });
|
||||
const { id, ...data } = ctx.data
|
||||
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } })
|
||||
if (!existing) throw new ActionError('Template not found')
|
||||
await prisma.websiteTicketTemplate.update({ where: { id }, data })
|
||||
return actionOk({ id })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() })
|
||||
|
||||
export const deleteTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
|
||||
return actionOk();
|
||||
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } })
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+73
-73
@@ -1,18 +1,18 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction, authAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import { notify } from '@/lib/services/webhook'
|
||||
import {
|
||||
assignTicketSchema,
|
||||
createTicketSchema,
|
||||
replyTicketSchema,
|
||||
updateTicketPrioritySchema,
|
||||
updateTicketStatusSchema,
|
||||
} from "@/lib/validators/ticket";
|
||||
} from '@/lib/validators/ticket'
|
||||
|
||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
|
||||
category: ctx.data.category,
|
||||
creatorId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Create the first message
|
||||
await prisma.websiteTicketMessage.create({
|
||||
@@ -33,26 +33,26 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
|
||||
message: ctx.data.message,
|
||||
isStaff: 0,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "ticket_create",
|
||||
action: 'ticket_create',
|
||||
actor: ctx.session.user.username,
|
||||
target: `#${ticket.id} - ${ticket.subject}`,
|
||||
details: `Category: ${ticket.category}`,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk({ id: ticket.id });
|
||||
});
|
||||
return actionOk({ id: ticket.id })
|
||||
})
|
||||
|
||||
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
|
||||
if (ticket.status === 'closed') throw new ActionError('Ticket is closed')
|
||||
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
@@ -61,38 +61,38 @@ export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (
|
||||
message: ctx.data.message,
|
||||
isStaff: 0,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// If ticket was in "waiting" (waiting for user), move back to open
|
||||
if (ticket.status === "waiting") {
|
||||
if (ticket.status === 'waiting') {
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { status: "open" },
|
||||
});
|
||||
data: { status: 'open' },
|
||||
})
|
||||
}
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk()
|
||||
})
|
||||
|
||||
export const closeTicketByUser = authAction(
|
||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed") throw new ActionError("Ticket is already closed");
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
|
||||
if (ticket.status === 'closed') throw new ActionError('Ticket is already closed')
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { status: "closed", closedAt: new Date() },
|
||||
});
|
||||
data: { status: 'closed', closedAt: new Date() },
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Admin actions ────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,9 +101,9 @@ export const adminReplyTicket = adminAction(
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
@@ -112,118 +112,118 @@ export const adminReplyTicket = adminAction(
|
||||
message: ctx.data.message,
|
||||
isStaff: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Auto-assign if not assigned yet
|
||||
const updates: Record<string, unknown> = { status: "waiting" };
|
||||
const updates: Record<string, unknown> = { status: 'waiting' }
|
||||
if (!ticket.assigneeId) {
|
||||
updates.assigneeId = ctx.session.user.id;
|
||||
updates.assigneeId = ctx.session.user.id
|
||||
}
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: updates,
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_reply",
|
||||
target: "WebsiteTicket",
|
||||
action: 'ticket_reply',
|
||||
target: 'WebsiteTicket',
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export const updateTicketStatus = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
|
||||
const data: Record<string, unknown> = { status: ctx.data.status };
|
||||
if (ctx.data.status === "closed") {
|
||||
data.closedAt = new Date();
|
||||
const data: Record<string, unknown> = { status: ctx.data.status }
|
||||
if (ctx.data.status === 'closed') {
|
||||
data.closedAt = new Date()
|
||||
}
|
||||
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
|
||||
data.assigneeId = ctx.session.user.id;
|
||||
if (ctx.data.status === 'in_progress' && !ticket.assigneeId) {
|
||||
data.assigneeId = ctx.session.user.id
|
||||
}
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data,
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_status_change",
|
||||
target: "WebsiteTicket",
|
||||
action: 'ticket_status_change',
|
||||
target: 'WebsiteTicket',
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { status: ticket.status },
|
||||
after: { status: ctx.data.status },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export const assignTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: {
|
||||
assigneeId: ctx.data.assigneeId,
|
||||
status: ctx.data.assigneeId ? "in_progress" : "open",
|
||||
status: ctx.data.assigneeId ? 'in_progress' : 'open',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_assign",
|
||||
target: "WebsiteTicket",
|
||||
action: 'ticket_assign',
|
||||
target: 'WebsiteTicket',
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { assigneeId: ticket.assigneeId },
|
||||
after: { assigneeId: ctx.data.assigneeId },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export const updateTicketPriority = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError('Ticket not found')
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { priority: ctx.data.priority },
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_priority_change",
|
||||
target: "WebsiteTicket",
|
||||
action: 'ticket_priority_change',
|
||||
target: 'WebsiteTicket',
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { priority: ticket.priority },
|
||||
after: { priority: ctx.data.priority },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+56
-53
@@ -1,73 +1,76 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { z } from "zod";
|
||||
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import JSON5 from 'json5'
|
||||
import { z } from 'zod'
|
||||
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
|
||||
import { patchJson5 } from '@/lib/json5-patch'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum(["en", "it"]),
|
||||
locale: z.enum(['en', 'it']),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
})
|
||||
|
||||
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
|
||||
|
||||
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
|
||||
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
|
||||
const filePath = path.join(process.cwd(), 'messages', `${ctx.data.locale}.json`)
|
||||
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), 'utf-8')
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk()
|
||||
})
|
||||
|
||||
const saveClientTranslationsSchema = z.object({
|
||||
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
|
||||
data: z.record(z.string(), z.string()),
|
||||
});
|
||||
})
|
||||
|
||||
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||
export const saveClientTranslations = adminAction(
|
||||
{ schema: saveClientTranslationsSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
|
||||
|
||||
const file = getClientTranslationFile(ctx.data.fileId);
|
||||
if (!file) throw new ActionError("Unknown file");
|
||||
if (file.readOnly) throw new ActionError("File is read-only");
|
||||
const file = getClientTranslationFile(ctx.data.fileId)
|
||||
if (!file) throw new ActionError('Unknown file')
|
||||
if (file.readOnly) throw new ActionError('File is read-only')
|
||||
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||
// Turbopack's static tracer can't prove that — without the hint it
|
||||
// pulls the entire project into the NFT list.
|
||||
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath);
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||
// Turbopack's static tracer can't prove that — without the hint it
|
||||
// pulls the entire project into the NFT list.
|
||||
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath)
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
|
||||
if (file.format === "json") {
|
||||
// Plain JSON — no comments to preserve, just round-trip.
|
||||
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8");
|
||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (file.format === 'json') {
|
||||
// Plain JSON — no comments to preserve, just round-trip.
|
||||
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), 'utf-8')
|
||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] })
|
||||
}
|
||||
}
|
||||
|
||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if (unpatchedKeys.length === 0) {
|
||||
await fs.writeFile(absPath, content, "utf-8");
|
||||
return actionOk({ commentsLost: false, unpatchedKeys });
|
||||
}
|
||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data)
|
||||
|
||||
// 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 });
|
||||
});
|
||||
if (unpatchedKeys.length === 0) {
|
||||
await fs.writeFile(absPath, content, 'utf-8')
|
||||
return actionOk({ commentsLost: false, unpatchedKeys })
|
||||
}
|
||||
|
||||
// At least one key could not be patched surgically (e.g. unusual
|
||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
||||
// and warn the caller that comments were lost.
|
||||
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), 'utf-8')
|
||||
return actionOk({ commentsLost: true, unpatchedKeys })
|
||||
},
|
||||
)
|
||||
+213
-192
@@ -1,31 +1,36 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user";
|
||||
import crypto from 'node:crypto'
|
||||
import { hash } from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import { rcon } from '@/lib/services/rcon'
|
||||
import { notify } from '@/lib/services/webhook'
|
||||
import {
|
||||
banUserSchema,
|
||||
createUserSchema,
|
||||
giveBadgeSchema,
|
||||
updateUserSchema,
|
||||
} from '@/lib/validators/user'
|
||||
|
||||
const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
|
||||
const DEFAULT_LOOK = 'hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64'
|
||||
|
||||
export const createUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||
async (ctx) => {
|
||||
const { username, mail, password, rank, motto } = ctx.data;
|
||||
const { username, mail, password, rank, motto } = ctx.data
|
||||
|
||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
throw new ActionError('Cannot assign rank equal or higher than your own')
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password, 12);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hashedPassword = await hash(password, 12)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
try {
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
@@ -40,211 +45,221 @@ export const createUser = adminAction(
|
||||
credits: 5000,
|
||||
pixels: 5000,
|
||||
accountCreated: now,
|
||||
ipRegister: "0.0.0.0",
|
||||
ipCurrent: "0.0.0.0",
|
||||
ipRegister: '0.0.0.0',
|
||||
ipCurrent: '0.0.0.0',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await tx.usersSettings.create({ data: { userId: created.id } });
|
||||
await tx.usersSettings.create({ data: { userId: created.id } })
|
||||
await tx.usersCurrency.createMany({
|
||||
data: [
|
||||
{ userId: created.id, type: 0, amount: 5000 },
|
||||
{ userId: created.id, type: 5, amount: 5000 },
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
return created;
|
||||
});
|
||||
return created
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_create",
|
||||
target: "User",
|
||||
action: 'user_create',
|
||||
target: 'User',
|
||||
targetId: user.id,
|
||||
after: { username, mail, rank },
|
||||
});
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
action: 'user_edit',
|
||||
actor: ctx.session.user.username,
|
||||
target: username,
|
||||
targetId: user.id,
|
||||
details: "Account created by admin",
|
||||
});
|
||||
details: 'Account created by admin',
|
||||
})
|
||||
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
return actionOk({ id: user.id, username: user.username })
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
const target = (err.meta?.target as string[]) ?? [];
|
||||
if (target.includes("username")) throw new ActionError("Username already taken");
|
||||
if (target.includes("mail")) throw new ActionError("Email already registered");
|
||||
throw new ActionError("Username or email already in use");
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
const target = (err.meta?.target as string[]) ?? []
|
||||
if (target.includes('username')) throw new ActionError('Username already taken')
|
||||
if (target.includes('mail')) throw new ActionError('Email already registered')
|
||||
throw new ActionError('Username or email already in use')
|
||||
}
|
||||
throw err;
|
||||
throw err
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const updateUserInput = updateUserSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const updateUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||
async (ctx) => {
|
||||
const { id, diamonds, duckets, ...userData } = ctx.data;
|
||||
const { id, diamonds, duckets, ...userData } = ctx.data
|
||||
|
||||
const targetUser = await guardRank(id, ctx.session.user.rank);
|
||||
const targetUser = await guardRank(id, ctx.session.user.rank)
|
||||
|
||||
if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
if (
|
||||
userData.rank !== undefined &&
|
||||
userData.rank >= ctx.session.user.rank &&
|
||||
ctx.session.user.rank < 7
|
||||
) {
|
||||
throw new ActionError('Cannot assign rank equal or higher than your own')
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id }, data: userData });
|
||||
await prisma.user.update({ where: { id }, data: userData })
|
||||
|
||||
if (diamonds !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId: id, type: 5 } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId: id, type: 5, amount: diamonds },
|
||||
});
|
||||
})
|
||||
}
|
||||
if (duckets !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId: id, type: 0 } },
|
||||
update: { amount: duckets },
|
||||
create: { userId: id, type: 0, amount: duckets },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_edit",
|
||||
target: "User",
|
||||
action: 'user_edit',
|
||||
target: 'User',
|
||||
targetId: id,
|
||||
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
|
||||
after: userData,
|
||||
});
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
action: 'user_edit',
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
targetId: id,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
const banInput = banUserSchema.extend({});
|
||||
const banInput = banUserSchema.extend({})
|
||||
|
||||
export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => {
|
||||
const { userId, reason, duration, type, ip } = ctx.data;
|
||||
export const banUser = adminAction(
|
||||
{ permission: PERMS.USERS_BAN, schema: banInput },
|
||||
async (ctx) => {
|
||||
const { userId, reason, duration, type, ip } = ctx.data
|
||||
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank)
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0
|
||||
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
userStaffId: ctx.session.user.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type || "account",
|
||||
ip: ip || "",
|
||||
machineId: "",
|
||||
},
|
||||
});
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
userStaffId: ctx.session.user.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type || 'account',
|
||||
ip: ip || '',
|
||||
machineId: '',
|
||||
},
|
||||
})
|
||||
|
||||
await rcon.disconnectUser(userId);
|
||||
await rcon.disconnectUser(userId)
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
after: { reason, type, duration },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'ban',
|
||||
target: 'User',
|
||||
targetId: userId,
|
||||
after: { reason, type, duration },
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "ban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
details: reason,
|
||||
});
|
||||
notify({
|
||||
action: 'ban',
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
details: reason,
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk()
|
||||
},
|
||||
)
|
||||
|
||||
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
|
||||
const unbanInput = z.object({ userId: z.coerce.number().int().positive() })
|
||||
|
||||
export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => {
|
||||
const { userId } = ctx.data;
|
||||
export const unbanUser = adminAction(
|
||||
{ permission: PERMS.USERS_BAN, schema: unbanInput },
|
||||
async (ctx) => {
|
||||
const { userId } = ctx.data
|
||||
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank)
|
||||
|
||||
await prisma.ban.deleteMany({ where: { userId } });
|
||||
await prisma.ban.deleteMany({ where: { userId } })
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'unban',
|
||||
target: 'User',
|
||||
targetId: userId,
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "unban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
});
|
||||
notify({
|
||||
action: 'unban',
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk()
|
||||
},
|
||||
)
|
||||
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
const { userId, badgeCode } = ctx.data
|
||||
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
await guardRank(userId, ctx.session.user.rank)
|
||||
|
||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
|
||||
if (existing) throw new ActionError("Badge already assigned");
|
||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
|
||||
if (existing) throw new ActionError('Badge already assigned')
|
||||
|
||||
await prisma.usersBadges.create({ data: { userId, badgeCode } });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
await prisma.usersBadges.create({ data: { userId, badgeCode } })
|
||||
await rcon.giveBadge(userId, badgeCode)
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Remove Badge ────────────────────────────────────────────────────
|
||||
|
||||
const removeBadgeSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badgeCode: z.string().min(1),
|
||||
});
|
||||
})
|
||||
|
||||
export const removeBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
const { userId, badgeCode } = ctx.data
|
||||
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
await guardRank(userId, ctx.session.user.rank)
|
||||
|
||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
|
||||
if (!existing) throw new ActionError("Badge not found");
|
||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
|
||||
if (!existing) throw new ActionError('Badge not found')
|
||||
|
||||
await prisma.usersBadges.delete({ where: { id: existing.id } });
|
||||
await rcon.removeBadge(userId, badgeCode);
|
||||
await prisma.usersBadges.delete({ where: { id: existing.id } })
|
||||
await rcon.removeBadge(userId, badgeCode)
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Rank guard helper ───────────────────────────────────────────────
|
||||
|
||||
@@ -252,163 +267,169 @@ async function guardRank(targetUserId: number, sessionRank: number) {
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { username: true, rank: true, mail: true },
|
||||
});
|
||||
if (!target) throw new ActionError("User not found");
|
||||
})
|
||||
if (!target) throw new ActionError('User not found')
|
||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
throw new ActionError('Cannot modify user with equal or higher rank')
|
||||
}
|
||||
return target;
|
||||
return target
|
||||
}
|
||||
|
||||
// ── Reset Password ──────────────────────────────────────────────────
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const resetPassword = adminAction(
|
||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
||||
|
||||
const newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16);
|
||||
const hashed = await hash(newPassword, 10);
|
||||
const newPassword = crypto.randomBytes(12).toString('base64url').slice(0, 16)
|
||||
const hashed = await hash(newPassword, 10)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: ctx.data.userId },
|
||||
data: { password: hashed },
|
||||
});
|
||||
})
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "reset_password",
|
||||
target: "User",
|
||||
action: 'reset_password',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
action: 'user_edit',
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
details: 'Password reset',
|
||||
})
|
||||
|
||||
return actionOk({ newPassword });
|
||||
return actionOk({ newPassword })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Disconnect User ─────────────────────────────────────────────────
|
||||
|
||||
const disconnectSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.disconnectUser(ctx.data.userId);
|
||||
if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?");
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
||||
const success = await rcon.disconnectUser(ctx.data.userId)
|
||||
if (!success) throw new ActionError('Failed to disconnect. Is the emulator running?')
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_disconnect",
|
||||
target: "User",
|
||||
action: 'user_disconnect',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
})
|
||||
|
||||
notify({
|
||||
action: "disconnect",
|
||||
action: 'disconnect',
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Alert User (in-game message) ────────────────────────────────────
|
||||
|
||||
const alertUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
})
|
||||
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
if (!success) throw new ActionError("Failed to send alert. Is the emulator running?");
|
||||
return actionOk();
|
||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message)
|
||||
if (!success) throw new ActionError('Failed to send alert. Is the emulator running?')
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ── Mute User ───────────────────────────────────────────────────────
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
})
|
||||
|
||||
export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
if (!success) throw new ActionError("Failed to mute. Is the emulator running?");
|
||||
export const muteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration)
|
||||
if (!success) throw new ActionError('Failed to mute. Is the emulator running?')
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'user_mute',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk()
|
||||
},
|
||||
)
|
||||
|
||||
// ── Unmute User ─────────────────────────────────────────────────────
|
||||
|
||||
const unmuteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
})
|
||||
|
||||
export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||
if (!success) throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||
export const unmuteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
||||
async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank)
|
||||
const success = await rcon.unmuteUser(ctx.data.userId)
|
||||
if (!success) throw new ActionError('Failed to unmute. Is the emulator running?')
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'user_unmute',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk()
|
||||
},
|
||||
)
|
||||
|
||||
// ── Send Credits via RCON ───────────────────────────────────────────
|
||||
|
||||
const sendCreditsSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
amount: z.coerce.number().int().min(1).max(1000000),
|
||||
});
|
||||
})
|
||||
|
||||
export const sendCredits = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||
if (!success) throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount)
|
||||
if (!success) throw new ActionError('Failed to send credits. Is the emulator running?')
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_send_credits",
|
||||
target: "User",
|
||||
action: 'user_send_credits',
|
||||
target: 'User',
|
||||
targetId: ctx.data.userId,
|
||||
after: { amount: ctx.data.amount },
|
||||
});
|
||||
})
|
||||
|
||||
return actionOk();
|
||||
return actionOk()
|
||||
},
|
||||
);
|
||||
)
|
||||
+19
-19
@@ -1,16 +1,16 @@
|
||||
"use server";
|
||||
'use server'
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { PERMS } from '@/lib/permission-slugs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { adminAction } from '@/lib/safe-action'
|
||||
import { actionOk } from '@/lib/safe-action-shared'
|
||||
|
||||
const toggleWatchSchema = z.object({
|
||||
targetUserId: z.coerce.number().int().positive(),
|
||||
reason: z.string().max(255).optional(),
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle a watch entry for the calling staff on the given target user.
|
||||
@@ -21,23 +21,23 @@ const toggleWatchSchema = z.object({
|
||||
export const toggleUserWatch = adminAction(
|
||||
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
|
||||
async (ctx) => {
|
||||
const staffId = ctx.session.user.id;
|
||||
const { targetUserId, reason } = ctx.data;
|
||||
const staffId = ctx.session.user.id
|
||||
const { targetUserId, reason } = ctx.data
|
||||
|
||||
const existing = await prisma.userWatch.findUnique({
|
||||
where: { staffId_targetUserId: { staffId, targetUserId } },
|
||||
});
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await prisma.userWatch.delete({ where: { id: existing.id } });
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: false });
|
||||
await prisma.userWatch.delete({ where: { id: existing.id } })
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
|
||||
return actionOk({ watching: false })
|
||||
}
|
||||
|
||||
await prisma.userWatch.create({
|
||||
data: { staffId, targetUserId, reason: reason ?? "" },
|
||||
});
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: true });
|
||||
data: { staffId, targetUserId, reason: reason ?? '' },
|
||||
})
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
|
||||
return actionOk({ watching: true })
|
||||
},
|
||||
);
|
||||
)
|
||||
@@ -1,17 +1,17 @@
|
||||
import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { MessageSquare, Shield, Terminal, Users } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCard } from '@/components/admin/stats-card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
async function getActivityData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const todayStart = now - (now % 86400)
|
||||
const weekAgo = now - 7 * 86400
|
||||
|
||||
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
|
||||
const rangeStart = todayStart - 6 * 86400;
|
||||
const rangeStart = todayStart - 6 * 86400
|
||||
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
|
||||
@@ -25,61 +25,57 @@ async function getActivityData() {
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
|
||||
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
|
||||
]);
|
||||
])
|
||||
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
|
||||
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||
const chatMap = toMap(chatRows);
|
||||
const cmdMap = toMap(cmdRows);
|
||||
const banMap = toMap(banRows);
|
||||
const regMap = toMap(regRows);
|
||||
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]))
|
||||
const chatMap = toMap(chatRows)
|
||||
const cmdMap = toMap(cmdRows)
|
||||
const banMap = toMap(banRows)
|
||||
const regMap = toMap(regRows)
|
||||
|
||||
const dailyData = [];
|
||||
const dailyData = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
const dayStart = todayStart - i * 86400
|
||||
const bucket = Math.floor(dayStart / 86400)
|
||||
const date = new Date(dayStart * 1000)
|
||||
dailyData.push({
|
||||
label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric" }),
|
||||
label: date.toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
chats: chatMap.get(bucket) ?? 0,
|
||||
commands: cmdMap.get(bucket) ?? 0,
|
||||
bans: banMap.get(bucket) ?? 0,
|
||||
registrations: regMap.get(bucket) ?? 0,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Totals
|
||||
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||
]);
|
||||
const weekChats = Number(weekChatRows[0]?.count ?? 0);
|
||||
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
|
||||
])
|
||||
const weekChats = Number(weekChatRows[0]?.count ?? 0)
|
||||
const weekCommands = Number(weekCommandRows[0]?.count ?? 0)
|
||||
|
||||
// Most active users (by chat messages)
|
||||
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
|
||||
SELECT user_from_id AS userId, COUNT(*) AS messages
|
||||
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
|
||||
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
|
||||
`;
|
||||
`
|
||||
|
||||
const userIds = topChatters.map((c) => c.userId);
|
||||
const userIds = topChatters.map((c) => c.userId)
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
})
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]))
|
||||
|
||||
const topChattersWithNames = topChatters.map((c) => ({
|
||||
userId: c.userId,
|
||||
username: userMap.get(c.userId) ?? `User #${c.userId}`,
|
||||
messages: Number(c.messages),
|
||||
}));
|
||||
}))
|
||||
|
||||
return {
|
||||
dailyData,
|
||||
@@ -88,16 +84,16 @@ async function getActivityData() {
|
||||
weekBans,
|
||||
weekRegistrations,
|
||||
topChatters: topChattersWithNames,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ActivityPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const data = await getActivityData();
|
||||
const data = await getActivityData()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -178,7 +174,10 @@ export default async function ActivityPage() {
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{data.topChatters.map((user, i) => (
|
||||
<div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<div
|
||||
key={user.userId}
|
||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
||||
{i + 1}
|
||||
</span>
|
||||
@@ -194,5 +193,5 @@ export default async function ActivityPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCard } from '@/components/admin/stats-card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const RevenueChart = dynamic(
|
||||
() => import("@/components/admin/revenue-chart").then((m) => ({ default: m.RevenueChart })),
|
||||
() => import('@/components/admin/revenue-chart').then((m) => ({ default: m.RevenueChart })),
|
||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||
);
|
||||
)
|
||||
|
||||
async function getEconomyData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const todayStart = now - (now % 86400)
|
||||
const weekAgo = now - 7 * 86400
|
||||
|
||||
// Total currency in circulation — parallel instead of sequential
|
||||
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
|
||||
prisma.user.aggregate({ _sum: { credits: true } }),
|
||||
prisma.user.aggregate({ _sum: { pixels: true } }),
|
||||
prisma.user.aggregate({ _sum: { points: true } }),
|
||||
]);
|
||||
const totalCredits = creditsAgg._sum.credits ?? 0;
|
||||
const totalPixels = pixelsAgg._sum.pixels ?? 0;
|
||||
const totalPoints = pointsAgg._sum.points ?? 0;
|
||||
])
|
||||
const totalCredits = creditsAgg._sum.credits ?? 0
|
||||
const totalPixels = pixelsAgg._sum.pixels ?? 0
|
||||
const totalPoints = pointsAgg._sum.points ?? 0
|
||||
|
||||
// Shop revenue (7 days)
|
||||
const shopRevenue = await prisma.logsShopPurchases.aggregate({
|
||||
where: { timestamp: { gte: weekAgo } },
|
||||
_sum: { costCredits: true, costPoints: true },
|
||||
_count: true,
|
||||
});
|
||||
})
|
||||
|
||||
// Marketplace stats
|
||||
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } });
|
||||
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } })
|
||||
const marketplaceSold = await prisma.marketplaceItems.count({
|
||||
where: { state: 2, soldTimestamp: { gte: weekAgo } },
|
||||
});
|
||||
})
|
||||
|
||||
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
|
||||
const rangeStart = todayStart - 6 * 86400;
|
||||
const rangeStart = todayStart - 6 * 86400
|
||||
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
|
||||
SELECT FLOOR(timestamp / 86400) AS day_bucket,
|
||||
COALESCE(SUM(cost_credits), 0) AS credits,
|
||||
@@ -49,55 +49,60 @@ async function getEconomyData() {
|
||||
FROM logs_shop_purchases
|
||||
WHERE timestamp >= ${rangeStart}
|
||||
GROUP BY day_bucket ORDER BY day_bucket
|
||||
`;
|
||||
`
|
||||
const revMap = new Map(
|
||||
revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]),
|
||||
);
|
||||
const revenueData = [];
|
||||
revRows.map((r) => [
|
||||
Number(r.day_bucket),
|
||||
{ credits: Number(r.credits), points: Number(r.points) },
|
||||
]),
|
||||
)
|
||||
const revenueData = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
const entry = revMap.get(bucket);
|
||||
const dayStart = todayStart - i * 86400
|
||||
const bucket = Math.floor(dayStart / 86400)
|
||||
const date = new Date(dayStart * 1000)
|
||||
const entry = revMap.get(bucket)
|
||||
revenueData.push({
|
||||
label: date.toLocaleDateString("en", { weekday: "short" }),
|
||||
label: date.toLocaleDateString('en', { weekday: 'short' }),
|
||||
credits: entry?.credits ?? 0,
|
||||
points: entry?.points ?? 0,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Trades (7 days)
|
||||
const weekTrades = await prisma.roomTradeLog.count({
|
||||
where: { timestamp: { gte: weekAgo } },
|
||||
});
|
||||
})
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
totalPixels,
|
||||
totalPoints,
|
||||
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
|
||||
shopPurchases: typeof shopRevenue._count === 'number' ? shopRevenue._count : 0,
|
||||
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
|
||||
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
|
||||
marketplaceActive,
|
||||
marketplaceSold,
|
||||
weekTrades,
|
||||
revenueData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EconomyPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const data = await getEconomyData();
|
||||
const data = await getEconomyData()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
|
||||
<p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p>
|
||||
<p className="text-muted-foreground">
|
||||
Currency circulation, shop revenue and marketplace activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -191,5 +196,5 @@ export default async function EconomyPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCard } from '@/components/admin/stats-card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const DashboardChart = dynamic(
|
||||
() => import("@/components/admin/dashboard-chart").then((m) => ({ default: m.DashboardChart })),
|
||||
() => import('@/components/admin/dashboard-chart').then((m) => ({ default: m.DashboardChart })),
|
||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||
);
|
||||
)
|
||||
|
||||
const PeakHoursHeatmap = dynamic(
|
||||
() => import("@/components/admin/peak-hours-heatmap").then((m) => ({ default: m.PeakHoursHeatmap })),
|
||||
() =>
|
||||
import('@/components/admin/peak-hours-heatmap').then((m) => ({ default: m.PeakHoursHeatmap })),
|
||||
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
|
||||
);
|
||||
)
|
||||
|
||||
async function getAnalyticsData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const monthAgo = now - 30 * 86400;
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const todayStart = now - (now % 86400)
|
||||
const weekAgo = now - 7 * 86400
|
||||
const monthAgo = now - 30 * 86400
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
@@ -35,17 +36,15 @@ async function getAnalyticsData() {
|
||||
weekBans,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
prisma.user.count({ where: { online: '1' } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
|
||||
prisma.rooms.count(),
|
||||
prisma.rooms.count({ where: { users: { gt: 0 } } }),
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
]);
|
||||
])
|
||||
|
||||
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
|
||||
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
@@ -53,17 +52,17 @@ async function getAnalyticsData() {
|
||||
FROM users
|
||||
WHERE account_created >= ${monthAgo}
|
||||
GROUP BY day_bucket ORDER BY day_bucket
|
||||
`;
|
||||
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||
const registrationChart = [];
|
||||
`
|
||||
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]))
|
||||
const registrationChart = []
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
const dayStart = todayStart - i * 86400
|
||||
const bucket = Math.floor(dayStart / 86400)
|
||||
const date = new Date(dayStart * 1000)
|
||||
registrationChart.push({
|
||||
label: date.toLocaleDateString("en", { month: "short", day: "numeric" }),
|
||||
label: date.toLocaleDateString('en', { month: 'short', day: 'numeric' }),
|
||||
value: regCountMap.get(bucket) ?? 0,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Peak hours — single GROUP BY query instead of fetching all user rows
|
||||
@@ -74,13 +73,13 @@ async function getAnalyticsData() {
|
||||
FROM users
|
||||
WHERE last_online >= ${weekAgo}
|
||||
GROUP BY d, h
|
||||
`;
|
||||
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||
let max = 0;
|
||||
`
|
||||
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
|
||||
let max = 0
|
||||
for (const r of peakRows) {
|
||||
const count = Number(r.c);
|
||||
grid[r.d][r.h] = count;
|
||||
if (count > max) max = count;
|
||||
const count = Number(r.c)
|
||||
grid[r.d][r.h] = count
|
||||
if (count > max) max = count
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -95,16 +94,16 @@ async function getAnalyticsData() {
|
||||
weekBans,
|
||||
registrationChart,
|
||||
peakHours: { grid, max },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const data = await getAnalyticsData();
|
||||
const data = await getAnalyticsData()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -188,5 +187,5 @@ export default async function AnalyticsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -39,11 +39,7 @@ export default async function EditArticle({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
|
||||
<ArticleForm
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
|
||||
@@ -21,11 +21,7 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
|
||||
<ArticleForm action={createArticle} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -35,11 +35,7 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createBanner, deleteBanner, updateBanner } from "@/actions/banners";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createBanner, deleteBanner, updateBanner } from '@/actions/banners'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { DateTimePicker } from '@/components/ui/date-time-picker'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
link: string;
|
||||
color: string;
|
||||
isActive: number;
|
||||
sortOrder: number;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string
|
||||
image: string
|
||||
link: string
|
||||
color: string
|
||||
isActive: number
|
||||
sortOrder: number
|
||||
startDate: string | null
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
banners: Banner[];
|
||||
banners: Banner[]
|
||||
}
|
||||
|
||||
export function BannersManager({ banners }: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const { run, isPending } = useServerAction()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
link: "",
|
||||
color: "",
|
||||
title: '',
|
||||
subtitle: '',
|
||||
image: '',
|
||||
link: '',
|
||||
color: '',
|
||||
isActive: 1,
|
||||
sortOrder: 0,
|
||||
startDate: "" as string | null,
|
||||
endDate: "" as string | null,
|
||||
});
|
||||
startDate: '' as string | null,
|
||||
endDate: '' as string | null,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
link: "",
|
||||
color: "",
|
||||
title: '',
|
||||
subtitle: '',
|
||||
image: '',
|
||||
link: '',
|
||||
color: '',
|
||||
isActive: 1,
|
||||
sortOrder: 0,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
setEditingId(null)
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
function handleEdit(b: Banner) {
|
||||
@@ -72,20 +72,20 @@ export function BannersManager({ banners }: Props) {
|
||||
sortOrder: b.sortOrder,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
});
|
||||
setEditingId(b.id);
|
||||
setShowForm(true);
|
||||
})
|
||||
setEditingId(b.id)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null };
|
||||
e.preventDefault()
|
||||
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null }
|
||||
if (editingId) {
|
||||
run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" });
|
||||
run(() => updateBanner({ id: editingId, ...data }), { successMessage: 'Banner updated!' })
|
||||
} else {
|
||||
run(() => createBanner(data), { successMessage: "Banner created!" });
|
||||
run(() => createBanner(data), { successMessage: 'Banner created!' })
|
||||
}
|
||||
resetForm();
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -93,8 +93,8 @@ export function BannersManager({ banners }: Props) {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowForm(!showForm);
|
||||
resetForm()
|
||||
setShowForm(!showForm)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" /> New Banner
|
||||
@@ -104,7 +104,7 @@ export function BannersManager({ banners }: Props) {
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
|
||||
<CardTitle>{editingId ? 'Edit Banner' : 'New Banner'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -136,7 +136,10 @@ export function BannersManager({ banners }: Props) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link URL</Label>
|
||||
<Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} />
|
||||
<Input
|
||||
value={form.link}
|
||||
onChange={(e) => setForm({ ...form, link: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@@ -144,7 +147,7 @@ export function BannersManager({ banners }: Props) {
|
||||
<Label>Color</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={form.color || "#000000"}
|
||||
value={form.color || '#000000'}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -188,7 +191,7 @@ export function BannersManager({ banners }: Props) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{editingId ? "Update" : "Create"}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -203,7 +206,10 @@ export function BannersManager({ banners }: Props) {
|
||||
{banners.map((b) => (
|
||||
<Card key={b.id} className="overflow-hidden">
|
||||
{b.image && (
|
||||
<div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} />
|
||||
<div
|
||||
className="h-32 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${b.image})` }}
|
||||
/>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -211,14 +217,19 @@ export function BannersManager({ banners }: Props) {
|
||||
<h3 className="font-medium">{b.title}</h3>
|
||||
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Badge variant={b.isActive ? "default" : "outline"}>
|
||||
{b.isActive ? "Active" : "Inactive"}
|
||||
<Badge variant={b.isActive ? 'default' : 'outline'}>
|
||||
{b.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
{b.startDate && <Badge variant="secondary">Scheduled</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(b)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -226,7 +237,7 @@ export function BannersManager({ banners }: Props) {
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
run(() => deleteBanner({ id: b.id }), { successMessage: "Banner deleted" })
|
||||
run(() => deleteBanner({ id: b.id }), { successMessage: 'Banner deleted' })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
@@ -238,5 +249,5 @@ export function BannersManager({ banners }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BannersManager } from "./banners-manager";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BannersManager } from './banners-manager'
|
||||
|
||||
export default async function BannersPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const banners = await prisma.websiteBanner.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -21,5 +21,5 @@ export default async function BannersPage() {
|
||||
</div>
|
||||
<BannersManager banners={banners} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,54 +1,54 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Languages, Package, Settings } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { CatalogItemData } from "./catalog-items-table";
|
||||
import { CatalogItemsTable } from "./catalog-items-table";
|
||||
import { CatalogPageForm } from "./catalog-page-form";
|
||||
import { CatalogTranslateTab } from "./catalog-translate-tab";
|
||||
import { Languages, Package, Settings } from 'lucide-react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import type { CatalogItemData } from './catalog-items-table'
|
||||
import { CatalogItemsTable } from './catalog-items-table'
|
||||
import { CatalogPageForm } from './catalog-page-form'
|
||||
import { CatalogTranslateTab } from './catalog-translate-tab'
|
||||
|
||||
interface CatalogPageData {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
clubOnly: string;
|
||||
orderNum: number;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string | null;
|
||||
pageText1: string | null;
|
||||
pageText2: string | null;
|
||||
pageTextDetails: string | null;
|
||||
pageTextTeaser: string | null;
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
clubOnly: string
|
||||
orderNum: number
|
||||
iconImage: number
|
||||
iconColor: number
|
||||
pageHeadline: string
|
||||
pageTeaser: string
|
||||
pageSpecial: string | null
|
||||
pageText1: string | null
|
||||
pageText2: string | null
|
||||
pageTextDetails: string | null
|
||||
pageTextTeaser: string | null
|
||||
}
|
||||
|
||||
interface BaseItemData {
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
id: number
|
||||
publicName: string
|
||||
itemName: string
|
||||
spriteId: number
|
||||
type: string
|
||||
}
|
||||
|
||||
interface CatalogDetailTabsProps {
|
||||
catalogPage: CatalogPageData;
|
||||
items: CatalogItemData[];
|
||||
baseItems: BaseItemData[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
childPages: { id: number; caption: string; enabled: string }[];
|
||||
pageId: number;
|
||||
canEdit: boolean;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
allPages: { id: number; caption: string }[];
|
||||
interactionTypes: string[];
|
||||
catalogPage: CatalogPageData
|
||||
items: CatalogItemData[]
|
||||
baseItems: BaseItemData[]
|
||||
catalogNameMap: Record<number, string>
|
||||
childPages: { id: number; caption: string; enabled: string }[]
|
||||
pageId: number
|
||||
canEdit: boolean
|
||||
furniDataIdList: number[]
|
||||
furniDescriptionMap: Record<number, string>
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>
|
||||
allPages: { id: number; caption: string }[]
|
||||
interactionTypes: string[]
|
||||
}
|
||||
|
||||
export function CatalogDetailTabs({
|
||||
@@ -78,15 +78,14 @@ export function CatalogDetailTabs({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-2">
|
||||
<Languages className="h-4 w-4" />
|
||||
Translate ({baseItems.length})
|
||||
{(() => {
|
||||
const furniSet = new Set(furniDataIdList);
|
||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
||||
Translate ({baseItems.length}){(() => {
|
||||
const furniSet = new Set(furniDataIdList)
|
||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length
|
||||
return missing > 0 ? (
|
||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null;
|
||||
) : null
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -123,5 +122,5 @@ export function CatalogDetailTabs({
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
* Re-export barrel for backward compatibility.
|
||||
* Implementation moved to ./catalog-items-table/ directory.
|
||||
*/
|
||||
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
|
||||
export { FurniIcon } from "./catalog-items-table/furni-icon";
|
||||
export type { BaseItemData, CatalogItemData } from "./catalog-items-table/types";
|
||||
export { CatalogItemsTable } from './catalog-items-table/catalog-items-table'
|
||||
export { FurniIcon } from './catalog-items-table/furni-icon'
|
||||
export type { BaseItemData, CatalogItemData } from './catalog-items-table/types'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Check, ChevronsUpDown, Star } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, Star } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from '@/components/ui/command'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Toggle field ──────────────────────────────────────────────
|
||||
|
||||
@@ -25,21 +25,21 @@ export function ToggleField({
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
label: string
|
||||
/** Boolean checked state */
|
||||
checked?: boolean;
|
||||
checked?: boolean
|
||||
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
|
||||
value?: number | string;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
value?: number | string
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const isChecked = checked ?? (value === 1 || value === "1");
|
||||
const isChecked = checked ?? (value === 1 || value === '1')
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Interaction Type Combobox ──────────────────────────────────
|
||||
@@ -50,19 +50,19 @@ export function InteractionTypeCombobox({
|
||||
types,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
types: string[];
|
||||
disabled?: boolean;
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
types: string[]
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!inputValue.trim()) return types.slice(0, 50);
|
||||
const q = inputValue.toLowerCase();
|
||||
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
|
||||
}, [types, inputValue]);
|
||||
if (!inputValue.trim()) return types.slice(0, 50)
|
||||
const q = inputValue.toLowerCase()
|
||||
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50)
|
||||
}, [types, inputValue])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -74,7 +74,7 @@ export function InteractionTypeCombobox({
|
||||
disabled={disabled}
|
||||
className="w-full justify-between font-normal h-8 text-sm"
|
||||
>
|
||||
<span className="truncate">{value || "Select type..."}</span>
|
||||
<span className="truncate">{value || 'Select type...'}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -92,15 +92,15 @@ export function InteractionTypeCombobox({
|
||||
type="button"
|
||||
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
|
||||
onClick={() => {
|
||||
onChange(inputValue.trim());
|
||||
setOpen(false);
|
||||
setInputValue("");
|
||||
onChange(inputValue.trim())
|
||||
setOpen(false)
|
||||
setInputValue('')
|
||||
}}
|
||||
>
|
||||
Use "{inputValue.trim()}"
|
||||
</button>
|
||||
) : (
|
||||
"No types found."
|
||||
'No types found.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -109,12 +109,14 @@ export function InteractionTypeCombobox({
|
||||
key={t}
|
||||
value={t}
|
||||
onSelect={() => {
|
||||
onChange(t);
|
||||
setOpen(false);
|
||||
setInputValue("");
|
||||
onChange(t)
|
||||
setOpen(false)
|
||||
setInputValue('')
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === t ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === t ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -123,7 +125,7 @@ export function InteractionTypeCombobox({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Move Page Combobox with smart suggestions ─────────────────
|
||||
@@ -135,32 +137,35 @@ export function MovePageCombobox({
|
||||
currentPageId,
|
||||
suggestedPageIds,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
pages: { id: number; caption: string }[];
|
||||
currentPageId: number;
|
||||
value: number | null
|
||||
onChange: (id: number | null) => void
|
||||
pages: { id: number; caption: string }[]
|
||||
currentPageId: number
|
||||
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
|
||||
suggestedPageIds?: number[];
|
||||
suggestedPageIds?: number[]
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId]);
|
||||
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId])
|
||||
|
||||
const selectedPage = value ? pages.find((p) => p.id === value) : null;
|
||||
const selectedPage = value ? pages.find((p) => p.id === value) : null
|
||||
|
||||
// Build suggested pages list
|
||||
const suggested = useMemo(() => {
|
||||
if (!suggestedPageIds?.length) return [];
|
||||
const pageMap = new Map(options.map((p) => [p.id, p]));
|
||||
if (!suggestedPageIds?.length) return []
|
||||
const pageMap = new Map(options.map((p) => [p.id, p]))
|
||||
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
|
||||
id: number;
|
||||
caption: string;
|
||||
}[];
|
||||
}, [suggestedPageIds, options]);
|
||||
id: number
|
||||
caption: string
|
||||
}[]
|
||||
}, [suggestedPageIds, options])
|
||||
|
||||
// Non-suggested pages (exclude those already in suggestions)
|
||||
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]);
|
||||
const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]);
|
||||
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested])
|
||||
const remaining = useMemo(
|
||||
() => options.filter((p) => !suggestedSet.has(p.id)),
|
||||
[options, suggestedSet],
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -173,7 +178,9 @@ export function MovePageCombobox({
|
||||
>
|
||||
{selectedPage ? (
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{selectedPage.id}</span>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
#{selectedPage.id}
|
||||
</span>
|
||||
{selectedPage.caption}
|
||||
</span>
|
||||
) : (
|
||||
@@ -196,13 +203,20 @@ export function MovePageCombobox({
|
||||
key={`s-${page.id}`}
|
||||
value={`${page.id} ${page.caption}`}
|
||||
onSelect={() => {
|
||||
onChange(page.id);
|
||||
setOpen(false);
|
||||
onChange(page.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === page.id ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
#{page.id}
|
||||
</span>
|
||||
<span className="truncate">{page.caption}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -210,17 +224,19 @@ export function MovePageCombobox({
|
||||
)}
|
||||
|
||||
{/* All pages */}
|
||||
<CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}>
|
||||
<CommandGroup heading={suggested.length > 0 ? 'All pages' : undefined}>
|
||||
{remaining.map((page) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
value={`${page.id} ${page.caption}`}
|
||||
onSelect={() => {
|
||||
onChange(page.id);
|
||||
setOpen(false);
|
||||
onChange(page.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === page.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
||||
<span className="truncate">{page.caption}</span>
|
||||
</CommandItem>
|
||||
@@ -230,5 +246,5 @@ export function MovePageCombobox({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
||||
import { Image as ImageIcon } from 'lucide-react'
|
||||
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
|
||||
|
||||
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) {
|
||||
if (!classname) {
|
||||
@@ -9,34 +9,34 @@ export function FurniIcon({ classname, revision }: { classname: string; revision
|
||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const localUrl = getLocalIconUrl(classname);
|
||||
const localUrl = getLocalIconUrl(classname)
|
||||
|
||||
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
const stage = img.dataset.fallback;
|
||||
const img = e.target as HTMLImageElement
|
||||
const stage = img.dataset.fallback
|
||||
if (!stage && revision > 0) {
|
||||
img.dataset.fallback = "cdn";
|
||||
img.src = getHabboCdnIconUrl(classname, revision);
|
||||
img.dataset.fallback = 'cdn'
|
||||
img.src = getHabboCdnIconUrl(classname, revision)
|
||||
} else {
|
||||
img.dataset.fallback = "none";
|
||||
img.style.opacity = "0";
|
||||
img.dataset.fallback = 'none'
|
||||
img.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{ }
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={localUrl}
|
||||
alt={classname}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
onError={handleError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export { CatalogItemsTable } from "./catalog-items-table";
|
||||
export { FurniIcon } from "./furni-icon";
|
||||
export type { BaseItemData, CatalogItemData, InlineEdit } from "./types";
|
||||
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from "./types";
|
||||
export { CatalogItemsTable } from './catalog-items-table'
|
||||
export { FurniIcon } from './furni-icon'
|
||||
export type { BaseItemData, CatalogItemData, InlineEdit } from './types'
|
||||
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from './types'
|
||||
@@ -1,53 +1,59 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Loader2, Music } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2, Music } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
getCachedSongPickerOptions,
|
||||
loadSongPickerOptions,
|
||||
type SoundtrackOption,
|
||||
} from "@/lib/client-cache/song-picker-cache";
|
||||
} from '@/lib/client-cache/song-picker-cache'
|
||||
|
||||
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
|
||||
// callsites keep working. New code should import from the lib module directly.
|
||||
export { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
||||
export { invalidateSongPickerCache } from '@/lib/client-cache/song-picker-cache'
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
onChange: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
value: number
|
||||
onChange: (id: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function formatLength(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function SongPicker({ value, onChange, disabled }: Props) {
|
||||
const initial = getCachedSongPickerOptions();
|
||||
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
const initial = getCachedSongPickerOptions()
|
||||
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? [])
|
||||
const [loading, setLoading] = useState(!initial)
|
||||
|
||||
useEffect(() => {
|
||||
if (getCachedSongPickerOptions()) return;
|
||||
let cancelled = false;
|
||||
if (getCachedSongPickerOptions()) return
|
||||
let cancelled = false
|
||||
loadSongPickerOptions()
|
||||
.then((items) => {
|
||||
if (!cancelled) setOptions(items);
|
||||
if (!cancelled) setOptions(items)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOptions([]);
|
||||
if (!cancelled) setOptions([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -75,12 +81,14 @@ export function SongPicker({ value, onChange, disabled }: Props) {
|
||||
<span className="font-medium">{opt.name}</span>
|
||||
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>}
|
||||
{opt.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground font-mono">{formatLength(opt.length)}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{formatLength(opt.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,71 +1,71 @@
|
||||
export interface BaseItemData {
|
||||
id: number;
|
||||
spriteId: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
width: number;
|
||||
length: number;
|
||||
stackHeight: number;
|
||||
allowStack: number;
|
||||
allowSit: number;
|
||||
allowLay: number;
|
||||
allowWalk: number;
|
||||
allowGift: number;
|
||||
allowTrade: number;
|
||||
allowRecycle: number;
|
||||
allowMarketplaceSell: number;
|
||||
allowInventoryStack: number;
|
||||
interactionType: string;
|
||||
interactionModesCount: number;
|
||||
vendingIds: string;
|
||||
customparams: string;
|
||||
effectIdMale: number;
|
||||
effectIdFemale: number;
|
||||
clothingOnWalk: string;
|
||||
id: number
|
||||
spriteId: number
|
||||
publicName: string
|
||||
itemName: string
|
||||
type: string
|
||||
width: number
|
||||
length: number
|
||||
stackHeight: number
|
||||
allowStack: number
|
||||
allowSit: number
|
||||
allowLay: number
|
||||
allowWalk: number
|
||||
allowGift: number
|
||||
allowTrade: number
|
||||
allowRecycle: number
|
||||
allowMarketplaceSell: number
|
||||
allowInventoryStack: number
|
||||
interactionType: string
|
||||
interactionModesCount: number
|
||||
vendingIds: string
|
||||
customparams: string
|
||||
effectIdMale: number
|
||||
effectIdFemale: number
|
||||
clothingOnWalk: string
|
||||
}
|
||||
|
||||
export interface CatalogItemData {
|
||||
id: number;
|
||||
catalogName: string;
|
||||
itemIds: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
extradata: string;
|
||||
baseName: string;
|
||||
baseItemName: string;
|
||||
spriteId: number;
|
||||
baseItem: BaseItemData | null;
|
||||
id: number
|
||||
catalogName: string
|
||||
itemIds: string
|
||||
costCredits: number
|
||||
costPoints: number
|
||||
pointsType: number
|
||||
amount: number
|
||||
limitedSells: number
|
||||
limitedStack: number
|
||||
orderNumber: number
|
||||
offerId: number
|
||||
songId: number
|
||||
haveOffer: string
|
||||
clubOnly: string
|
||||
extradata: string
|
||||
baseName: string
|
||||
baseItemName: string
|
||||
spriteId: number
|
||||
baseItem: BaseItemData | null
|
||||
}
|
||||
|
||||
export interface InlineEdit {
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
costCredits: number
|
||||
costPoints: number
|
||||
pointsType: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
|
||||
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
|
||||
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
|
||||
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
|
||||
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
|
||||
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
|
||||
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
|
||||
};
|
||||
0: { label: 'Duckets', color: 'text-[var(--admin-info)]' },
|
||||
5: { label: 'Diamonds', color: 'text-[var(--admin-accent-text)]' },
|
||||
101: { label: 'Snowflakes', color: 'text-[var(--admin-info)]' },
|
||||
103: { label: 'Hearts', color: 'text-[var(--admin-error)]' },
|
||||
104: { label: 'Stars', color: 'text-[var(--admin-warning)]' },
|
||||
105: { label: 'Shells', color: 'text-[var(--admin-warning)]' },
|
||||
}
|
||||
|
||||
export const DEFAULT_NEW_ITEM = {
|
||||
catalogName: "",
|
||||
itemIds: "0",
|
||||
catalogName: '',
|
||||
itemIds: '0',
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
@@ -75,11 +75,11 @@ export const DEFAULT_NEW_ITEM = {
|
||||
orderNumber: 1,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
};
|
||||
haveOffer: '1',
|
||||
clubOnly: '0',
|
||||
extradata: '',
|
||||
}
|
||||
|
||||
export function getPointsLabel(type: number) {
|
||||
return POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" };
|
||||
return POINTS_TYPES[type] || { label: `Type ${type}`, color: 'theme-text-muted' }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Check,
|
||||
@@ -11,17 +11,17 @@ import {
|
||||
Save,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
|
||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { deleteCatalogPage, updateCatalogPage } from '@/actions/catalog'
|
||||
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
|
||||
import { IconPicker } from '@/components/admin/catalog/icon-picker'
|
||||
import { ImagePreview } from '@/components/admin/catalog-manager/image-preview'
|
||||
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -29,109 +29,120 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from '@/components/ui/command'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CatalogPageData {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
clubOnly: string;
|
||||
orderNum: number;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string | null;
|
||||
pageText1: string | null;
|
||||
pageText2: string | null;
|
||||
pageTextDetails: string | null;
|
||||
pageTextTeaser: string | null;
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
clubOnly: string
|
||||
orderNum: number
|
||||
iconImage: number
|
||||
iconColor: number
|
||||
pageHeadline: string
|
||||
pageTeaser: string
|
||||
pageSpecial: string | null
|
||||
pageText1: string | null
|
||||
pageText2: string | null
|
||||
pageTextDetails: string | null
|
||||
pageTextTeaser: string | null
|
||||
}
|
||||
|
||||
interface CatalogPageFormProps {
|
||||
catalogPage: CatalogPageData;
|
||||
childPages: { id: number; caption: string; enabled: string }[];
|
||||
canEdit: boolean;
|
||||
allPages: { id: number; caption: string }[];
|
||||
catalogPage: CatalogPageData
|
||||
childPages: { id: number; caption: string; enabled: string }[]
|
||||
canEdit: boolean
|
||||
allPages: { id: number; caption: string }[]
|
||||
}
|
||||
|
||||
export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }: CatalogPageFormProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
export function CatalogPageForm({
|
||||
catalogPage,
|
||||
childPages,
|
||||
canEdit,
|
||||
allPages,
|
||||
}: CatalogPageFormProps) {
|
||||
const { isPending, run } = useServerAction()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
caption: catalogPage.caption,
|
||||
parentId: catalogPage.parentId,
|
||||
pageLayout: catalogPage.pageLayout,
|
||||
enabled: catalogPage.enabled as "0" | "1",
|
||||
visible: catalogPage.visible as "0" | "1",
|
||||
enabled: catalogPage.enabled as '0' | '1',
|
||||
visible: catalogPage.visible as '0' | '1',
|
||||
minRank: catalogPage.minRank,
|
||||
clubOnly: catalogPage.clubOnly as "0" | "1",
|
||||
clubOnly: catalogPage.clubOnly as '0' | '1',
|
||||
orderNum: catalogPage.orderNum,
|
||||
iconImage: catalogPage.iconImage,
|
||||
iconColor: catalogPage.iconColor,
|
||||
pageHeadline: catalogPage.pageHeadline,
|
||||
pageTeaser: catalogPage.pageTeaser,
|
||||
pageSpecial: catalogPage.pageSpecial || "",
|
||||
pageText1: catalogPage.pageText1 || "",
|
||||
pageText2: catalogPage.pageText2 || "",
|
||||
pageTextDetails: catalogPage.pageTextDetails || "",
|
||||
pageTextTeaser: catalogPage.pageTextTeaser || "",
|
||||
});
|
||||
pageSpecial: catalogPage.pageSpecial || '',
|
||||
pageText1: catalogPage.pageText1 || '',
|
||||
pageText2: catalogPage.pageText2 || '',
|
||||
pageTextDetails: catalogPage.pageTextDetails || '',
|
||||
pageTextTeaser: catalogPage.pageTextTeaser || '',
|
||||
})
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
if (!canEdit) return
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
formRef.current?.requestSubmit()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [canEdit]);
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [canEdit])
|
||||
|
||||
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
|
||||
successMessage: "Page updated and catalog synced.",
|
||||
errorMessage: "Failed to update.",
|
||||
});
|
||||
successMessage: 'Page updated and catalog synced.',
|
||||
errorMessage: 'Failed to update.',
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
const childCount = childPages.length;
|
||||
const childCount = childPages.length
|
||||
const msg =
|
||||
childCount > 0
|
||||
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to root. All items in this page will be deleted.`
|
||||
: "Delete this catalog page and all its items?";
|
||||
if (!confirm(msg)) return;
|
||||
: 'Delete this catalog page and all its items?'
|
||||
if (!confirm(msg)) return
|
||||
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
||||
successMessage: "Page deleted.",
|
||||
redirectTo: "/admin/catalog",
|
||||
});
|
||||
successMessage: 'Page deleted.',
|
||||
redirectTo: '/admin/catalog',
|
||||
})
|
||||
}
|
||||
|
||||
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
|
||||
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || 'bg-muted text-muted-foreground'
|
||||
|
||||
return (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
@@ -164,7 +175,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<Input
|
||||
id="caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
onChange={(e) => update('caption', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -172,7 +183,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<Label>Parent Page</Label>
|
||||
<ParentCombobox
|
||||
value={form.parentId}
|
||||
onChange={(id) => update("parentId", id)}
|
||||
onChange={(id) => update('parentId', id)}
|
||||
pages={allPages}
|
||||
currentPageId={catalogPage.id}
|
||||
disabled={!canEdit}
|
||||
@@ -184,11 +195,11 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<Label>Layout</Label>
|
||||
<LayoutSelect
|
||||
value={form.pageLayout}
|
||||
onChange={(v) => update("pageLayout", v)}
|
||||
onChange={(v) => update('pageLayout', v)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? "Custom layout"}
|
||||
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? 'Custom layout'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +210,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="orderNum"
|
||||
type="number"
|
||||
value={form.orderNum}
|
||||
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
|
||||
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -211,7 +222,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.minRank}
|
||||
onChange={(e) => update("minRank", parseInt(e.target.value, 10) || 1)}
|
||||
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -224,7 +235,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<Label>Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageHeadline}
|
||||
onChange={(v) => update("pageHeadline", v)}
|
||||
onChange={(v) => update('pageHeadline', v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
disabled={!canEdit}
|
||||
@@ -235,7 +246,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<Label>Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageTeaser}
|
||||
onChange={(v) => update("pageTeaser", v)}
|
||||
onChange={(v) => update('pageTeaser', v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
disabled={!canEdit}
|
||||
@@ -247,7 +258,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="pageText1"
|
||||
label="Text 1"
|
||||
value={form.pageText1}
|
||||
onChange={(v) => update("pageText1", v)}
|
||||
onChange={(v) => update('pageText1', v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
@@ -256,7 +267,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="pageText2"
|
||||
label="Text 2"
|
||||
value={form.pageText2}
|
||||
onChange={(v) => update("pageText2", v)}
|
||||
onChange={(v) => update('pageText2', v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
@@ -267,7 +278,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="pageTextDetails"
|
||||
label="Details"
|
||||
value={form.pageTextDetails}
|
||||
onChange={(v) => update("pageTextDetails", v)}
|
||||
onChange={(v) => update('pageTextDetails', v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
@@ -276,7 +287,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="pageTextTeaser"
|
||||
label="Text Teaser"
|
||||
value={form.pageTextTeaser}
|
||||
onChange={(v) => update("pageTextTeaser", v)}
|
||||
onChange={(v) => update('pageTextTeaser', v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
@@ -286,7 +297,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="pageSpecial"
|
||||
label="Special"
|
||||
value={form.pageSpecial}
|
||||
onChange={(v) => update("pageSpecial", v)}
|
||||
onChange={(v) => update('pageSpecial', v)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
max={2048}
|
||||
@@ -300,7 +311,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<div className="flex items-center gap-3">
|
||||
<IconPicker
|
||||
value={form.iconImage}
|
||||
onChange={(id) => update("iconImage", id)}
|
||||
onChange={(id) => update('iconImage', id)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -317,11 +328,12 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
min={0}
|
||||
className="w-32"
|
||||
value={form.iconColor}
|
||||
onChange={(e) => update("iconColor", parseInt(e.target.value, 10) || 0)}
|
||||
onChange={(e) => update('iconColor', parseInt(e.target.value, 10) || 0)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Legacy tint value. Leave at default unless you need to match a specific theme.
|
||||
Legacy tint value. Leave at default unless you need to match a specific
|
||||
theme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,10 +361,12 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
>
|
||||
<span>
|
||||
{child.caption}
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">#{child.id}</span>
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
||||
#{child.id}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={child.enabled === "1" ? "default" : "outline"}>
|
||||
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
||||
<Badge variant={child.enabled === '1' ? 'default' : 'outline'}>
|
||||
{child.enabled === '1' ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
@@ -375,8 +389,13 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{form.caption || "Untitled"}</p>
|
||||
<Badge className={cn("mt-1 h-5 px-1.5 text-[10px] font-mono border-0", layoutColorClass)}>
|
||||
<p className="truncate text-sm font-medium">{form.caption || 'Untitled'}</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
'mt-1 h-5 px-1.5 text-[10px] font-mono border-0',
|
||||
layoutColorClass,
|
||||
)}
|
||||
>
|
||||
{form.pageLayout}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -388,24 +407,24 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="tg-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the catalog"
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
checked={form.enabled === '1'}
|
||||
onChange={(v) => update('enabled', v ? '1' : '0')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-visible"
|
||||
label="Visible"
|
||||
description="Shown in navigation"
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
checked={form.visible === '1'}
|
||||
onChange={(v) => update('visible', v ? '1' : '0')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
checked={form.clubOnly === '1'}
|
||||
onChange={(v) => update('clubOnly', v ? '1' : '0')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -452,7 +471,7 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toggle row ─────────────────────────────────────────────────
|
||||
@@ -465,12 +484,12 @@ function ToggleRow({
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -482,7 +501,7 @@ function ToggleRow({
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Text field with counter ────────────────────────────────────
|
||||
@@ -495,12 +514,12 @@ function _TextField({
|
||||
disabled,
|
||||
max,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
max?: number;
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
max?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -520,7 +539,7 @@ function _TextField({
|
||||
maxLength={max}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function TextareaField({
|
||||
@@ -532,13 +551,13 @@ function TextareaField({
|
||||
rows = 3,
|
||||
max,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
max?: number;
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
rows?: number
|
||||
max?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -559,7 +578,7 @@ function TextareaField({
|
||||
maxLength={max}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Layout select with color swatch ────────────────────────────
|
||||
@@ -569,9 +588,9 @@ function LayoutSelect({
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||
@@ -580,19 +599,19 @@ function LayoutSelect({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATALOG_LAYOUTS.map((layout) => {
|
||||
const colorClass = LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
|
||||
const colorClass = LAYOUT_COLORS[layout] || 'bg-muted text-muted-foreground'
|
||||
return (
|
||||
<SelectItem key={layout} value={layout}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn("inline-block h-2 w-2 rounded-full", colorClass)} />
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full', colorClass)} />
|
||||
{layout}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Parent page combobox with search ────────────────────────────
|
||||
@@ -604,16 +623,16 @@ function ParentCombobox({
|
||||
currentPageId,
|
||||
disabled,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (id: number) => void;
|
||||
pages: { id: number; caption: string }[];
|
||||
currentPageId: number;
|
||||
disabled?: boolean;
|
||||
value: number
|
||||
onChange: (id: number) => void
|
||||
pages: { id: number; caption: string }[]
|
||||
currentPageId: number
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const options = pages.filter((p) => p.id !== currentPageId);
|
||||
const selected = value === -1 ? { id: -1, caption: "Root" } : pages.find((p) => p.id === value);
|
||||
const options = pages.filter((p) => p.id !== currentPageId)
|
||||
const selected = value === -1 ? { id: -1, caption: 'Root' } : pages.find((p) => p.id === value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -629,7 +648,7 @@ function ParentCombobox({
|
||||
{selected ? (
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
{selected.id === -1 ? "—" : `#${selected.id}`}
|
||||
{selected.id === -1 ? '—' : `#${selected.id}`}
|
||||
</span>
|
||||
{selected.caption}
|
||||
</span>
|
||||
@@ -648,11 +667,11 @@ function ParentCombobox({
|
||||
<CommandItem
|
||||
value="root"
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
setOpen(false);
|
||||
onChange(-1)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === -1 ? "opacity-100" : "opacity-0")} />
|
||||
<Check className={cn('mr-2 h-4 w-4', value === -1 ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">—</span>
|
||||
Root
|
||||
</CommandItem>
|
||||
@@ -661,11 +680,13 @@ function ParentCombobox({
|
||||
key={page.id}
|
||||
value={`${page.id} ${page.caption}`}
|
||||
onSelect={() => {
|
||||
onChange(page.id);
|
||||
setOpen(false);
|
||||
onChange(page.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === page.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
||||
<span className="truncate">{page.caption}</span>
|
||||
</CommandItem>
|
||||
@@ -675,5 +696,5 @@ function ParentCombobox({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -11,39 +11,46 @@ import {
|
||||
Save,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { translateCatalogItems } from "@/actions/catalog-items";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { FurniIcon } from "./catalog-items-table";
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { translateCatalogItems } from '@/actions/catalog-items'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import { FurniIcon } from './catalog-items-table'
|
||||
|
||||
interface BaseItemForTranslation {
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
id: number
|
||||
publicName: string
|
||||
itemName: string
|
||||
spriteId: number
|
||||
type: string
|
||||
}
|
||||
|
||||
interface CatalogTranslateTabProps {
|
||||
baseItems: BaseItemForTranslation[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
canEdit: boolean;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
|
||||
baseItems: BaseItemForTranslation[]
|
||||
catalogNameMap: Record<number, string>
|
||||
canEdit: boolean
|
||||
furniDataIdList: number[]
|
||||
furniDescriptionMap: Record<number, string>
|
||||
furniRevisionMap?: Record<number, { classname: string; revision: number }>
|
||||
}
|
||||
|
||||
type FilterMode = "all" | "missing" | "modified";
|
||||
type FilterMode = 'all' | 'missing' | 'modified'
|
||||
|
||||
export function CatalogTranslateTab({
|
||||
baseItems,
|
||||
@@ -53,164 +60,164 @@ export function CatalogTranslateTab({
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap = {},
|
||||
}: CatalogTranslateTabProps) {
|
||||
const router = useRouter();
|
||||
const { run, isPending } = useServerAction();
|
||||
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
||||
const router = useRouter()
|
||||
const { run, isPending } = useServerAction()
|
||||
const [isSuggesting, setIsSuggesting] = useState(false)
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all')
|
||||
|
||||
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList])
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
|
||||
// ── Editable state: names + descriptions ─────────────────────────
|
||||
const [editedNames, setEditedNames] = useState<Record<number, string>>(
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
|
||||
);
|
||||
)
|
||||
|
||||
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
|
||||
);
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])),
|
||||
)
|
||||
|
||||
// Track which items have name or description changes
|
||||
const isNameChanged = useCallback(
|
||||
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
|
||||
[editedNames],
|
||||
);
|
||||
)
|
||||
|
||||
const isDescChanged = useCallback(
|
||||
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
|
||||
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ''),
|
||||
[editedDescs, furniDescriptionMap],
|
||||
);
|
||||
)
|
||||
|
||||
const changedItems = useMemo(
|
||||
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
|
||||
[baseItems, isNameChanged, isDescChanged],
|
||||
);
|
||||
)
|
||||
|
||||
const missingItems = useMemo(
|
||||
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
|
||||
[baseItems, furniDataIdSet],
|
||||
);
|
||||
)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
switch (filterMode) {
|
||||
case "missing":
|
||||
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
|
||||
case "modified":
|
||||
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item));
|
||||
case 'missing':
|
||||
return baseItems.filter((item) => !furniDataIdSet.has(item.id))
|
||||
case 'modified':
|
||||
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item))
|
||||
default:
|
||||
return baseItems;
|
||||
return baseItems
|
||||
}
|
||||
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
|
||||
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged])
|
||||
|
||||
const toggleOne = useCallback((id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) =>
|
||||
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)),
|
||||
);
|
||||
}, [filteredItems]);
|
||||
)
|
||||
}, [filteredItems])
|
||||
|
||||
// Items to act on: selected ones if any, otherwise all
|
||||
const targetItems = useMemo(() => {
|
||||
if (selected.size === 0) return baseItems;
|
||||
return baseItems.filter((i) => selected.has(i.id));
|
||||
}, [baseItems, selected]);
|
||||
if (selected.size === 0) return baseItems
|
||||
return baseItems.filter((i) => selected.has(i.id))
|
||||
}, [baseItems, selected])
|
||||
|
||||
function handleNameChange(id: number, value: string) {
|
||||
setEditedNames((prev) => ({ ...prev, [id]: value }));
|
||||
setEditedNames((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
|
||||
function handleDescChange(id: number, value: string) {
|
||||
setEditedDescs((prev) => ({ ...prev, [id]: value }));
|
||||
setEditedDescs((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])));
|
||||
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])))
|
||||
setEditedDescs(
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
|
||||
);
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSuggestFromHabboFurni() {
|
||||
if (targetItems.length === 0) return;
|
||||
setIsSuggesting(true);
|
||||
if (targetItems.length === 0) return
|
||||
setIsSuggesting(true)
|
||||
|
||||
try {
|
||||
// Collect classnames from selected items (or all if none selected)
|
||||
const classnames = targetItems.map((item) => item.itemName).filter(Boolean);
|
||||
const classnames = targetItems.map((item) => item.itemName).filter(Boolean)
|
||||
if (classnames.length === 0) {
|
||||
toast.info("No classnames found to look up.");
|
||||
setIsSuggesting(false);
|
||||
return;
|
||||
toast.info('No classnames found to look up.')
|
||||
setIsSuggesting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Batch in chunks of 50 to avoid too-long URLs
|
||||
const CHUNK_SIZE = 50;
|
||||
const allSuggestions: Record<string, { name: string; description: string }> = {};
|
||||
const CHUNK_SIZE = 50
|
||||
const allSuggestions: Record<string, { name: string; description: string }> = {}
|
||||
|
||||
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
|
||||
const chunk = classnames.slice(i, i + CHUNK_SIZE);
|
||||
const chunk = classnames.slice(i, i + CHUNK_SIZE)
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
|
||||
);
|
||||
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(','))}`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
Object.assign(allSuggestions, data.suggestions || {});
|
||||
const data = await res.json()
|
||||
Object.assign(allSuggestions, data.suggestions || {})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply suggestions to items that have a match (names + descriptions)
|
||||
let nameCount = 0;
|
||||
let descCount = 0;
|
||||
const newNames = { ...editedNames };
|
||||
const newDescs = { ...editedDescs };
|
||||
let nameCount = 0
|
||||
let descCount = 0
|
||||
const newNames = { ...editedNames }
|
||||
const newDescs = { ...editedDescs }
|
||||
|
||||
for (const item of targetItems) {
|
||||
const suggestion = allSuggestions[item.itemName];
|
||||
if (!suggestion) continue;
|
||||
const suggestion = allSuggestions[item.itemName]
|
||||
if (!suggestion) continue
|
||||
|
||||
if (suggestion.name && suggestion.name !== item.publicName) {
|
||||
newNames[item.id] = suggestion.name;
|
||||
nameCount++;
|
||||
newNames[item.id] = suggestion.name
|
||||
nameCount++
|
||||
}
|
||||
|
||||
const currentDesc = furniDescriptionMap[item.id] ?? "";
|
||||
const currentDesc = furniDescriptionMap[item.id] ?? ''
|
||||
if (suggestion.description && suggestion.description !== currentDesc) {
|
||||
newDescs[item.id] = suggestion.description;
|
||||
descCount++;
|
||||
newDescs[item.id] = suggestion.description
|
||||
descCount++
|
||||
}
|
||||
}
|
||||
|
||||
setEditedNames(newNames);
|
||||
setEditedDescs(newDescs);
|
||||
setEditedNames(newNames)
|
||||
setEditedDescs(newDescs)
|
||||
|
||||
const total = nameCount + descCount;
|
||||
const total = nameCount + descCount
|
||||
if (total > 0) {
|
||||
const parts = [];
|
||||
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
|
||||
if (descCount > 0) parts.push(`${descCount} description(s)`);
|
||||
toast.success(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`);
|
||||
const parts = []
|
||||
if (nameCount > 0) parts.push(`${nameCount} name(s)`)
|
||||
if (descCount > 0) parts.push(`${descCount} description(s)`)
|
||||
toast.success(`HabboFurni: ${parts.join(' + ')} suggested from habbo.it. Review and save.`)
|
||||
} else {
|
||||
toast.info(
|
||||
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
|
||||
);
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`HabboFurni error: ${(err as Error).message}`);
|
||||
toast.error(`HabboFurni error: ${(err as Error).message}`)
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
setIsSuggesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAll() {
|
||||
if (changedItems.length === 0) return;
|
||||
if (changedItems.length === 0) return
|
||||
|
||||
run(
|
||||
() =>
|
||||
@@ -222,26 +229,26 @@ export function CatalogTranslateTab({
|
||||
})),
|
||||
}),
|
||||
{
|
||||
successMessage: "Translations saved.",
|
||||
errorMessage: "Failed to save translations.",
|
||||
successMessage: 'Translations saved.',
|
||||
errorMessage: 'Failed to save translations.',
|
||||
onSuccess: (data) => {
|
||||
const d = data as Record<string, number | boolean>;
|
||||
const parts = [];
|
||||
const d = data as Record<string, number | boolean>
|
||||
const parts = []
|
||||
if (d.namesUpdated && (d.namesUpdated as number) > 0)
|
||||
parts.push(`${d.namesUpdated} name(s) updated`);
|
||||
parts.push(`${d.namesUpdated} name(s) updated`)
|
||||
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
|
||||
parts.push(`${d.descriptionsUpdated} description(s) updated`);
|
||||
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`);
|
||||
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
|
||||
parts.push(`${d.descriptionsUpdated} description(s) updated`)
|
||||
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`)
|
||||
if (d.furniDataUpdated) parts.push('FurnitureData.json synced')
|
||||
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
|
||||
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`);
|
||||
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`)
|
||||
}
|
||||
parts.push("emulator cache refreshed");
|
||||
toast.success(`${parts.join(" · ")}.`);
|
||||
router.refresh();
|
||||
parts.push('emulator cache refreshed')
|
||||
toast.success(`${parts.join(' · ')}.`)
|
||||
router.refresh()
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,7 +274,8 @@ export function CatalogTranslateTab({
|
||||
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save
|
||||
{missingItems.length} item(s) missing from FurnitureData.json — will be
|
||||
auto-inserted on save
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -286,7 +294,7 @@ export function CatalogTranslateTab({
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -297,7 +305,11 @@ export function CatalogTranslateTab({
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAll}
|
||||
disabled={isPending || changedItems.length === 0}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -315,28 +327,28 @@ export function CatalogTranslateTab({
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filterMode === "all" ? "default" : "outline"}
|
||||
variant={filterMode === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode("all")}
|
||||
onClick={() => setFilterMode('all')}
|
||||
>
|
||||
All ({baseItems.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterMode === "missing" ? "default" : "outline"}
|
||||
variant={filterMode === 'missing' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode("missing")}
|
||||
onClick={() => setFilterMode('missing')}
|
||||
disabled={missingItems.length === 0}
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
Missing ({missingItems.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterMode === "modified" ? "default" : "outline"}
|
||||
variant={filterMode === 'modified' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode("modified")}
|
||||
onClick={() => setFilterMode('modified')}
|
||||
disabled={changedItems.length === 0}
|
||||
>
|
||||
Modified ({changedItems.length})
|
||||
@@ -350,7 +362,9 @@ export function CatalogTranslateTab({
|
||||
No items to translate in this page.
|
||||
</p>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No items match the current filter.</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No items match the current filter.
|
||||
</p>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
||||
@@ -375,19 +389,19 @@ export function CatalogTranslateTab({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => {
|
||||
const nameChanged = isNameChanged(item);
|
||||
const descChanged = isDescChanged(item);
|
||||
const isChanged = nameChanged || descChanged;
|
||||
const isMissing = !furniDataIdSet.has(item.id);
|
||||
const currentCatalogName = catalogNameMap[item.id];
|
||||
const nameChanged = isNameChanged(item)
|
||||
const descChanged = isDescChanged(item)
|
||||
const isChanged = nameChanged || descChanged
|
||||
const isMissing = !furniDataIdSet.has(item.id)
|
||||
const currentCatalogName = catalogNameMap[item.id]
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={
|
||||
isChanged
|
||||
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
|
||||
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]'
|
||||
: isMissing
|
||||
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
|
||||
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -400,18 +414,24 @@ export function CatalogTranslateTab({
|
||||
</TableCell>
|
||||
<TableCell className="px-1">
|
||||
{(() => {
|
||||
const revInfo = furniRevisionMap[item.id];
|
||||
const revInfo = furniRevisionMap[item.id]
|
||||
return revInfo ? (
|
||||
<FurniIcon classname={revInfo.classname} revision={revInfo.revision} />
|
||||
<FurniIcon
|
||||
classname={revInfo.classname}
|
||||
revision={revInfo.revision}
|
||||
/>
|
||||
) : (
|
||||
<FurniIcon classname={item.itemName} revision={0} />
|
||||
);
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs">
|
||||
{item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type}
|
||||
<Badge
|
||||
variant={item.type === 's' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{item.type === 's' ? 'Floor' : item.type === 'i' ? 'Wall' : item.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||
@@ -419,10 +439,10 @@ export function CatalogTranslateTab({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editedNames[item.id] || ""}
|
||||
value={editedNames[item.id] || ''}
|
||||
onChange={(e) => handleNameChange(item.id, e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
|
||||
className={`h-8 text-sm ${nameChanged ? 'border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]' : ''}`}
|
||||
/>
|
||||
{currentCatalogName && currentCatalogName !== item.publicName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||
@@ -432,11 +452,11 @@ export function CatalogTranslateTab({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editedDescs[item.id] || ""}
|
||||
value={editedDescs[item.id] || ''}
|
||||
onChange={(e) => handleDescChange(item.id, e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="No description"
|
||||
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
|
||||
className={`h-8 text-sm ${descChanged ? 'border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]' : ''}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -444,7 +464,7 @@ export function CatalogTranslateTab({
|
||||
{isChanged ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
|
||||
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
|
||||
>
|
||||
Modified
|
||||
</Badge>
|
||||
@@ -474,7 +494,7 @@ export function CatalogTranslateTab({
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -483,5 +503,5 @@ export function CatalogTranslateTab({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CatalogDetailLoading() {
|
||||
return (
|
||||
@@ -37,5 +37,5 @@ export default function CatalogDetailLoading() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,42 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { Fragment } from 'react'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { loadCatalogItemsData } from '@/lib/services/catalog-items-loader'
|
||||
import { getAncestors } from '@/lib/services/catalog-tree'
|
||||
import { CatalogDetailTabs } from './catalog-detail-tabs'
|
||||
|
||||
export default async function CatalogEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { id } = await params
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const pageId = parseInt(id, 10);
|
||||
if (Number.isNaN(pageId)) notFound();
|
||||
const pageId = parseInt(id, 10)
|
||||
if (Number.isNaN(pageId)) notFound()
|
||||
|
||||
const catalogPage = await prisma.catalogPages.findUnique({
|
||||
where: { id: pageId },
|
||||
});
|
||||
})
|
||||
|
||||
if (!catalogPage) notFound();
|
||||
if (!catalogPage) notFound()
|
||||
|
||||
const [children, itemsData, ancestors] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
where: { parentId: pageId },
|
||||
orderBy: { orderNum: "asc" },
|
||||
orderBy: { orderNum: 'asc' },
|
||||
select: { id: true, caption: true, enabled: true },
|
||||
}),
|
||||
loadCatalogItemsData(pageId),
|
||||
getAncestors(catalogPage.parentId),
|
||||
]);
|
||||
])
|
||||
|
||||
const {
|
||||
items: catalogItems,
|
||||
@@ -47,21 +47,24 @@ export default async function CatalogEditPage({
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
} = itemsData;
|
||||
} = itemsData
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Link href={"/admin/catalog"} className="hover:text-foreground transition-colors">
|
||||
<Link href={'/admin/catalog'} className="hover:text-foreground transition-colors">
|
||||
Catalog
|
||||
</Link>
|
||||
{ancestors.map((bc) => (
|
||||
<Fragment key={bc.id}>
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
||||
<Link href={`/admin/catalog/${bc.id}`} className="hover:text-foreground transition-colors">
|
||||
<Link
|
||||
href={`/admin/catalog/${bc.id}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{bc.caption}
|
||||
</Link>
|
||||
</Fragment>
|
||||
@@ -74,7 +77,9 @@ export default async function CatalogEditPage({
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{catalogPage.caption}
|
||||
<span className="ml-2 text-base font-normal text-muted-foreground">#{catalogPage.id}</span>
|
||||
<span className="ml-2 text-base font-normal text-muted-foreground">
|
||||
#{catalogPage.id}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Layout: {catalogPage.pageLayout} · {catalogItems.length} items
|
||||
@@ -116,5 +121,5 @@ export default async function CatalogEditPage({
|
||||
interactionTypes={interactionTypes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BcPageDetail } from "../bc-manager";
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BcPageDetail } from '../bc-manager'
|
||||
|
||||
export default async function BcPageDetailPage({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const { id: idStr } = await paramsPromise;
|
||||
const id = parseInt(idStr, 10);
|
||||
if (Number.isNaN(id)) notFound();
|
||||
const { id: idStr } = await paramsPromise
|
||||
const id = parseInt(idStr, 10)
|
||||
if (Number.isNaN(id)) notFound()
|
||||
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
||||
|
||||
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
|
||||
if (!bcPage) notFound();
|
||||
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } })
|
||||
if (!bcPage) notFound()
|
||||
|
||||
const bcItems = await prisma.catalogItemsBc.findMany({
|
||||
where: { pageId: id },
|
||||
orderBy: { orderNumber: "asc" },
|
||||
});
|
||||
orderBy: { orderNumber: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<BcPageDetail
|
||||
@@ -41,11 +41,11 @@ export default async function BcPageDetailPage({
|
||||
enabled: bcPage.enabled,
|
||||
pageHeadline: bcPage.pageHeadline,
|
||||
pageTeaser: bcPage.pageTeaser,
|
||||
pageSpecial: bcPage.pageSpecial ?? "",
|
||||
pageText1: bcPage.pageText1 ?? "",
|
||||
pageText2: bcPage.pageText2 ?? "",
|
||||
pageTextDetails: bcPage.pageTextDetails ?? "",
|
||||
pageTextTeaser: bcPage.pageTextTeaser ?? "",
|
||||
pageSpecial: bcPage.pageSpecial ?? '',
|
||||
pageText1: bcPage.pageText1 ?? '',
|
||||
pageText2: bcPage.pageText2 ?? '',
|
||||
pageTextDetails: bcPage.pageTextDetails ?? '',
|
||||
pageTextTeaser: bcPage.pageTextTeaser ?? '',
|
||||
}}
|
||||
items={bcItems.map((i) => ({
|
||||
id: i.id,
|
||||
@@ -57,5 +57,5 @@ export default async function BcPageDetailPage({
|
||||
}))}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,69 +1,82 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from "@/actions/catalog-bc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from '@/actions/catalog-bc'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BcPageData {
|
||||
id: number;
|
||||
parentId: number;
|
||||
caption: string;
|
||||
pageLayout: string;
|
||||
iconColor: number;
|
||||
iconImage: number;
|
||||
orderNum: number;
|
||||
visible: string;
|
||||
enabled: string;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string;
|
||||
pageText1: string;
|
||||
pageText2: string;
|
||||
pageTextDetails: string;
|
||||
pageTextTeaser: string;
|
||||
id: number
|
||||
parentId: number
|
||||
caption: string
|
||||
pageLayout: string
|
||||
iconColor: number
|
||||
iconImage: number
|
||||
orderNum: number
|
||||
visible: string
|
||||
enabled: string
|
||||
pageHeadline: string
|
||||
pageTeaser: string
|
||||
pageSpecial: string
|
||||
pageText1: string
|
||||
pageText2: string
|
||||
pageTextDetails: string
|
||||
pageTextTeaser: string
|
||||
}
|
||||
|
||||
export interface BcItemData {
|
||||
id: number;
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
orderNumber: number;
|
||||
extradata: string;
|
||||
id: number
|
||||
pageId: number
|
||||
itemIds: string
|
||||
catalogName: string
|
||||
orderNumber: number
|
||||
extradata: string
|
||||
}
|
||||
|
||||
interface BcPageDetailProps {
|
||||
page: BcPageData;
|
||||
items: BcItemData[];
|
||||
canEdit: boolean;
|
||||
page: BcPageData
|
||||
items: BcItemData[]
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────
|
||||
|
||||
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
|
||||
const [addingItem, setAddingItem] = useState(false);
|
||||
const { isPending, run } = useServerAction()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [editingItem, setEditingItem] = useState<BcItemData | null>(null)
|
||||
const [addingItem, setAddingItem] = useState(false)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
caption: page.caption,
|
||||
orderNum: page.orderNum,
|
||||
enabled: page.enabled as "0" | "1",
|
||||
visible: page.visible as "0" | "1",
|
||||
enabled: page.enabled as '0' | '1',
|
||||
visible: page.visible as '0' | '1',
|
||||
pageHeadline: page.pageHeadline,
|
||||
pageTeaser: page.pageTeaser,
|
||||
pageSpecial: page.pageSpecial,
|
||||
@@ -71,31 +84,31 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
pageText2: page.pageText2,
|
||||
pageTextDetails: page.pageTextDetails,
|
||||
pageTextTeaser: page.pageTextTeaser,
|
||||
});
|
||||
})
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
if (!canEdit) return
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
formRef.current?.requestSubmit()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [canEdit]);
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [canEdit])
|
||||
|
||||
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
run(() => updateBcPage({ id: page.id, ...form }), {
|
||||
successMessage: "BC page updated and catalog synced.",
|
||||
errorMessage: "Failed to update.",
|
||||
});
|
||||
successMessage: 'BC page updated and catalog synced.',
|
||||
errorMessage: 'Failed to update.',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -137,7 +150,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Input
|
||||
id="caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
onChange={(e) => update('caption', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -147,7 +160,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
id="orderNum"
|
||||
type="number"
|
||||
value={form.orderNum}
|
||||
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
|
||||
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -157,7 +170,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Headline</Label>
|
||||
<Input
|
||||
value={form.pageHeadline}
|
||||
onChange={(e) => update("pageHeadline", e.target.value)}
|
||||
onChange={(e) => update('pageHeadline', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="Headline image path"
|
||||
/>
|
||||
@@ -166,7 +179,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Teaser</Label>
|
||||
<Input
|
||||
value={form.pageTeaser}
|
||||
onChange={(e) => update("pageTeaser", e.target.value)}
|
||||
onChange={(e) => update('pageTeaser', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="Teaser image path"
|
||||
/>
|
||||
@@ -177,7 +190,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Text 1</Label>
|
||||
<Textarea
|
||||
value={form.pageText1}
|
||||
onChange={(e) => update("pageText1", e.target.value)}
|
||||
onChange={(e) => update('pageText1', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
@@ -186,7 +199,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Text 2</Label>
|
||||
<Textarea
|
||||
value={form.pageText2}
|
||||
onChange={(e) => update("pageText2", e.target.value)}
|
||||
onChange={(e) => update('pageText2', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
@@ -198,7 +211,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Details</Label>
|
||||
<Textarea
|
||||
value={form.pageTextDetails}
|
||||
onChange={(e) => update("pageTextDetails", e.target.value)}
|
||||
onChange={(e) => update('pageTextDetails', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
@@ -207,7 +220,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Text Teaser</Label>
|
||||
<Textarea
|
||||
value={form.pageTextTeaser}
|
||||
onChange={(e) => update("pageTextTeaser", e.target.value)}
|
||||
onChange={(e) => update('pageTextTeaser', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
@@ -218,7 +231,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Special</Label>
|
||||
<Textarea
|
||||
value={form.pageSpecial}
|
||||
onChange={(e) => update("pageSpecial", e.target.value)}
|
||||
onChange={(e) => update('pageSpecial', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
/>
|
||||
@@ -250,7 +263,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No items in this page.</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No items in this page.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -273,7 +288,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.orderNumber}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
||||
{item.extradata || "\u2014"}
|
||||
{item.extradata || '\u2014'}
|
||||
</TableCell>
|
||||
{canEdit && (
|
||||
<TableCell>
|
||||
@@ -293,10 +308,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
||||
onClick={() => {
|
||||
if (!confirm(`Delete item "${item.catalogName}"?`)) return;
|
||||
if (!confirm(`Delete item "${item.catalogName}"?`)) return
|
||||
run(() => deleteBcItem({ id: item.id }), {
|
||||
successMessage: "Item deleted.",
|
||||
});
|
||||
successMessage: 'Item deleted.',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
@@ -325,16 +340,16 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
id="tg-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the BC catalog"
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
checked={form.enabled === '1'}
|
||||
onChange={(v) => update('enabled', v ? '1' : '0')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-visible"
|
||||
label="Visible"
|
||||
description="Shown in BC navigation"
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
checked={form.visible === '1'}
|
||||
onChange={(v) => update('visible', v ? '1' : '0')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -345,8 +360,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
Layout: <span className="font-mono">{page.pageLayout}</span>
|
||||
</span>
|
||||
<span>
|
||||
Parent:{" "}
|
||||
<span className="font-mono">{page.parentId <= 0 ? "Root" : `#${page.parentId}`}</span>
|
||||
Parent:{' '}
|
||||
<span className="font-mono">
|
||||
{page.parentId <= 0 ? 'Root' : `#${page.parentId}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -381,9 +398,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
onClose={() => setEditingItem(null)}
|
||||
onSubmit={(data) => {
|
||||
run(() => updateBcItem({ id: editingItem.id, ...data }), {
|
||||
successMessage: "Item updated.",
|
||||
successMessage: 'Item updated.',
|
||||
onSuccess: () => setEditingItem(null),
|
||||
});
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -394,24 +411,24 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
item={{
|
||||
id: 0,
|
||||
pageId: page.id,
|
||||
itemIds: "",
|
||||
catalogName: "",
|
||||
itemIds: '',
|
||||
catalogName: '',
|
||||
orderNumber: 1,
|
||||
extradata: "",
|
||||
extradata: '',
|
||||
}}
|
||||
mode="add"
|
||||
isPending={isPending}
|
||||
onClose={() => setAddingItem(false)}
|
||||
onSubmit={(data) => {
|
||||
run(() => createBcItem({ pageId: page.id, ...data }), {
|
||||
successMessage: "Item created.",
|
||||
successMessage: 'Item created.',
|
||||
onSuccess: () => setAddingItem(false),
|
||||
});
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toggle row ───────────────────────────────────────────────────────
|
||||
@@ -424,12 +441,12 @@ function ToggleRow({
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -441,7 +458,7 @@ function ToggleRow({
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Item Dialog ──────────────────────────────────────────────────────
|
||||
@@ -453,24 +470,29 @@ function ItemDialog({
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
item: BcItemData;
|
||||
mode: "add" | "edit";
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { itemIds: string; catalogName: string; orderNumber: number; extradata: string }) => void;
|
||||
item: BcItemData
|
||||
mode: 'add' | 'edit'
|
||||
isPending: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: {
|
||||
itemIds: string
|
||||
catalogName: string
|
||||
orderNumber: number
|
||||
extradata: string
|
||||
}) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
itemIds: item.itemIds,
|
||||
catalogName: item.catalogName,
|
||||
orderNumber: item.orderNumber,
|
||||
extradata: item.extradata,
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}</DialogTitle>
|
||||
<DialogTitle>{mode === 'add' ? 'Add BC Item' : `Edit Item #${item.id}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
@@ -493,7 +515,9 @@ function ItemDialog({
|
||||
<Input
|
||||
type="number"
|
||||
value={form.orderNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -510,12 +534,15 @@ function ItemDialog({
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onSubmit(form)} disabled={isPending || !form.catalogName || !form.itemIds}>
|
||||
<Button
|
||||
onClick={() => onSubmit(form)}
|
||||
disabled={isPending || !form.catalogName || !form.itemIds}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === "add" ? "Add Item" : "Save"}
|
||||
{mode === 'add' ? 'Add Item' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function BuilderClubPage() {
|
||||
redirect("/admin/catalog?catalog=bc");
|
||||
redirect('/admin/catalog?catalog=bc')
|
||||
}
|
||||
@@ -1,57 +1,69 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createCatalogPage } from "@/actions/catalog";
|
||||
import { createBcPage } from "@/actions/catalog-bc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { createCatalogPage } from '@/actions/catalog'
|
||||
import { createBcPage } from '@/actions/catalog-bc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import { CATALOG_LAYOUTS } from '@/lib/catalog-layouts'
|
||||
|
||||
interface CatalogActionsProps {
|
||||
catalogType?: "normal" | "bc";
|
||||
catalogType?: 'normal' | 'bc'
|
||||
}
|
||||
|
||||
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [caption, setCaption] = useState("");
|
||||
const [parentId, setParentId] = useState("-1");
|
||||
const [pageLayout, setPageLayout] = useState("default_3x3");
|
||||
const isBc = catalogType === "bc";
|
||||
export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps) {
|
||||
const { run, isPending } = useServerAction()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [caption, setCaption] = useState('')
|
||||
const [parentId, setParentId] = useState('-1')
|
||||
const [pageLayout, setPageLayout] = useState('default_3x3')
|
||||
const isBc = catalogType === 'bc'
|
||||
|
||||
function handleCreate() {
|
||||
if (!caption.trim()) {
|
||||
toast.error("Caption is required");
|
||||
return;
|
||||
toast.error('Caption is required')
|
||||
return
|
||||
}
|
||||
|
||||
const input = {
|
||||
caption: caption.trim(),
|
||||
parentId: parseInt(parentId, 10),
|
||||
pageLayout,
|
||||
};
|
||||
}
|
||||
|
||||
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
|
||||
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input)
|
||||
|
||||
run(fn, {
|
||||
successMessage: `Catalog page "${caption.trim()}" created.`,
|
||||
onSuccess: (data) => {
|
||||
setOpen(false);
|
||||
setCaption("");
|
||||
setParentId("-1");
|
||||
setOpen(false)
|
||||
setCaption('')
|
||||
setParentId('-1')
|
||||
if (data.id) {
|
||||
window.location.href = isBc
|
||||
? `/admin/catalog/builder-club/${data.id}`
|
||||
: `/admin/catalog/${data.id}`;
|
||||
: `/admin/catalog/${data.id}`
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -64,7 +76,7 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isBc ? "Create Builder Club Page" : "Create Catalog Page"}</DialogTitle>
|
||||
<DialogTitle>{isBc ? 'Create Builder Club Page' : 'Create Catalog Page'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -112,5 +124,5 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,56 +1,57 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toggleCatalogPage } from "@/actions/catalog";
|
||||
import { toggleBcPage } from "@/actions/catalog-bc";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import { Pencil } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { toggleCatalogPage } from '@/actions/catalog'
|
||||
import { toggleBcPage } from '@/actions/catalog-bc'
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface CatalogPageRow {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
parentCaption: string;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
orderNum: number;
|
||||
itemCount: number;
|
||||
childCount: number;
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
parentCaption: string
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
orderNum: number
|
||||
itemCount: number
|
||||
childCount: number
|
||||
}
|
||||
|
||||
interface CatalogTableProps {
|
||||
data: PaginatedResult<CatalogPageRow>;
|
||||
canEdit: boolean;
|
||||
catalogType?: "normal" | "bc";
|
||||
data: PaginatedResult<CatalogPageRow>
|
||||
canEdit: boolean
|
||||
catalogType?: 'normal' | 'bc'
|
||||
}
|
||||
|
||||
export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogTableProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const isBc = catalogType === "bc";
|
||||
export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogTableProps) {
|
||||
const { run, isPending } = useServerAction()
|
||||
const isBc = catalogType === 'bc'
|
||||
|
||||
// Parent URLs stay in the same catalog; root takes you to the catalog index.
|
||||
const detailHref = (id: number) => (isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`);
|
||||
const detailHref = (id: number) =>
|
||||
isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`
|
||||
|
||||
function handleToggle(id: number, action: "toggleEnabled" | "toggleVisible") {
|
||||
function handleToggle(id: number, action: 'toggleEnabled' | 'toggleVisible') {
|
||||
if (isBc) {
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
|
||||
const field = action === 'toggleEnabled' ? 'enabled' : 'visible'
|
||||
run(() => toggleBcPage({ id, field }), { successMessage: 'Updated.' })
|
||||
} else {
|
||||
run(() => toggleCatalogPage({ id, action }), { successMessage: "Updated." });
|
||||
run(() => toggleCatalogPage({ id, action }), { successMessage: 'Updated.' })
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<CatalogPageRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{
|
||||
key: "caption",
|
||||
label: "Caption",
|
||||
key: 'caption',
|
||||
label: 'Caption',
|
||||
sortable: true,
|
||||
render: (value, row) => (
|
||||
<Link href={detailHref(row.id)} className="font-medium text-primary hover:underline">
|
||||
@@ -59,8 +60,8 @@ export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogT
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "parentId",
|
||||
label: "Parent",
|
||||
key: 'parentId',
|
||||
label: 'Parent',
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
row.parentId === -1 ? (
|
||||
@@ -72,78 +73,86 @@ export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogT
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pageLayout",
|
||||
label: "Layout",
|
||||
key: 'pageLayout',
|
||||
label: 'Layout',
|
||||
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Enabled",
|
||||
key: 'enabled',
|
||||
label: 'Enabled',
|
||||
sortable: true,
|
||||
filterKey: "filter_enabled",
|
||||
filterKey: 'filter_enabled',
|
||||
filterOptions: [
|
||||
{ label: "Enabled", value: "1" },
|
||||
{ label: "Disabled", value: "0" },
|
||||
{ label: 'Enabled', value: '1' },
|
||||
{ label: 'Disabled', value: '0' },
|
||||
],
|
||||
render: (value, row) =>
|
||||
canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle(row.id, "toggleEnabled")}
|
||||
onClick={() => handleToggle(row.id, 'toggleEnabled')}
|
||||
disabled={isPending}
|
||||
className="cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
||||
{value === '1' ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
||||
{value === '1' ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "visible",
|
||||
label: "Visible",
|
||||
key: 'visible',
|
||||
label: 'Visible',
|
||||
sortable: true,
|
||||
filterKey: "filter_visible",
|
||||
filterKey: 'filter_visible',
|
||||
filterOptions: [
|
||||
{ label: "Visible", value: "1" },
|
||||
{ label: "Hidden", value: "0" },
|
||||
{ label: 'Visible', value: '1' },
|
||||
{ label: 'Hidden', value: '0' },
|
||||
],
|
||||
render: (value, row) =>
|
||||
canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle(row.id, "toggleVisible")}
|
||||
onClick={() => handleToggle(row.id, 'toggleVisible')}
|
||||
disabled={isPending}
|
||||
className="cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
||||
{value === '1' ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
||||
{value === '1' ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
...(isBc
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "minRank" as const,
|
||||
label: "Min Rank",
|
||||
key: 'minRank' as const,
|
||||
label: 'Min Rank',
|
||||
sortable: true,
|
||||
} satisfies DataTableColumn<CatalogPageRow>,
|
||||
]),
|
||||
{
|
||||
key: "itemCount",
|
||||
label: "Items",
|
||||
key: 'itemCount',
|
||||
label: 'Items',
|
||||
render: (value) => <span>{String(value)}</span>,
|
||||
},
|
||||
{
|
||||
key: "childCount",
|
||||
label: "Children",
|
||||
key: 'childCount',
|
||||
label: 'Children',
|
||||
render: (value) => <span>{String(value)}</span>,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
render: (_, row) => (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={detailHref(row.id)}>
|
||||
@@ -152,7 +161,7 @@ export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogT
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
@@ -160,8 +169,10 @@ export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogT
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by caption, layout or item..."
|
||||
bulkActions={
|
||||
canEdit ? [{ label: "Delete Selected", value: "delete", variant: "destructive" }] : undefined
|
||||
canEdit
|
||||
? [{ label: 'Delete Selected', value: 'delete', variant: 'destructive' }]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { FolderTree, HardHat, List, Package } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FolderTree, HardHat, List, Package } from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CatalogViewToggleProps {
|
||||
currentView: string;
|
||||
catalogType?: string;
|
||||
currentView: string
|
||||
catalogType?: string
|
||||
}
|
||||
|
||||
export function CatalogViewToggle({ currentView, catalogType = "normal" }: CatalogViewToggleProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
export function CatalogViewToggle({ currentView, catalogType = 'normal' }: CatalogViewToggleProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
function setView(view: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (view === "tree") {
|
||||
params.delete("view");
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (view === 'tree') {
|
||||
params.delete('view')
|
||||
} else {
|
||||
params.set("view", view);
|
||||
params.set('view', view)
|
||||
}
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`)
|
||||
}
|
||||
|
||||
function setCatalog(type: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (type === "normal") {
|
||||
params.delete("catalog");
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (type === 'normal') {
|
||||
params.delete('catalog')
|
||||
} else {
|
||||
params.set("catalog", type);
|
||||
params.set('catalog', type)
|
||||
}
|
||||
params.delete("view"); // reset to tree when switching
|
||||
router.push(`?${params.toString()}`);
|
||||
params.delete('view') // reset to tree when switching
|
||||
router.push(`?${params.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -42,8 +42,8 @@ export function CatalogViewToggle({ currentView, catalogType = "normal" }: Catal
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 rounded-r-none", catalogType === "normal" && "bg-accent")}
|
||||
onClick={() => setCatalog("normal")}
|
||||
className={cn('h-8 rounded-r-none', catalogType === 'normal' && 'bg-accent')}
|
||||
onClick={() => setCatalog('normal')}
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
Catalog
|
||||
@@ -51,8 +51,8 @@ export function CatalogViewToggle({ currentView, catalogType = "normal" }: Catal
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 rounded-l-none", catalogType === "bc" && "bg-accent")}
|
||||
onClick={() => setCatalog("bc")}
|
||||
className={cn('h-8 rounded-l-none', catalogType === 'bc' && 'bg-accent')}
|
||||
onClick={() => setCatalog('bc')}
|
||||
>
|
||||
<HardHat className="mr-1.5 h-4 w-4" />
|
||||
Builder Club
|
||||
@@ -64,8 +64,8 @@ export function CatalogViewToggle({ currentView, catalogType = "normal" }: Catal
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 rounded-r-none", currentView === "tree" && "bg-accent")}
|
||||
onClick={() => setView("tree")}
|
||||
className={cn('h-8 rounded-r-none', currentView === 'tree' && 'bg-accent')}
|
||||
onClick={() => setView('tree')}
|
||||
>
|
||||
<FolderTree className="mr-1.5 h-4 w-4" />
|
||||
Tree
|
||||
@@ -73,13 +73,13 @@ export function CatalogViewToggle({ currentView, catalogType = "normal" }: Catal
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 rounded-l-none", currentView === "table" && "bg-accent")}
|
||||
onClick={() => setView("table")}
|
||||
className={cn('h-8 rounded-l-none', currentView === 'table' && 'bg-accent')}
|
||||
onClick={() => setView('table')}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
Table
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CatalogLoading() {
|
||||
return (
|
||||
@@ -40,5 +40,5 @@ export default function CatalogLoading() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
+146
-141
@@ -1,87 +1,87 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CatalogActions } from "./catalog-actions";
|
||||
import { CatalogTable } from "./catalog-table";
|
||||
import { CatalogViewToggle } from "./catalog-view-toggle";
|
||||
import dynamic from 'next/dynamic'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { CatalogTree } from '@/components/admin/catalog-tree'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { CatalogActions } from './catalog-actions'
|
||||
import { CatalogTable } from './catalog-table'
|
||||
import { CatalogViewToggle } from './catalog-view-toggle'
|
||||
|
||||
const CatalogManagerDialog = dynamic(() =>
|
||||
import("@/components/admin/catalog-manager/catalog-manager-dialog").then((m) => ({
|
||||
import('@/components/admin/catalog-manager/catalog-manager-dialog').then((m) => ({
|
||||
default: m.CatalogManagerDialog,
|
||||
})),
|
||||
);
|
||||
)
|
||||
export default async function CatalogPagesPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
await paramsPromise;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
await paramsPromise
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
||||
|
||||
const params = await searchParams;
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page, sort, order } = parseListParams(sp);
|
||||
const viewMode = sp.get("view") || "tree";
|
||||
const catalogType = sp.get("catalog") || "normal";
|
||||
const params = await searchParams
|
||||
const sp = new URLSearchParams(params)
|
||||
const { search, perPage, page, sort, order } = parseListParams(sp)
|
||||
const viewMode = sp.get('view') || 'tree'
|
||||
const catalogType = sp.get('catalog') || 'normal'
|
||||
|
||||
// ── Builder Club mode ──────────────────────────────────────────
|
||||
type TreeNodeShape = {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageLayout: string;
|
||||
childCount: number;
|
||||
itemCount: number;
|
||||
};
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
enabled: string
|
||||
visible: string
|
||||
iconImage: number
|
||||
iconColor: number
|
||||
pageLayout: string
|
||||
childCount: number
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
if (catalogType === "bc") {
|
||||
if (catalogType === 'bc') {
|
||||
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
|
||||
prisma.catalogPagesBc.count(),
|
||||
prisma.catalogItemsBc.count(),
|
||||
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
|
||||
]);
|
||||
prisma.catalogPagesBc.count({ where: { enabled: '1' } }),
|
||||
])
|
||||
|
||||
// ── BC tree view ──────────────────────────────────────────────
|
||||
let bcRootPages: TreeNodeShape[] = [];
|
||||
if (viewMode === "tree") {
|
||||
let bcRootPages: TreeNodeShape[] = []
|
||||
if (viewMode === 'tree') {
|
||||
const bcRootRaw = await prisma.catalogPagesBc.findMany({
|
||||
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
|
||||
orderBy: { orderNum: "asc" },
|
||||
});
|
||||
const bcRootIds = bcRootRaw.map((p) => p.id);
|
||||
orderBy: { orderNum: 'asc' },
|
||||
})
|
||||
const bcRootIds = bcRootRaw.map((p) => p.id)
|
||||
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
||||
bcRootIds.length > 0
|
||||
? prisma.catalogPagesBc.groupBy({
|
||||
by: ["parentId"],
|
||||
by: ['parentId'],
|
||||
where: { parentId: { in: bcRootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
bcRootIds.length > 0
|
||||
? prisma.catalogItemsBc.groupBy({
|
||||
by: ["pageId"],
|
||||
by: ['pageId'],
|
||||
where: { pageId: { in: bcRootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
|
||||
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
|
||||
])
|
||||
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]))
|
||||
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]))
|
||||
bcRootPages = bcRootRaw.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
@@ -93,45 +93,45 @@ export default async function CatalogPagesPage({
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: bcChildMap[p.id] ?? 0,
|
||||
itemCount: bcItemMap[p.id] ?? 0,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
// ── BC table view ─────────────────────────────────────────────
|
||||
let bcRows: {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
parentCaption: string;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
orderNum: number;
|
||||
itemCount: number;
|
||||
childCount: number;
|
||||
}[] = [];
|
||||
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
parentCaption: string
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
orderNum: number
|
||||
itemCount: number
|
||||
childCount: number
|
||||
}[] = []
|
||||
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 }
|
||||
|
||||
if (viewMode === "table") {
|
||||
const parentFilter = sp.get("parent");
|
||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
||||
if (viewMode === 'table') {
|
||||
const parentFilter = sp.get('parent')
|
||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
||||
const where: Record<string, unknown> = {}
|
||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
|
||||
if (search) {
|
||||
const pattern = { contains: search };
|
||||
where.OR = [{ caption: pattern }];
|
||||
const pattern = { contains: search }
|
||||
where.OR = [{ caption: pattern }]
|
||||
}
|
||||
if (hasParent) where.parentId = parentId;
|
||||
if (hasParent) where.parentId = parentId
|
||||
|
||||
// Prisma sort key is snake_case for BC model. Translate known keys.
|
||||
const sortKeyMap: Record<string, string> = {
|
||||
parentId: "parentId",
|
||||
pageLayout: "pageLayout",
|
||||
orderNum: "orderNum",
|
||||
};
|
||||
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
|
||||
const orderBy = { [sortKey]: sort ? order : "asc" };
|
||||
parentId: 'parentId',
|
||||
pageLayout: 'pageLayout',
|
||||
orderNum: 'orderNum',
|
||||
}
|
||||
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'orderNum'
|
||||
const orderBy = { [sortKey]: sort ? order : 'asc' }
|
||||
|
||||
const [pages, total] = await Promise.all([
|
||||
prisma.catalogPagesBc.findMany({
|
||||
@@ -141,23 +141,23 @@ export default async function CatalogPagesPage({
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.catalogPagesBc.count({ where }),
|
||||
]);
|
||||
])
|
||||
|
||||
bcPagination = calcPagination(total, page, perPage);
|
||||
bcPagination = calcPagination(total, page, perPage)
|
||||
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
|
||||
const pageIds = pages.map((p) => p.id)
|
||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
|
||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
||||
pageIds.length > 0
|
||||
? prisma.catalogPagesBc.groupBy({
|
||||
by: ["parentId"],
|
||||
by: ['parentId'],
|
||||
where: { parentId: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
pageIds.length > 0
|
||||
? prisma.catalogItemsBc.groupBy({
|
||||
by: ["pageId"],
|
||||
by: ['pageId'],
|
||||
where: { pageId: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
@@ -168,16 +168,17 @@ export default async function CatalogPagesPage({
|
||||
select: { id: true, caption: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
|
||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
|
||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
|
||||
])
|
||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
|
||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
|
||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
|
||||
|
||||
bcRows = pages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||
parentCaption:
|
||||
p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||
pageLayout: String(p.pageLayout),
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
@@ -185,7 +186,7 @@ export default async function CatalogPagesPage({
|
||||
orderNum: p.orderNum,
|
||||
itemCount: itemCountMap[p.id] ?? 0,
|
||||
childCount: childCountMap[p.id] ?? 0,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -202,13 +203,17 @@ export default async function CatalogPagesPage({
|
||||
{canEdit && <CatalogActions catalogType="bc" />}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "tree" ? (
|
||||
{viewMode === 'tree' ? (
|
||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
||||
) : (
|
||||
<CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
|
||||
<CatalogTable
|
||||
data={{ rows: bcRows, ...bcPagination }}
|
||||
canEdit={canEdit}
|
||||
catalogType="bc"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Normal catalog mode ────────────────────────────────────────
|
||||
@@ -217,35 +222,35 @@ export default async function CatalogPagesPage({
|
||||
const [totalPages, totalItems, enabledPages] = await Promise.all([
|
||||
prisma.catalogPages.count(),
|
||||
prisma.catalogItems.count(),
|
||||
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
||||
]);
|
||||
prisma.catalogPages.count({ where: { enabled: '1' } }),
|
||||
])
|
||||
|
||||
// ── Tree view data ──────────────────────────────────────────────
|
||||
let rootPages: TreeNodeShape[] = [];
|
||||
if (viewMode === "tree") {
|
||||
let rootPages: TreeNodeShape[] = []
|
||||
if (viewMode === 'tree') {
|
||||
const rootPagesRaw = await prisma.catalogPages.findMany({
|
||||
where: { parentId: -1 },
|
||||
orderBy: { orderNum: "asc" },
|
||||
});
|
||||
const rootIds = rootPagesRaw.map((p) => p.id);
|
||||
orderBy: { orderNum: 'asc' },
|
||||
})
|
||||
const rootIds = rootPagesRaw.map((p) => p.id)
|
||||
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
||||
rootIds.length > 0
|
||||
? prisma.catalogPages.groupBy({
|
||||
by: ["parentId"],
|
||||
by: ['parentId'],
|
||||
where: { parentId: { in: rootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
rootIds.length > 0
|
||||
? prisma.catalogItems.groupBy({
|
||||
by: ["pageId"],
|
||||
by: ['pageId'],
|
||||
where: { pageId: { in: rootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
|
||||
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
|
||||
])
|
||||
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]))
|
||||
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]))
|
||||
rootPages = rootPagesRaw.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
@@ -257,34 +262,34 @@ export default async function CatalogPagesPage({
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: rootChildMap[p.id] ?? 0,
|
||||
itemCount: rootItemMap[p.id] ?? 0,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Table view data (skip if tree) ────────────────────────────
|
||||
let rows: {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
parentCaption: string;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
orderNum: number;
|
||||
itemCount: number;
|
||||
childCount: number;
|
||||
}[] = [];
|
||||
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
parentCaption: string
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
orderNum: number
|
||||
itemCount: number
|
||||
childCount: number
|
||||
}[] = []
|
||||
let pagination = { total: 0, page: 1, perPage, lastPage: 0 }
|
||||
|
||||
if (viewMode === "table") {
|
||||
const parentFilter = sp.get("parent");
|
||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
||||
if (viewMode === 'table') {
|
||||
const parentFilter = sp.get('parent')
|
||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
||||
const where: Record<string, unknown> = {}
|
||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
|
||||
if (search) {
|
||||
const searchPattern = `%${search}%`;
|
||||
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
|
||||
const searchPattern = `%${search}%`
|
||||
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty
|
||||
|
||||
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
|
||||
SELECT DISTINCT cp.id FROM catalog_pages cp
|
||||
@@ -296,13 +301,13 @@ export default async function CatalogPagesPage({
|
||||
OR ib.public_name LIKE ${searchPattern}
|
||||
OR ib.item_name LIKE ${searchPattern})
|
||||
${parentClause}
|
||||
`);
|
||||
where.id = { in: matchedIds.map((r) => r.id) };
|
||||
`)
|
||||
where.id = { in: matchedIds.map((r) => r.id) }
|
||||
} else if (hasParent) {
|
||||
where.parentId = parentId;
|
||||
where.parentId = parentId
|
||||
}
|
||||
|
||||
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
|
||||
const orderBy = sort ? { [sort]: order } : { orderNum: 'asc' as const }
|
||||
|
||||
const [pages, total] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
@@ -312,24 +317,24 @@ export default async function CatalogPagesPage({
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.catalogPages.count({ where }),
|
||||
]);
|
||||
])
|
||||
|
||||
pagination = calcPagination(total, page, perPage);
|
||||
pagination = calcPagination(total, page, perPage)
|
||||
|
||||
// Count children and items per page, and resolve parent names in parallel
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
|
||||
const pageIds = pages.map((p) => p.id)
|
||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
|
||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
||||
pageIds.length > 0
|
||||
? prisma.catalogPages.groupBy({
|
||||
by: ["parentId"],
|
||||
by: ['parentId'],
|
||||
where: { parentId: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
pageIds.length > 0
|
||||
? prisma.catalogItems.groupBy({
|
||||
by: ["pageId"],
|
||||
by: ['pageId'],
|
||||
where: { pageId: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
@@ -340,16 +345,16 @@ export default async function CatalogPagesPage({
|
||||
select: { id: true, caption: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
|
||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
|
||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
|
||||
])
|
||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
|
||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
|
||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
|
||||
|
||||
rows = pages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||
parentCaption: p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||
pageLayout: p.pageLayout,
|
||||
enabled: p.enabled,
|
||||
visible: p.visible,
|
||||
@@ -357,7 +362,7 @@ export default async function CatalogPagesPage({
|
||||
orderNum: p.orderNum,
|
||||
itemCount: itemCountMap[p.id] ?? 0,
|
||||
childCount: childCountMap[p.id] ?? 0,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -376,11 +381,11 @@ export default async function CatalogPagesPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
{viewMode === 'tree' ? (
|
||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
||||
) : (
|
||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -616,7 +616,7 @@ export default async function CommandoCentrum() {
|
||||
{e.version}
|
||||
</td>
|
||||
<td>
|
||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||
{snippet}
|
||||
</pre>
|
||||
</td>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export default async function EmulatorErrorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
const perPage = 50;
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number(params.page) || 1)
|
||||
const perPage = 50
|
||||
|
||||
const [errors, total] = await Promise.all([
|
||||
prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
orderBy: { id: 'desc' },
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.emulatorErrors.count(),
|
||||
]);
|
||||
])
|
||||
|
||||
const lastPage = Math.ceil(total / perPage);
|
||||
const lastPage = Math.ceil(total / perPage)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -52,7 +52,7 @@ export default async function EmulatorErrorsPage({
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
|
||||
{Buffer.from(error.stacktrace).toString("utf-8")}
|
||||
{Buffer.from(error.stacktrace).toString('utf-8')}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
@@ -79,5 +79,5 @@ export default async function EmulatorErrorsPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,55 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function HealthCheckClient() {
|
||||
const [status, setStatus] = useState<"checking" | "online" | "offline">("checking");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking')
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
async function checkEmulator() {
|
||||
setChecking(true);
|
||||
setStatus("checking");
|
||||
setChecking(true)
|
||||
setStatus('checking')
|
||||
try {
|
||||
const res = await fetch("/api/admin/devops/health");
|
||||
const res = await fetch('/api/admin/devops/health')
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data.emulator ? "online" : "offline");
|
||||
const data = await res.json()
|
||||
setStatus(data.emulator ? 'online' : 'offline')
|
||||
} else {
|
||||
setStatus("offline");
|
||||
setStatus('offline')
|
||||
}
|
||||
} catch {
|
||||
setStatus("offline");
|
||||
setStatus('offline')
|
||||
} finally {
|
||||
setChecking(false);
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
checkEmulator();
|
||||
}, []);
|
||||
checkEmulator()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={status === "online" ? "default" : status === "offline" ? "destructive" : "secondary"}>
|
||||
{status === "checking" ? "Checking..." : status === "online" ? "Online" : "Offline"}
|
||||
<Badge
|
||||
variant={
|
||||
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
|
||||
}
|
||||
>
|
||||
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={checkEmulator} disabled={checking}>
|
||||
<RefreshCw className={`h-3 w-3 ${checking ? "animate-spin" : ""}`} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={checkEmulator}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,61 +1,61 @@
|
||||
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { HealthCheckClient } from "./health-check-client";
|
||||
import { AlertTriangle, Database, HeartPulse, Server } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { HealthCheckClient } from './health-check-client'
|
||||
|
||||
async function getDevOpsData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const dayAgo = now - 86400;
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const dayAgo = now - 86400
|
||||
|
||||
// DB health — simple count query
|
||||
let dbStatus: "healthy" | "error" = "healthy";
|
||||
let dbLatency = 0;
|
||||
let dbStatus: 'healthy' | 'error' = 'healthy'
|
||||
let dbLatency = 0
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.user.count({ take: 1 });
|
||||
dbLatency = Date.now() - start;
|
||||
const start = Date.now()
|
||||
await prisma.user.count({ take: 1 })
|
||||
dbLatency = Date.now() - start
|
||||
} catch {
|
||||
dbStatus = "error";
|
||||
dbStatus = 'error'
|
||||
}
|
||||
|
||||
// Recent emulator errors
|
||||
const recentErrors = await prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
})
|
||||
|
||||
const errorsToday = await prisma.emulatorErrors.count({
|
||||
where: { timestamp: { gte: dayAgo } },
|
||||
});
|
||||
})
|
||||
|
||||
// Emulator settings (version info)
|
||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
||||
where: { key: "version" },
|
||||
});
|
||||
where: { key: 'version' },
|
||||
})
|
||||
|
||||
// Online user count as proxy for emulator health
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
const onlineUsers = await prisma.user.count({ where: { online: '1' } })
|
||||
|
||||
return {
|
||||
dbStatus,
|
||||
dbLatency,
|
||||
recentErrors,
|
||||
errorsToday,
|
||||
emuVersion: emuVersion?.value ?? "Unknown",
|
||||
emuVersion: emuVersion?.value ?? 'Unknown',
|
||||
onlineUsers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DevOpsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const data = await getDevOpsData();
|
||||
const data = await getDevOpsData()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -72,8 +72,8 @@ export default async function DevOpsPage() {
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={data.dbStatus === "healthy" ? "default" : "destructive"}>
|
||||
{data.dbStatus === "healthy" ? "Healthy" : "Error"}
|
||||
<Badge variant={data.dbStatus === 'healthy' ? 'default' : 'destructive'}>
|
||||
{data.dbStatus === 'healthy' ? 'Healthy' : 'Error'}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p>
|
||||
</CardContent>
|
||||
@@ -134,7 +134,7 @@ export default async function DevOpsPage() {
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
|
||||
{Buffer.from(error.stacktrace).toString("utf-8")}
|
||||
{Buffer.from(error.stacktrace).toString('utf-8')}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
@@ -143,5 +143,5 @@ export default async function DevOpsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,56 +1,62 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { addEventPrize, deleteEventPrize } from "@/actions/events";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { addEventPrize, deleteEventPrize } from '@/actions/events'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
interface Prize {
|
||||
id: number;
|
||||
position: number;
|
||||
prizeType: string;
|
||||
badgeCode: string | null;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
points: number;
|
||||
itemId: number | null;
|
||||
description: string;
|
||||
id: number
|
||||
position: number
|
||||
prizeType: string
|
||||
badgeCode: string | null
|
||||
credits: number
|
||||
pixels: number
|
||||
points: number
|
||||
itemId: number | null
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eventId: number;
|
||||
prizes: Prize[];
|
||||
eventId: number
|
||||
prizes: Prize[]
|
||||
}
|
||||
|
||||
export function EventPrizesManager({ eventId, prizes }: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { run, isPending } = useServerAction()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
position: prizes.length + 1,
|
||||
prizeType: "badge" as string,
|
||||
badgeCode: "",
|
||||
prizeType: 'badge' as string,
|
||||
badgeCode: '',
|
||||
credits: 0,
|
||||
pixels: 0,
|
||||
points: 0,
|
||||
description: "",
|
||||
});
|
||||
description: '',
|
||||
})
|
||||
|
||||
function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
run(() => addEventPrize({ eventId, ...form } as any), {
|
||||
successMessage: "Prize added!",
|
||||
successMessage: 'Prize added!',
|
||||
onSuccess: () => {
|
||||
setShowForm(false);
|
||||
setForm({ ...form, position: form.position + 1, badgeCode: "", description: "" });
|
||||
setShowForm(false)
|
||||
setForm({ ...form, position: form.position + 1, badgeCode: '', description: '' })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -75,7 +81,10 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Type</Label>
|
||||
<Select value={form.prizeType} onValueChange={(v) => setForm({ ...form, prizeType: v })}>
|
||||
<Select
|
||||
value={form.prizeType}
|
||||
onValueChange={(v) => setForm({ ...form, prizeType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -124,11 +133,16 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
||||
)}
|
||||
|
||||
{prizes.length === 0 && !showForm && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No prizes configured yet.</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No prizes configured yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{prizes.map((prize) => (
|
||||
<div key={prize.id} className="flex items-center justify-between border rounded-lg px-4 py-3">
|
||||
<div
|
||||
key={prize.id}
|
||||
className="flex items-center justify-between border rounded-lg px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-accent font-bold text-sm">
|
||||
#{prize.position}
|
||||
@@ -141,14 +155,16 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
||||
{prize.pixels > 0 && <span className="text-sm">{prize.pixels} pixels</span>}
|
||||
{prize.points > 0 && <span className="text-sm">{prize.points} points</span>}
|
||||
</div>
|
||||
{prize.description && <p className="text-xs text-muted-foreground">{prize.description}</p>}
|
||||
{prize.description && (
|
||||
<p className="text-xs text-muted-foreground">{prize.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
run(() => deleteEventPrize({ id: prize.id }), { successMessage: "Prize removed" })
|
||||
run(() => deleteEventPrize({ id: prize.id }), { successMessage: 'Prize removed' })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
@@ -157,5 +173,5 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface Registration {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface Props {
|
||||
registrations: Registration[];
|
||||
registrations: Registration[]
|
||||
}
|
||||
|
||||
export function EventRegistrations({ registrations }: Props) {
|
||||
@@ -36,5 +36,5 @@ export function EventRegistrations({ registrations }: Props) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,43 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { addEventWinner } from "@/actions/events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { addEventWinner } from '@/actions/events'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
interface Winner {
|
||||
id: number;
|
||||
userId: number;
|
||||
position: number;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
id: number
|
||||
userId: number
|
||||
position: number
|
||||
username: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eventId: number;
|
||||
winners: Winner[];
|
||||
eventId: number
|
||||
winners: Winner[]
|
||||
}
|
||||
|
||||
export function EventWinnersManager({ eventId, winners }: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [userId, setUserId] = useState("");
|
||||
const [position, setPosition] = useState(winners.length + 1);
|
||||
const { run, isPending } = useServerAction()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [userId, setUserId] = useState('')
|
||||
const [position, setPosition] = useState(winners.length + 1)
|
||||
|
||||
function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
run(() => addEventWinner({ eventId, userId: Number(userId), position }), {
|
||||
successMessage: "Winner added!",
|
||||
successMessage: 'Winner added!',
|
||||
onSuccess: () => {
|
||||
setShowForm(false);
|
||||
setUserId("");
|
||||
setPosition(position + 1);
|
||||
setShowForm(false)
|
||||
setUserId('')
|
||||
setPosition(position + 1)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -95,5 +95,5 @@ export function EventWinnersManager({ eventId, winners }: Props) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,70 +1,75 @@
|
||||
import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventForm } from "../event-form";
|
||||
import { EventPrizesManager } from "./event-prizes";
|
||||
import { EventRegistrations } from "./event-registrations";
|
||||
import { EventWinnersManager } from "./event-winners";
|
||||
import { CalendarDays, Gift, Trophy, Users } from 'lucide-react'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { EventForm } from '../event-form'
|
||||
import { EventPrizesManager } from './event-prizes'
|
||||
import { EventRegistrations } from './event-registrations'
|
||||
import { EventWinnersManager } from './event-winners'
|
||||
|
||||
export default async function EventDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { id } = await params
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
||||
redirect("/admin/events");
|
||||
redirect('/admin/events')
|
||||
}
|
||||
|
||||
const eventId = parseInt(id, 10);
|
||||
if (Number.isNaN(eventId)) notFound();
|
||||
const eventId = parseInt(id, 10)
|
||||
if (Number.isNaN(eventId)) notFound()
|
||||
|
||||
const event = await prisma.websiteEvent.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
type: true,
|
||||
prizes: { orderBy: { position: "asc" } },
|
||||
winners: { orderBy: { position: "asc" } },
|
||||
registrations: { orderBy: { createdAt: "desc" } },
|
||||
prizes: { orderBy: { position: 'asc' } },
|
||||
winners: { orderBy: { position: 'asc' } },
|
||||
registrations: { orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (!event) notFound();
|
||||
if (!event) notFound()
|
||||
|
||||
const eventTypes = await prisma.websiteEventType.findMany({
|
||||
where: { isActive: 1 },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
// Fetch usernames for registrations and winners
|
||||
const userIds = [...event.registrations.map((r) => r.userId), ...event.winners.map((w) => w.userId)];
|
||||
const userIds = [
|
||||
...event.registrations.map((r) => r.userId),
|
||||
...event.winners.map((w) => w.userId),
|
||||
]
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: [...new Set(userIds)] } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
: []
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<AdminBreadcrumb items={[{ label: "Events", href: "/admin/events" }, { label: event.title }]} />
|
||||
<AdminBreadcrumb
|
||||
items={[{ label: 'Events', href: '/admin/events' }, { label: event.title }]}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
event.status === "published"
|
||||
? "default"
|
||||
: event.status === "cancelled"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
event.status === 'published'
|
||||
? 'default'
|
||||
: event.status === 'cancelled'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{event.status}
|
||||
@@ -84,7 +89,9 @@ export default async function EventDetailPage({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{event.registrations.length}</div>
|
||||
{event.maxPlayers && <p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>}
|
||||
{event.maxPlayers && (
|
||||
<p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -112,10 +119,12 @@ export default async function EventDetailPage({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-medium">
|
||||
{new Date(event.startsAt) > new Date() ? "Upcoming" : "Past"}
|
||||
{new Date(event.startsAt) > new Date() ? 'Upcoming' : 'Past'}
|
||||
</div>
|
||||
{event.endsAt && (
|
||||
<p className="text-xs text-muted-foreground">Ends: {new Date(event.endsAt).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ends: {new Date(event.endsAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -135,7 +144,7 @@ export default async function EventDetailPage({
|
||||
maxPlayers: event.maxPlayers,
|
||||
isRecurring: event.isRecurring,
|
||||
recurrenceRule: event.recurrenceRule,
|
||||
status: event.status as "draft" | "published" | "cancelled" | "completed",
|
||||
status: event.status as 'draft' | 'published' | 'cancelled' | 'completed',
|
||||
image: event.image,
|
||||
}}
|
||||
/>
|
||||
@@ -160,5 +169,5 @@ export default async function EventDetailPage({
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventForm } from "../event-form";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { EventForm } from '../event-form'
|
||||
|
||||
export default async function CreateEventPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
||||
redirect("/admin/events");
|
||||
redirect('/admin/events')
|
||||
}
|
||||
|
||||
const eventTypes = await prisma.websiteEventType.findMany({
|
||||
where: { isActive: 1 },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -23,5 +23,5 @@ export default async function CreateEventPage() {
|
||||
|
||||
<EventForm eventTypes={eventTypes} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,60 +1,66 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { createEvent, updateEvent } from "@/actions/events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { CreateEventInput } from "@/lib/validators/event";
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { createEvent, updateEvent } from '@/actions/events'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { DateTimePicker } from '@/components/ui/date-time-picker'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import type { CreateEventInput } from '@/lib/validators/event'
|
||||
|
||||
interface EventType {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface EventFormProps {
|
||||
eventTypes: EventType[];
|
||||
defaultValues?: Partial<CreateEventInput> & { id?: number };
|
||||
eventTypes: EventType[]
|
||||
defaultValues?: Partial<CreateEventInput> & { id?: number }
|
||||
}
|
||||
|
||||
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const isEditing = !!defaultValues?.id;
|
||||
const { run, isPending } = useServerAction()
|
||||
const isEditing = !!defaultValues?.id
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
title: '',
|
||||
description: '',
|
||||
typeId: eventTypes[0]?.id,
|
||||
status: "draft",
|
||||
status: 'draft',
|
||||
isRecurring: 0,
|
||||
...defaultValues,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
|
||||
function onSubmit(data: any) {
|
||||
if (isEditing && defaultValues?.id) {
|
||||
run(() => updateEvent({ id: defaultValues.id!, ...data }), {
|
||||
successMessage: "Event updated!",
|
||||
});
|
||||
successMessage: 'Event updated!',
|
||||
})
|
||||
} else {
|
||||
run(() => createEvent(data), {
|
||||
successMessage: "Event created!",
|
||||
successMessage: 'Event created!',
|
||||
onSuccess: (result) => {
|
||||
const data = result.data as Record<string, unknown> | undefined;
|
||||
const data = result.data as Record<string, unknown> | undefined
|
||||
if (data?.id) {
|
||||
window.location.href = `/admin/events/${data.id}`;
|
||||
window.location.href = `/admin/events/${data.id}`
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +68,13 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{isEditing ? "Edit Event" : "Event Details"}</CardTitle>
|
||||
<CardTitle>{isEditing ? 'Edit Event' : 'Event Details'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" {...form.register("title")} />
|
||||
<Input id="title" {...form.register('title')} />
|
||||
{form.formState.errors.title && (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p>
|
||||
)}
|
||||
@@ -77,8 +83,8 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="typeId">Event Type</Label>
|
||||
<Select
|
||||
value={String(form.watch("typeId"))}
|
||||
onValueChange={(v) => form.setValue("typeId", Number(v))}
|
||||
value={String(form.watch('typeId'))}
|
||||
onValueChange={(v) => form.setValue('typeId', Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
@@ -96,44 +102,54 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" rows={4} {...form.register("description")} />
|
||||
<Textarea id="description" rows={4} {...form.register('description')} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Starts At</Label>
|
||||
<DateTimePicker
|
||||
value={form.watch("startsAt")}
|
||||
value={form.watch('startsAt')}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
|
||||
onChange={(d) => form.setValue("startsAt", d as any, { shouldDirty: true })}
|
||||
onChange={(d) => form.setValue('startsAt', d as any, { shouldDirty: true })}
|
||||
placeholder="Select start date"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Ends At</Label>
|
||||
<DateTimePicker
|
||||
value={form.watch("endsAt")}
|
||||
value={form.watch('endsAt')}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
|
||||
onChange={(d) => form.setValue("endsAt", d as any, { shouldDirty: true })}
|
||||
onChange={(d) => form.setValue('endsAt', d as any, { shouldDirty: true })}
|
||||
placeholder="Select end date"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPlayers">Max Players</Label>
|
||||
<Input id="maxPlayers" type="number" {...form.register("maxPlayers")} placeholder="Unlimited" />
|
||||
<Input
|
||||
id="maxPlayers"
|
||||
type="number"
|
||||
{...form.register('maxPlayers')}
|
||||
placeholder="Unlimited"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="roomId">Room ID</Label>
|
||||
<Input id="roomId" type="number" {...form.register("roomId")} placeholder="Optional" />
|
||||
<Input
|
||||
id="roomId"
|
||||
type="number"
|
||||
{...form.register('roomId')}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={form.watch("status")}
|
||||
onValueChange={(v) => form.setValue("status", v as CreateEventInput["status"])}
|
||||
value={form.watch('status')}
|
||||
onValueChange={(v) => form.setValue('status', v as CreateEventInput['status'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -150,16 +166,16 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Image URL</Label>
|
||||
<Input id="image" {...form.register("image")} placeholder="https://..." />
|
||||
<Input id="image" {...form.register('image')} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEditing ? "Update Event" : "Create Event"}
|
||||
{isPending ? 'Saving...' : isEditing ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,66 +1,66 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import { Pencil } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface EventRow {
|
||||
id: number;
|
||||
title: string;
|
||||
typeName: string;
|
||||
typeColor: string;
|
||||
startsAt: Date;
|
||||
endsAt: Date | null;
|
||||
status: string;
|
||||
hostUserId: number;
|
||||
id: number
|
||||
title: string
|
||||
typeName: string
|
||||
typeColor: string
|
||||
startsAt: Date
|
||||
endsAt: Date | null
|
||||
status: string
|
||||
hostUserId: number
|
||||
}
|
||||
|
||||
const statusVariants: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
published: "default",
|
||||
draft: "secondary",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
};
|
||||
const statusVariants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
published: 'default',
|
||||
draft: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
}
|
||||
|
||||
function getColumns(): DataTableColumn<EventRow>[] {
|
||||
return [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{
|
||||
key: "title",
|
||||
label: "Title",
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
sortable: true,
|
||||
render: (value) => <span className="font-medium">{String(value)}</span>,
|
||||
},
|
||||
{
|
||||
key: "typeName",
|
||||
label: "Type",
|
||||
key: 'typeName',
|
||||
label: 'Type',
|
||||
sortable: false,
|
||||
render: (_, row) => (
|
||||
<Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>{row.typeName}</Badge>
|
||||
<Badge style={{ backgroundColor: row.typeColor, color: '#fff' }}>{row.typeName}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "startsAt",
|
||||
label: "Starts",
|
||||
key: 'startsAt',
|
||||
label: 'Starts',
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<Badge variant={statusVariants[String(value)] ?? "secondary"}>{String(value)}</Badge>
|
||||
<Badge variant={statusVariants[String(value)] ?? 'secondary'}>{String(value)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
render: (_, row) => (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/events/${row.id}`}>
|
||||
@@ -69,13 +69,13 @@ function getColumns(): DataTableColumn<EventRow>[] {
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
interface EventsTableProps {
|
||||
data: PaginatedResult<EventRow>;
|
||||
data: PaginatedResult<EventRow>
|
||||
}
|
||||
|
||||
export function EventsTable({ data }: EventsTableProps) {
|
||||
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />;
|
||||
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { EventsTable } from "./events-table";
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { fetchAdminList } from '@/lib/admin-list'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { EventsTable } from './events-table'
|
||||
|
||||
export default async function EventsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.EVENTS_VIEW,
|
||||
model: "websiteEvent",
|
||||
searchFields: ["title"],
|
||||
defaultSort: { startsAt: "desc" },
|
||||
model: 'websiteEvent',
|
||||
searchFields: ['title'],
|
||||
defaultSort: { startsAt: 'desc' },
|
||||
include: { type: { select: { name: true, color: true } } },
|
||||
mapRow: (e: {
|
||||
id: number;
|
||||
title: string;
|
||||
type: { name: string; color: string };
|
||||
startsAt: Date;
|
||||
endsAt: Date | null;
|
||||
status: string;
|
||||
hostUserId: number;
|
||||
_count?: { registrations: number };
|
||||
id: number
|
||||
title: string
|
||||
type: { name: string; color: string }
|
||||
startsAt: Date
|
||||
endsAt: Date | null
|
||||
status: string
|
||||
hostUserId: number
|
||||
_count?: { registrations: number }
|
||||
}) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
@@ -40,7 +40,7 @@ export default async function EventsPage({
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -64,5 +64,5 @@ export default async function EventsPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createEventType, deleteEventType, updateEventType } from "@/actions/events";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createEventType, deleteEventType, updateEventType } from '@/actions/events'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
interface EventType {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
isActive: number;
|
||||
minRank: number;
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
color: string
|
||||
icon: string
|
||||
isActive: number
|
||||
minRank: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eventTypes: EventType[];
|
||||
eventTypes: EventType[]
|
||||
}
|
||||
|
||||
export function EventTypesManager({ eventTypes }: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const { run, isPending } = useServerAction()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
color: "#3b82f6",
|
||||
icon: "calendar",
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
color: '#3b82f6',
|
||||
icon: 'calendar',
|
||||
minRank: 0,
|
||||
});
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
setForm({ name: "", slug: "", description: "", color: "#3b82f6", icon: "calendar", minRank: 0 });
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
setForm({ name: '', slug: '', description: '', color: '#3b82f6', icon: 'calendar', minRank: 0 })
|
||||
setEditingId(null)
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
function handleEdit(et: EventType) {
|
||||
@@ -52,30 +52,30 @@ export function EventTypesManager({ eventTypes }: Props) {
|
||||
color: et.color,
|
||||
icon: et.icon,
|
||||
minRank: et.minRank,
|
||||
});
|
||||
setEditingId(et.id);
|
||||
setShowForm(true);
|
||||
})
|
||||
setEditingId(et.id)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (editingId) {
|
||||
run(() => updateEventType({ id: editingId, ...form }), {
|
||||
successMessage: "Event type updated!",
|
||||
successMessage: 'Event type updated!',
|
||||
onSuccess: resetForm,
|
||||
});
|
||||
})
|
||||
} else {
|
||||
run(() => createEventType(form), {
|
||||
successMessage: "Event type created!",
|
||||
successMessage: 'Event type created!',
|
||||
onSuccess: resetForm,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
run(() => deleteEventType({ id }), {
|
||||
successMessage: "Event type deleted!",
|
||||
});
|
||||
successMessage: 'Event type deleted!',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -90,7 +90,7 @@ export function EventTypesManager({ eventTypes }: Props) {
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? "Edit Event Type" : "New Event Type"}</CardTitle>
|
||||
<CardTitle>{editingId ? 'Edit Event Type' : 'New Event Type'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -136,9 +136,13 @@ export function EventTypesManager({ eventTypes }: Props) {
|
||||
min={0}
|
||||
max={7}
|
||||
value={form.minRank}
|
||||
onChange={(e) => setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
@@ -146,7 +150,7 @@ export function EventTypesManager({ eventTypes }: Props) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : editingId ? "Update" : "Create"}
|
||||
{isPending ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -170,8 +174,8 @@ export function EventTypesManager({ eventTypes }: Props) {
|
||||
<span className="font-medium">{et.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{et.slug}</span>
|
||||
</div>
|
||||
<Badge variant={et.isActive ? "default" : "outline"}>
|
||||
{et.isActive ? "Active" : "Inactive"}
|
||||
<Badge variant={et.isActive ? 'default' : 'outline'}>
|
||||
{et.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
{et.minRank > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
@@ -193,5 +197,5 @@ export function EventTypesManager({ eventTypes }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventTypesManager } from "./event-types-manager";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { EventTypesManager } from './event-types-manager'
|
||||
|
||||
export default async function EventTypesPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
||||
redirect("/admin/events");
|
||||
redirect('/admin/events')
|
||||
}
|
||||
|
||||
const eventTypes = await prisma.websiteEventType.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -22,5 +22,5 @@ export default async function EventTypesPage() {
|
||||
|
||||
<EventTypesManager eventTypes={eventTypes} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -60,9 +60,7 @@ export default async function AdminHelpQuestions() {
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={String(e.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{e.position}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">{e.position}</td>
|
||||
<td>
|
||||
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
|
||||
</td>
|
||||
|
||||
@@ -1,56 +1,62 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getBadgeData, updateBadge } from "@/actions/badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { getBadgeData, updateBadge } from '@/actions/badges'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
interface BadgeEditorDialogProps {
|
||||
code: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
code: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Called after a successful save so the parent can refresh state */
|
||||
onSaved?: () => void;
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [desc, setDesc] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { run, isPending: saving } = useServerAction();
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { run, isPending: saving } = useServerAction()
|
||||
|
||||
// Fetch current values when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !code) return;
|
||||
setLoading(true);
|
||||
if (!open || !code) return
|
||||
setLoading(true)
|
||||
getBadgeData({ code })
|
||||
.then((result) => {
|
||||
if (result.ok) {
|
||||
const data = result.data as { name: string; desc: string };
|
||||
setName(data.name ?? "");
|
||||
setDesc(data.desc ?? "");
|
||||
const data = result.data as { name: string; desc: string }
|
||||
setName(data.name ?? '')
|
||||
setDesc(data.desc ?? '')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to load badge data");
|
||||
toast.error('Failed to load badge data')
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, code]);
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, code])
|
||||
|
||||
function handleSave() {
|
||||
run(() => updateBadge({ code, name, desc }), {
|
||||
successMessage: `Badge ${code} updated`,
|
||||
errorMessage: "Save failed",
|
||||
errorMessage: 'Save failed',
|
||||
onSuccess: () => {
|
||||
onSaved?.();
|
||||
onOpenChange(false);
|
||||
onSaved?.()
|
||||
onOpenChange(false)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,5 +104,5 @@ export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEd
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -12,179 +12,183 @@ import {
|
||||
Pencil,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { importBadgeFromRemote } from "@/actions/import-badges";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BadgeEditorDialog } from "./badge-editor-dialog";
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { importBadgeFromRemote } from '@/actions/import-badges'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { BadgeEditorDialog } from './badge-editor-dialog'
|
||||
|
||||
interface BadgeItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
hotel: string;
|
||||
url_habbo: string;
|
||||
url_habboassets: string;
|
||||
alreadyImported: boolean;
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
hotel: string
|
||||
url_habbo: string
|
||||
url_habboassets: string
|
||||
alreadyImported: boolean
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
export function ImportBadgesClient() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeSearch, setActiveSearch] = useState("");
|
||||
const [badges, setBadges] = useState<BadgeItem[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importingCode, setImportingCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [allHotels, setAllHotels] = useState(true);
|
||||
const [editingCode, setEditingCode] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchImporting, setBatchImporting] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeSearch, setActiveSearch] = useState('')
|
||||
const [badges, setBadges] = useState<BadgeItem[]>([])
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [importingCode, setImportingCode] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [allHotels, setAllHotels] = useState(true)
|
||||
const [editingCode, setEditingCode] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchImporting, setBatchImporting] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchBadges = useCallback(async (search: string, off: number, all: boolean) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams({ offset: String(off) });
|
||||
if (search) params.set("search", search);
|
||||
if (all) params.set("allHotels", "1");
|
||||
const res = await fetch(`/api/admin/import/badges?${params}`);
|
||||
const data = await res.json();
|
||||
const params = new URLSearchParams({ offset: String(off) })
|
||||
if (search) params.set('search', search)
|
||||
if (all) params.set('allHotels', '1')
|
||||
const res = await fetch(`/api/admin/import/badges?${params}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
setError(data.error || 'Failed to load')
|
||||
return
|
||||
}
|
||||
const list = data.badges || [];
|
||||
setBadges(list);
|
||||
setHasMore(list.length >= PAGE_SIZE);
|
||||
setSelected(new Set());
|
||||
const list = data.badges || []
|
||||
setBadges(list)
|
||||
setHasMore(list.length >= PAGE_SIZE)
|
||||
setSelected(new Set())
|
||||
} catch {
|
||||
setError("Network error");
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// Auto-load on mount and when allHotels changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
fetchBadges(activeSearch, 0, allHotels);
|
||||
}, [fetchBadges, allHotels]);
|
||||
setOffset(0)
|
||||
fetchBadges(activeSearch, 0, allHotels)
|
||||
}, [fetchBadges, allHotels])
|
||||
|
||||
// "/" keyboard shortcut to focus search
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
function doSearch() {
|
||||
setActiveSearch(searchTerm);
|
||||
setOffset(0);
|
||||
fetchBadges(searchTerm, 0, allHotels);
|
||||
setActiveSearch(searchTerm)
|
||||
setOffset(0)
|
||||
fetchBadges(searchTerm, 0, allHotels)
|
||||
}
|
||||
|
||||
function goPage(direction: "prev" | "next") {
|
||||
const newOffset = direction === "next" ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE);
|
||||
setOffset(newOffset);
|
||||
fetchBadges(activeSearch, newOffset, allHotels);
|
||||
function goPage(direction: 'prev' | 'next') {
|
||||
const newOffset = direction === 'next' ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE)
|
||||
setOffset(newOffset)
|
||||
fetchBadges(activeSearch, newOffset, allHotels)
|
||||
}
|
||||
|
||||
async function doImport(badge: BadgeItem) {
|
||||
setImportingCode(badge.code);
|
||||
setImportingCode(badge.code)
|
||||
try {
|
||||
const result = await importBadgeFromRemote({
|
||||
code: badge.code,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
});
|
||||
})
|
||||
if (result.ok) {
|
||||
toast.success(`${badge.code} imported`);
|
||||
setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
|
||||
toast.success(`${badge.code} imported`)
|
||||
setBadges((prev) =>
|
||||
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
|
||||
)
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(badge.code);
|
||||
return n;
|
||||
});
|
||||
const n = new Set(prev)
|
||||
n.delete(badge.code)
|
||||
return n
|
||||
})
|
||||
} else {
|
||||
toast.error(result.error || `Failed to import ${badge.code}`);
|
||||
toast.error(result.error || `Failed to import ${badge.code}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(`Network error importing ${badge.code}`);
|
||||
toast.error(`Network error importing ${badge.code}`)
|
||||
} finally {
|
||||
setImportingCode(null);
|
||||
setImportingCode(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function doBatchImport() {
|
||||
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported);
|
||||
if (toImport.length === 0) return;
|
||||
setBatchImporting(true);
|
||||
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported)
|
||||
if (toImport.length === 0) return
|
||||
setBatchImporting(true)
|
||||
let ok = 0,
|
||||
fail = 0;
|
||||
fail = 0
|
||||
for (const badge of toImport) {
|
||||
try {
|
||||
const result = await importBadgeFromRemote({
|
||||
code: badge.code,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
});
|
||||
})
|
||||
if (result.ok) {
|
||||
ok++;
|
||||
setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
|
||||
ok++
|
||||
setBadges((prev) =>
|
||||
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
|
||||
)
|
||||
} else {
|
||||
fail++;
|
||||
fail++
|
||||
}
|
||||
} catch {
|
||||
fail++;
|
||||
fail++
|
||||
}
|
||||
}
|
||||
setBatchImporting(false);
|
||||
setSelected(new Set());
|
||||
if (ok > 0) toast.success(`${ok} badge(s) imported`);
|
||||
if (fail > 0) toast.error(`${fail} failed`);
|
||||
setBatchImporting(false)
|
||||
setSelected(new Set())
|
||||
if (ok > 0) toast.success(`${ok} badge(s) imported`)
|
||||
if (fail > 0) toast.error(`${fail} failed`)
|
||||
}
|
||||
|
||||
function toggleSelect(code: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) next.delete(code);
|
||||
else next.add(code);
|
||||
return next;
|
||||
});
|
||||
const next = new Set(prev)
|
||||
if (next.has(code)) next.delete(code)
|
||||
else next.add(code)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges]);
|
||||
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges])
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selected.size === importableBadges.length) {
|
||||
setSelected(new Set());
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(importableBadges.map((b) => b.code)));
|
||||
setSelected(new Set(importableBadges.map((b) => b.code)))
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges]);
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges])
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -222,10 +226,10 @@ export function ImportBadgesClient() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doSearch();
|
||||
if (e.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
searchRef.current?.blur();
|
||||
if (e.key === 'Enter') doSearch()
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
searchRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
className="pl-9 pr-8"
|
||||
@@ -234,9 +238,9 @@ export function ImportBadgesClient() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
fetchBadges("", 0, allHotels);
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
fetchBadges('', 0, allHotels)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -245,24 +249,28 @@ export function ImportBadgesClient() {
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={doSearch} size="sm" disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={allHotels ? "default" : "outline"}
|
||||
variant={allHotels ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setAllHotels((v) => !v)}
|
||||
>
|
||||
<Globe className="w-4 h-4 mr-1" />
|
||||
{allHotels ? "All" : "IT"}
|
||||
{allHotels ? 'All' : 'IT'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{allHotels
|
||||
? "Showing all hotels — click for Italian only"
|
||||
: "Showing Italian only — click for all hotels"}
|
||||
? 'Showing all hotels — click for Italian only'
|
||||
: 'Showing Italian only — click for all hotels'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -279,7 +287,7 @@ export function ImportBadgesClient() {
|
||||
disabled={batchImporting}
|
||||
>
|
||||
{selected.size === importableBadges.length
|
||||
? "Deselect All"
|
||||
? 'Deselect All'
|
||||
: `Select All (${importableBadges.length})`}
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
@@ -322,7 +330,7 @@ export function ImportBadgesClient() {
|
||||
{!loading && badges.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No badges found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||
<p className="text-sm">No badges found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
|
||||
{!activeSearch && <p className="text-xs mt-1">Try searching by badge name or code</p>}
|
||||
</div>
|
||||
)}
|
||||
@@ -330,17 +338,20 @@ export function ImportBadgesClient() {
|
||||
{/* Badge grid */}
|
||||
{badges.length > 0 && (
|
||||
<div
|
||||
className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}
|
||||
className={cn(
|
||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
||||
loading && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selected.has(badge.code);
|
||||
const isSelected = selected.has(badge.code)
|
||||
return (
|
||||
<div
|
||||
key={badge.code}
|
||||
className={cn(
|
||||
"rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all",
|
||||
badge.alreadyImported && "ring-1 ring-[var(--admin-success)]/30",
|
||||
isSelected && "ring-2 ring-primary border-primary",
|
||||
'rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all',
|
||||
badge.alreadyImported && 'ring-1 ring-[var(--admin-success)]/30',
|
||||
isSelected && 'ring-2 ring-primary border-primary',
|
||||
)}
|
||||
>
|
||||
{/* Checkbox for non-imported */}
|
||||
@@ -349,10 +360,10 @@ export function ImportBadgesClient() {
|
||||
type="button"
|
||||
onClick={() => toggleSelect(badge.code)}
|
||||
className={cn(
|
||||
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
@@ -367,12 +378,12 @@ export function ImportBadgesClient() {
|
||||
className="image-pixelated drop-shadow-sm"
|
||||
style={{ maxHeight: 48, maxWidth: 48 }}
|
||||
onError={(e) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
const img = e.target as HTMLImageElement
|
||||
if (!img.dataset.fallback) {
|
||||
img.dataset.fallback = "1";
|
||||
img.src = badge.url_habboassets;
|
||||
img.dataset.fallback = '1'
|
||||
img.src = badge.url_habboassets
|
||||
} else {
|
||||
img.style.opacity = "0.3";
|
||||
img.style.opacity = '0.3'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -391,7 +402,9 @@ export function ImportBadgesClient() {
|
||||
<TooltipContent side="bottom" className="max-w-64">
|
||||
<p className="font-medium">{badge.name}</p>
|
||||
{badge.description && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{badge.description}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
{badge.description}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -439,7 +452,7 @@ export function ImportBadgesClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -453,7 +466,7 @@ export function ImportBadgesClient() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset <= 0 || loading}
|
||||
onClick={() => goPage("prev")}
|
||||
onClick={() => goPage('prev')}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -461,7 +474,7 @@ export function ImportBadgesClient() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => goPage("next")}
|
||||
onClick={() => goPage('next')}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
@@ -471,13 +484,13 @@ export function ImportBadgesClient() {
|
||||
|
||||
{/* Badge Editor Dialog */}
|
||||
<BadgeEditorDialog
|
||||
code={editingCode ?? ""}
|
||||
code={editingCode ?? ''}
|
||||
open={!!editingCode}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingCode(null);
|
||||
if (!open) setEditingCode(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ImportBadgesLoading() {
|
||||
return (
|
||||
@@ -17,5 +17,5 @@ export default function ImportBadgesLoading() {
|
||||
<Skeleton className="h-10 w-24 mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportBadgesClient } from "./import-badges-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportBadgesClient } from './import-badges-client'
|
||||
|
||||
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
return <ImportBadgesClient />;
|
||||
return <ImportBadgesClient />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -12,46 +12,46 @@ import {
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CloneSource {
|
||||
id: string;
|
||||
name: string;
|
||||
furnidataUrl: string;
|
||||
nitroBaseUrl: string;
|
||||
iconBaseUrl: string;
|
||||
id: string
|
||||
name: string
|
||||
furnidataUrl: string
|
||||
nitroBaseUrl: string
|
||||
iconBaseUrl: string
|
||||
}
|
||||
|
||||
interface FurniItem {
|
||||
classname: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
revision?: number;
|
||||
category?: string;
|
||||
itemType: "s" | "i";
|
||||
xdim: number;
|
||||
ydim: number;
|
||||
present: boolean;
|
||||
classname: string
|
||||
name: string
|
||||
description?: string
|
||||
revision?: number
|
||||
category?: string
|
||||
itemType: 's' | 'i'
|
||||
xdim: number
|
||||
ydim: number
|
||||
present: boolean
|
||||
}
|
||||
|
||||
interface CloneStats {
|
||||
total: number;
|
||||
present: number;
|
||||
clonable: number;
|
||||
total: number
|
||||
present: number
|
||||
clonable: number
|
||||
}
|
||||
|
||||
interface PageMeta {
|
||||
page: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
page: number
|
||||
perPage: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
|
||||
@@ -63,63 +63,63 @@ async function runSseImport(
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
): Promise<void> {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
})
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
toast.error('No response stream')
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
const { value, done: streamDone } = await reader.read()
|
||||
if (streamDone) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split('\n\n')
|
||||
buf = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
|
||||
onDone(evt.classname);
|
||||
if (!part.startsWith('data: ')) continue
|
||||
const evt = JSON.parse(part.slice(6))
|
||||
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) {
|
||||
onDone(evt.classname)
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
if (evt.type === 'batch_complete') {
|
||||
succeeded = evt.succeeded
|
||||
failed = evt.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(succeeded, failed);
|
||||
onComplete(succeeded, failed)
|
||||
}
|
||||
|
||||
// ── Source form ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface SourceFormProps {
|
||||
initial?: Partial<CloneSource>;
|
||||
onSave: (src: Partial<CloneSource>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
saving: boolean;
|
||||
initial?: Partial<CloneSource>
|
||||
onSave: (src: Partial<CloneSource>) => Promise<void>
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? "");
|
||||
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
|
||||
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? '')
|
||||
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? '')
|
||||
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? '')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl });
|
||||
e.preventDefault()
|
||||
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border p-4 bg-card">
|
||||
<div className="text-sm font-medium">{initial?.id ? "Edit Source" : "Add Source"}</div>
|
||||
<div className="text-sm font-medium">{initial?.id ? 'Edit Source' : 'Add Source'}</div>
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
placeholder="Name (e.g. HotelX)"
|
||||
@@ -156,61 +156,61 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sources manager ───────────────────────────────────────────────────────────
|
||||
|
||||
interface SourcesManagerProps {
|
||||
sources: CloneSource[];
|
||||
activeSourceId: string | null;
|
||||
onSelect: (src: CloneSource) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
sources: CloneSource[]
|
||||
activeSourceId: string | null
|
||||
onSelect: (src: CloneSource) => void
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editSrc, setEditSrc] = useState<CloneSource | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editSrc, setEditSrc] = useState<CloneSource | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
async function handleSave(src: Partial<CloneSource>) {
|
||||
setSaving(true);
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/admin/import/clone", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const res = await fetch('/api/admin/import/clone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(src),
|
||||
});
|
||||
const data = await res.json();
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || "Save failed");
|
||||
return;
|
||||
toast.error(data.error || 'Save failed')
|
||||
return
|
||||
}
|
||||
toast.success(src.id ? "Source updated" : "Source added");
|
||||
setShowForm(false);
|
||||
setEditSrc(null);
|
||||
await onRefresh();
|
||||
toast.success(src.id ? 'Source updated' : 'Source added')
|
||||
setShowForm(false)
|
||||
setEditSrc(null)
|
||||
await onRefresh()
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(src: CloneSource) {
|
||||
setDeletingId(src.id);
|
||||
setDeletingId(src.id)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
method: 'DELETE',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || "Delete failed");
|
||||
return;
|
||||
toast.error(data.error || 'Delete failed')
|
||||
return
|
||||
}
|
||||
toast.success(`${src.name} removed`);
|
||||
await onRefresh();
|
||||
toast.success(`${src.name} removed`)
|
||||
await onRefresh()
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +223,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setEditSrc(null);
|
||||
setShowForm(true);
|
||||
setEditSrc(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
@@ -246,12 +246,17 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
||||
{sources.map((src) => (
|
||||
<div key={src.id}>
|
||||
{editSrc?.id === src.id ? (
|
||||
<SourceForm initial={src} onSave={handleSave} onCancel={() => setEditSrc(null)} saving={saving} />
|
||||
<SourceForm
|
||||
initial={src}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setEditSrc(null)}
|
||||
saving={saving}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border",
|
||||
activeSourceId === src.id && "border-primary ring-1 ring-primary bg-primary/5",
|
||||
'w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border',
|
||||
activeSourceId === src.id && 'border-primary ring-1 ring-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -272,8 +277,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
||||
className="h-6 w-6 p-0"
|
||||
title="Edit source"
|
||||
onClick={() => {
|
||||
setEditSrc(src);
|
||||
setShowForm(false);
|
||||
setEditSrc(src)
|
||||
setShowForm(false)
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
@@ -298,7 +303,7 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Furni icon with fallback ──────────────────────────────────────────────────
|
||||
@@ -308,20 +313,20 @@ function FurniIcon({
|
||||
iconBaseUrl,
|
||||
classname,
|
||||
}: {
|
||||
sourceId: string;
|
||||
iconBaseUrl: string;
|
||||
classname: string;
|
||||
sourceId: string
|
||||
iconBaseUrl: string
|
||||
classname: string
|
||||
}) {
|
||||
// 0 = source's standalone icon, 1 = icon extracted from the .nitro (our
|
||||
// endpoint), 2 = give up and show a placeholder.
|
||||
const [stage, setStage] = useState(0);
|
||||
const [stage, setStage] = useState(0)
|
||||
if (stage >= 2) {
|
||||
return <Package className="w-8 h-8 text-muted-foreground/50" />;
|
||||
return <Package className="w-8 h-8 text-muted-foreground/50" />
|
||||
}
|
||||
const src =
|
||||
stage === 0
|
||||
? `${iconBaseUrl}/${classname}_icon.png`
|
||||
: `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`;
|
||||
: `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset
|
||||
<img
|
||||
@@ -331,197 +336,203 @@ function FurniIcon({
|
||||
className="max-h-full max-w-full object-contain image-pixelated"
|
||||
onError={() => setStage((s) => s + 1)}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Furni grid view ───────────────────────────────────────────────────────────
|
||||
|
||||
interface FurniGridProps {
|
||||
source: CloneSource;
|
||||
source: CloneSource
|
||||
}
|
||||
|
||||
const PER_PAGE = 50;
|
||||
const PER_PAGE = 50
|
||||
|
||||
function FurniGrid({ source }: FurniGridProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeSearch, setActiveSearch] = useState("");
|
||||
const [items, setItems] = useState<FurniItem[]>([]);
|
||||
const [stats, setStats] = useState<CloneStats | null>(null);
|
||||
const [meta, setMeta] = useState<PageMeta | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [busyClassname, setBusyClassname] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeSearch, setActiveSearch] = useState('')
|
||||
const [items, setItems] = useState<FurniItem[]>([])
|
||||
const [stats, setStats] = useState<CloneStats | null>(null)
|
||||
const [meta, setMeta] = useState<PageMeta | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [busyClassname, setBusyClassname] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'missing' | 'present'>('all')
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (search: string, pageNum: number, filterVal: "all" | "missing" | "present" = "all") => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
async (search: string, pageNum: number, filterVal: 'all' | 'missing' | 'present' = 'all') => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
source: source.id,
|
||||
page: String(pageNum),
|
||||
perPage: String(PER_PAGE),
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
if (filterVal !== "all") params.set("filter", filterVal);
|
||||
const res = await fetch(`/api/admin/import/clone?${params}`);
|
||||
const data = await res.json();
|
||||
})
|
||||
if (search) params.set('search', search)
|
||||
if (filterVal !== 'all') params.set('filter', filterVal)
|
||||
const res = await fetch(`/api/admin/import/clone?${params}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
setError(data.error || 'Failed to load')
|
||||
return
|
||||
}
|
||||
setItems(data.item || []);
|
||||
setMeta(data.meta || null);
|
||||
setStats(data.stats || null);
|
||||
setSelected(new Set());
|
||||
setItems(data.item || [])
|
||||
setMeta(data.meta || null)
|
||||
setStats(data.stats || null)
|
||||
setSelected(new Set())
|
||||
} catch {
|
||||
setError("Network error");
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[source.id],
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setItems([]);
|
||||
setStats(null);
|
||||
setMeta(null);
|
||||
setFilter("all");
|
||||
fetchItems("", 1, "all");
|
||||
}, [fetchItems]);
|
||||
setPage(1)
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setItems([])
|
||||
setStats(null)
|
||||
setMeta(null)
|
||||
setFilter('all')
|
||||
fetchItems('', 1, 'all')
|
||||
}, [fetchItems])
|
||||
|
||||
function doSearch() {
|
||||
setActiveSearch(searchTerm);
|
||||
setPage(1);
|
||||
fetchItems(searchTerm, 1, filter);
|
||||
setActiveSearch(searchTerm)
|
||||
setPage(1)
|
||||
fetchItems(searchTerm, 1, filter)
|
||||
}
|
||||
|
||||
function goPage(dir: "prev" | "next") {
|
||||
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
|
||||
setPage(next);
|
||||
fetchItems(activeSearch, next, filter);
|
||||
function goPage(dir: 'prev' | 'next') {
|
||||
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
|
||||
setPage(next)
|
||||
fetchItems(activeSearch, next, filter)
|
||||
}
|
||||
|
||||
function changeFilter(f: "all" | "missing" | "present") {
|
||||
setFilter(f);
|
||||
setPage(1);
|
||||
fetchItems(activeSearch, 1, f);
|
||||
function changeFilter(f: 'all' | 'missing' | 'present') {
|
||||
setFilter(f)
|
||||
setPage(1)
|
||||
fetchItems(activeSearch, 1, f)
|
||||
}
|
||||
|
||||
const importable = items.filter((it) => !it.present);
|
||||
const total = meta?.total ?? 0;
|
||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||
const importable = items.filter((it) => !it.present)
|
||||
const total = meta?.total ?? 0
|
||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
|
||||
|
||||
function toggle(classname: string) {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(classname)) n.delete(classname);
|
||||
else n.add(classname);
|
||||
return n;
|
||||
});
|
||||
const n = new Set(prev)
|
||||
if (n.has(classname)) n.delete(classname)
|
||||
else n.add(classname)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function markDone(classname: string) {
|
||||
setItems((prev) => prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)));
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)),
|
||||
)
|
||||
}
|
||||
|
||||
async function cloneViaSse(toClone: FurniItem[]) {
|
||||
setBatchProgress({ done: 0, total: toClone.length });
|
||||
let done = 0;
|
||||
setBatchProgress({ done: 0, total: toClone.length })
|
||||
let done = 0
|
||||
await runSseImport(
|
||||
"/api/admin/import/clone/batch",
|
||||
'/api/admin/import/clone/batch',
|
||||
{ sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2 },
|
||||
(classname) => {
|
||||
done++;
|
||||
markDone(classname);
|
||||
setBatchProgress({ done, total: toClone.length });
|
||||
done++
|
||||
markDone(classname)
|
||||
setBatchProgress({ done, total: toClone.length })
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} furni cloned`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
setBatchProgress(null)
|
||||
setSelected(new Set())
|
||||
if (succeeded > 0) toast.success(`${succeeded} furni cloned`)
|
||||
if (failed > 0) toast.error(`${failed} failed`)
|
||||
// Refresh stats after batch
|
||||
fetchItems(activeSearch, page, filter);
|
||||
fetchItems(activeSearch, page, filter)
|
||||
},
|
||||
);
|
||||
setBatchProgress(null);
|
||||
)
|
||||
setBatchProgress(null)
|
||||
}
|
||||
|
||||
async function cloneOne(it: FurniItem) {
|
||||
setBusyClassname(it.classname);
|
||||
setBusyClassname(it.classname)
|
||||
try {
|
||||
await cloneViaSse([it]);
|
||||
await cloneViaSse([it])
|
||||
} finally {
|
||||
setBusyClassname(null);
|
||||
setBusyClassname(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneSelected() {
|
||||
const toClone = items.filter((it) => selected.has(it.classname) && !it.present);
|
||||
if (toClone.length === 0) return;
|
||||
await cloneViaSse(toClone);
|
||||
const toClone = items.filter((it) => selected.has(it.classname) && !it.present)
|
||||
if (toClone.length === 0) return
|
||||
await cloneViaSse(toClone)
|
||||
}
|
||||
|
||||
// Clone EVERY not-yet-present furni from the source (across all pages),
|
||||
// in chunks of 400 to stay under the batch endpoint's 500 cap.
|
||||
async function cloneAll() {
|
||||
let names: string[] = [];
|
||||
let names: string[] = []
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
|
||||
);
|
||||
const data = await res.json();
|
||||
)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || "Failed to load clonable list");
|
||||
return;
|
||||
toast.error(data.error || 'Failed to load clonable list')
|
||||
return
|
||||
}
|
||||
names = data.classnames || [];
|
||||
names = data.classnames || []
|
||||
} catch {
|
||||
toast.error("Network error");
|
||||
return;
|
||||
toast.error('Network error')
|
||||
return
|
||||
}
|
||||
if (names.length === 0) {
|
||||
toast.info("Nothing to clone — everything is already present");
|
||||
return;
|
||||
toast.info('Nothing to clone — everything is already present')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`)) {
|
||||
return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const CHUNK = 400;
|
||||
setBatchProgress({ done: 0, total: names.length });
|
||||
let done = 0;
|
||||
let okCount = 0;
|
||||
let failCount = 0;
|
||||
const CHUNK = 400
|
||||
setBatchProgress({ done: 0, total: names.length })
|
||||
let done = 0
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
for (let i = 0; i < names.length; i += CHUNK) {
|
||||
const chunk = names.slice(i, i + CHUNK);
|
||||
const chunk = names.slice(i, i + CHUNK)
|
||||
await runSseImport(
|
||||
"/api/admin/import/clone/batch",
|
||||
'/api/admin/import/clone/batch',
|
||||
{ sourceId: source.id, classnames: chunk, concurrency: 2 },
|
||||
(classname) => {
|
||||
done++;
|
||||
markDone(classname);
|
||||
setBatchProgress({ done, total: names.length });
|
||||
done++
|
||||
markDone(classname)
|
||||
setBatchProgress({ done, total: names.length })
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
okCount += succeeded;
|
||||
failCount += failed;
|
||||
okCount += succeeded
|
||||
failCount += failed
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (okCount > 0) toast.success(`${okCount} furni cloned`);
|
||||
if (failCount > 0) toast.error(`${failCount} failed`);
|
||||
fetchItems(activeSearch, page, filter);
|
||||
setBatchProgress(null)
|
||||
setSelected(new Set())
|
||||
if (okCount > 0) toast.success(`${okCount} furni cloned`)
|
||||
if (failCount > 0) toast.error(`${failCount} failed`)
|
||||
fetchItems(activeSearch, page, filter)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -556,12 +567,12 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doSearch();
|
||||
if (e.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setPage(1);
|
||||
fetchItems("", 1, filter);
|
||||
if (e.key === 'Enter') doSearch()
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setPage(1)
|
||||
fetchItems('', 1, filter)
|
||||
}
|
||||
}}
|
||||
className="pl-9 pr-8"
|
||||
@@ -570,10 +581,10 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setPage(1);
|
||||
fetchItems("", 1, filter);
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setPage(1)
|
||||
fetchItems('', 1, filter)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -590,22 +601,22 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(
|
||||
[
|
||||
{ key: "all", label: "All", count: stats?.total },
|
||||
{ key: "missing", label: "Not present (don't have)", count: stats?.clonable },
|
||||
{ key: "present", label: "Present", count: stats?.present },
|
||||
{ key: 'all', label: 'All', count: stats?.total },
|
||||
{ key: 'missing', label: "Not present (don't have)", count: stats?.clonable },
|
||||
{ key: 'present', label: 'Present', count: stats?.present },
|
||||
] as const
|
||||
).map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === f.key ? "default" : "outline"}
|
||||
variant={filter === f.key ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
disabled={loading || !!batchProgress}
|
||||
onClick={() => changeFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
{typeof f.count === "number" ? ` (${f.count})` : ""}
|
||||
{typeof f.count === 'number' ? ` (${f.count})` : ''}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -621,11 +632,15 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
disabled={!!batchProgress}
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev.size === importable.length ? new Set() : new Set(importable.map((it) => it.classname)),
|
||||
prev.size === importable.length
|
||||
? new Set()
|
||||
: new Set(importable.map((it) => it.classname)),
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
|
||||
{selected.size === importable.length
|
||||
? 'Deselect page'
|
||||
: `Select page (${importable.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
@@ -674,22 +689,27 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CloudDownload className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{items.length > 0 && (
|
||||
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
||||
loading && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{items.map((it) => {
|
||||
const isSelected = selected.has(it.classname);
|
||||
const isSelected = selected.has(it.classname)
|
||||
return (
|
||||
<div
|
||||
key={it.classname}
|
||||
className={cn(
|
||||
"group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||
it.present && "ring-1 ring-[var(--admin-success)]/30",
|
||||
isSelected && "ring-2 ring-primary border-primary",
|
||||
'group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
||||
it.present && 'ring-1 ring-[var(--admin-success)]/30',
|
||||
isSelected && 'ring-2 ring-primary border-primary',
|
||||
)}
|
||||
>
|
||||
{!it.present && (
|
||||
@@ -697,10 +717,10 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
type="button"
|
||||
onClick={() => toggle(it.classname)}
|
||||
className={cn(
|
||||
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
@@ -709,19 +729,23 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
{/* Type badge */}
|
||||
<div className="absolute top-1.5 right-1.5 z-10">
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80">
|
||||
{it.itemType === "s" ? "floor" : "wall"}
|
||||
{it.itemType === 's' ? 'floor' : 'wall'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-center h-28 bg-accent/20">
|
||||
<FurniIcon sourceId={source.id} iconBaseUrl={source.iconBaseUrl} classname={it.classname} />
|
||||
<FurniIcon
|
||||
sourceId={source.id}
|
||||
iconBaseUrl={source.iconBaseUrl}
|
||||
classname={it.classname}
|
||||
/>
|
||||
{/* Hover detail tooltip */}
|
||||
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="bg-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
|
||||
{it.description ? <p className="truncate">{it.description}</p> : null}
|
||||
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
|
||||
{it.revision ? <span>Rev: {it.revision}</span> : null}
|
||||
<span>{it.itemType === "s" ? "Floor" : "Wall"}</span>
|
||||
<span>{it.itemType === 's' ? 'Floor' : 'Wall'}</span>
|
||||
<span>
|
||||
{it.xdim}×{it.ydim}
|
||||
</span>
|
||||
@@ -738,7 +762,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
{it.classname}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{it.itemType === "s" ? "Floor" : "Wall"} · {it.category || "other"}
|
||||
{it.itemType === 's' ? 'Floor' : 'Wall'} · {it.category || 'other'}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
{it.present ? (
|
||||
@@ -765,7 +789,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -781,7 +805,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => goPage("prev")}
|
||||
onClick={() => goPage('prev')}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -789,7 +813,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage("next")}
|
||||
onClick={() => goPage('next')}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
@@ -797,33 +821,33 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Root component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ImportCloneClient() {
|
||||
const [sources, setSources] = useState<CloneSource[]>([]);
|
||||
const [activeSource, setActiveSource] = useState<CloneSource | null>(null);
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [sources, setSources] = useState<CloneSource[]>([])
|
||||
const [activeSource, setActiveSource] = useState<CloneSource | null>(null)
|
||||
const [loadingSources, setLoadingSources] = useState(true)
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/import/clone?action=sources");
|
||||
const data = await res.json();
|
||||
const res = await fetch('/api/admin/import/clone?action=sources')
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setSources(data.sources || []);
|
||||
setSources(data.sources || [])
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoadingSources(false);
|
||||
setLoadingSources(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSources();
|
||||
}, [fetchSources]);
|
||||
fetchSources()
|
||||
}, [fetchSources])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -859,5 +883,5 @@ export function ImportCloneClient() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportCloneClient } from "./import-clone-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportCloneClient } from './import-clone-client'
|
||||
|
||||
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
return <ImportCloneClient />;
|
||||
return <ImportCloneClient />
|
||||
}
|
||||
@@ -1,35 +1,45 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { AlertCircle, Check, Download, Loader2, Package, Search, Shirt, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Loader2,
|
||||
Package,
|
||||
Search,
|
||||
Shirt,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface FigureItem {
|
||||
lib: string;
|
||||
revision: number;
|
||||
nitroExists: boolean;
|
||||
previewType?: string;
|
||||
previewId?: number;
|
||||
lib: string
|
||||
revision: number
|
||||
nitroExists: boolean
|
||||
previewType?: string
|
||||
previewId?: number
|
||||
}
|
||||
|
||||
interface ClothingSetItem {
|
||||
setType: string;
|
||||
setId: number;
|
||||
gender: string;
|
||||
club: number;
|
||||
sellable: boolean;
|
||||
present: boolean;
|
||||
setType: string
|
||||
setId: number
|
||||
gender: string
|
||||
club: number
|
||||
sellable: boolean
|
||||
present: boolean
|
||||
}
|
||||
|
||||
type Mode = "libs" | "sets";
|
||||
type Mode = 'libs' | 'sets'
|
||||
|
||||
const PER_PAGE = 100;
|
||||
const PER_PAGE = 100
|
||||
|
||||
// ── Shared SSE reader ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,38 +50,38 @@ async function runSseImport(
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
): Promise<void> {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
})
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
toast.error('No response stream')
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
const { value, done: streamDone } = await reader.read()
|
||||
if (streamDone) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split('\n\n')
|
||||
buf = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
|
||||
onDone(evt.classname);
|
||||
if (!part.startsWith('data: ')) continue
|
||||
const evt = JSON.parse(part.slice(6))
|
||||
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) {
|
||||
onDone(evt.classname)
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
if (evt.type === 'batch_complete') {
|
||||
succeeded = evt.succeeded
|
||||
failed = evt.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(succeeded, failed);
|
||||
onComplete(succeeded, failed)
|
||||
}
|
||||
|
||||
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
|
||||
@@ -79,30 +89,30 @@ async function runSseImport(
|
||||
// A default dressed avatar; the previewed part overrides/adds its slot so the
|
||||
// item is shown on a complete figure.
|
||||
const BASE_FIGURE: Record<string, string> = {
|
||||
hd: "hd-180-1",
|
||||
hr: "hr-828-45",
|
||||
ch: "ch-665-92",
|
||||
lg: "lg-700-82",
|
||||
sh: "sh-705-62",
|
||||
};
|
||||
hd: 'hd-180-1',
|
||||
hr: 'hr-828-45',
|
||||
ch: 'ch-665-92',
|
||||
lg: 'lg-700-82',
|
||||
sh: 'sh-705-62',
|
||||
}
|
||||
|
||||
// Head-worn slots → render head-only (zoomed) so the item fills the frame;
|
||||
// everything else renders the full body.
|
||||
const HEAD_TYPES = new Set(["hd", "hr", "ha", "he", "ea", "fa", "fc"]);
|
||||
const HEAD_TYPES = new Set(['hd', 'hr', 'ha', 'he', 'ea', 'fa', 'fc'])
|
||||
|
||||
function buildPreviewFigure(type: string, id: number): string {
|
||||
const parts: Record<string, string> = { ...BASE_FIGURE };
|
||||
parts[type] = `${type}-${id}`; // default color
|
||||
return Object.values(parts).join(".");
|
||||
const parts: Record<string, string> = { ...BASE_FIGURE }
|
||||
parts[type] = `${type}-${id}` // default color
|
||||
return Object.values(parts).join('.')
|
||||
}
|
||||
|
||||
function AvatarPreview({ type, id }: { type?: string; id?: number }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (failed || !type || id === undefined) {
|
||||
return <Shirt className="w-8 h-8 text-muted-foreground/60" />;
|
||||
return <Shirt className="w-8 h-8 text-muted-foreground/60" />
|
||||
}
|
||||
const figure = buildPreviewFigure(type, id);
|
||||
const headOnly = HEAD_TYPES.has(type) ? "&headonly=1" : "";
|
||||
const figure = buildPreviewFigure(type, id)
|
||||
const headOnly = HEAD_TYPES.has(type) ? '&headonly=1' : ''
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset
|
||||
<img
|
||||
@@ -111,129 +121,129 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) {
|
||||
className="max-h-full object-contain image-pixelated"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Librerie (v1) view ────────────────────────────────────────────────────────
|
||||
|
||||
function LibsView() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeSearch, setActiveSearch] = useState("");
|
||||
const [items, setItems] = useState<FigureItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeSearch, setActiveSearch] = useState('')
|
||||
const [items, setItems] = useState<FigureItem[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null)
|
||||
|
||||
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(pageNum) });
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/admin/import/clothing?${params}`);
|
||||
const data = await res.json();
|
||||
const params = new URLSearchParams({ page: String(pageNum) })
|
||||
if (search) params.set('search', search)
|
||||
const res = await fetch(`/api/admin/import/clothing?${params}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
setError(data.error || 'Failed to load')
|
||||
return
|
||||
}
|
||||
setItems(data.item || []);
|
||||
setTotal(data.meta?.total || 0);
|
||||
setSelected(new Set());
|
||||
setItems(data.item || [])
|
||||
setTotal(data.meta?.total || 0)
|
||||
setSelected(new Set())
|
||||
} catch {
|
||||
setError("Network error");
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems("", 1);
|
||||
}, [fetchItems]);
|
||||
fetchItems('', 1)
|
||||
}, [fetchItems])
|
||||
|
||||
function doSearch() {
|
||||
setActiveSearch(searchTerm);
|
||||
setPage(1);
|
||||
fetchItems(searchTerm, 1);
|
||||
setActiveSearch(searchTerm)
|
||||
setPage(1)
|
||||
fetchItems(searchTerm, 1)
|
||||
}
|
||||
|
||||
function goPage(dir: "prev" | "next") {
|
||||
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
|
||||
setPage(next);
|
||||
fetchItems(activeSearch, next);
|
||||
function goPage(dir: 'prev' | 'next') {
|
||||
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
|
||||
setPage(next)
|
||||
fetchItems(activeSearch, next)
|
||||
}
|
||||
|
||||
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items]);
|
||||
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items]);
|
||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items])
|
||||
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items])
|
||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
|
||||
|
||||
function toggle(lib: string) {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(lib)) n.delete(lib);
|
||||
else n.add(lib);
|
||||
return n;
|
||||
});
|
||||
const n = new Set(prev)
|
||||
if (n.has(lib)) n.delete(lib)
|
||||
else n.add(lib)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function markDone(lib: string) {
|
||||
setItems((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)));
|
||||
setItems((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)))
|
||||
}
|
||||
|
||||
async function importViaSse(toImport: FigureItem[]) {
|
||||
setBatchProgress({ done: 0, total: toImport.length });
|
||||
let done = 0;
|
||||
setBatchProgress({ done: 0, total: toImport.length })
|
||||
let done = 0
|
||||
await runSseImport(
|
||||
"/api/admin/import/clothing/batch",
|
||||
'/api/admin/import/clothing/batch',
|
||||
{ items: toImport, concurrency: 3 },
|
||||
(label) => {
|
||||
done++;
|
||||
markDone(label);
|
||||
setBatchProgress({ done, total: toImport.length });
|
||||
done++
|
||||
markDone(label)
|
||||
setBatchProgress({ done, total: toImport.length })
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
setBatchProgress(null)
|
||||
setSelected(new Set())
|
||||
if (succeeded > 0) toast.success(`${succeeded} libraries imported`)
|
||||
if (failed > 0) toast.error(`${failed} failed`)
|
||||
},
|
||||
);
|
||||
setBatchProgress(null);
|
||||
)
|
||||
setBatchProgress(null)
|
||||
}
|
||||
|
||||
async function importOne(p: FigureItem) {
|
||||
setBusyLib(p.lib);
|
||||
setBusyLib(p.lib)
|
||||
try {
|
||||
await importViaSse([p]);
|
||||
await importViaSse([p])
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
setBusyLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function importSelected() {
|
||||
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
|
||||
if (toImport.length === 0) return;
|
||||
await importViaSse(toImport);
|
||||
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists)
|
||||
if (toImport.length === 0) return
|
||||
await importViaSse(toImport)
|
||||
}
|
||||
|
||||
async function remove(p: FigureItem) {
|
||||
setBusyLib(p.lib);
|
||||
setBusyLib(p.lib)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
method: 'DELETE',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success(`${p.lib} removed`);
|
||||
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
|
||||
toast.success(`${p.lib} removed`)
|
||||
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)))
|
||||
} else {
|
||||
toast.error(data.error || "Delete failed");
|
||||
toast.error(data.error || 'Delete failed')
|
||||
}
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
setBusyLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +277,12 @@ function LibsView() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doSearch();
|
||||
if (e.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setPage(1);
|
||||
fetchItems("", 1);
|
||||
if (e.key === 'Enter') doSearch()
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setPage(1)
|
||||
fetchItems('', 1)
|
||||
}
|
||||
}}
|
||||
className="pl-9 pr-8"
|
||||
@@ -281,10 +291,10 @@ function LibsView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setPage(1);
|
||||
fetchItems("", 1);
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setPage(1)
|
||||
fetchItems('', 1)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -310,7 +320,9 @@ function LibsView() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
|
||||
{selected.size === importable.length
|
||||
? 'Deselect page'
|
||||
: `Select page (${importable.length})`}
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||
@@ -340,21 +352,28 @@ function LibsView() {
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No libraries found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||
<p className="text-sm">
|
||||
No libraries found{activeSearch ? ` for "${activeSearch}"` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
||||
loading && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{items.map((p) => {
|
||||
const isSelected = selected.has(p.lib);
|
||||
const isSelected = selected.has(p.lib)
|
||||
return (
|
||||
<div
|
||||
key={p.lib}
|
||||
className={cn(
|
||||
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
|
||||
isSelected && "ring-2 ring-primary border-primary",
|
||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
||||
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
|
||||
isSelected && 'ring-2 ring-primary border-primary',
|
||||
)}
|
||||
>
|
||||
{!p.nitroExists && (
|
||||
@@ -362,10 +381,10 @@ function LibsView() {
|
||||
type="button"
|
||||
onClick={() => toggle(p.lib)}
|
||||
className={cn(
|
||||
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
@@ -415,7 +434,7 @@ function LibsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -430,7 +449,7 @@ function LibsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => goPage("prev")}
|
||||
onClick={() => goPage('prev')}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -438,7 +457,7 @@ function LibsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage("next")}
|
||||
onClick={() => goPage('next')}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
@@ -446,120 +465,120 @@ function LibsView() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
|
||||
|
||||
function SetsView() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeSearch, setActiveSearch] = useState("");
|
||||
const [items, setItems] = useState<ClothingSetItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeSearch, setActiveSearch] = useState('')
|
||||
const [items, setItems] = useState<ClothingSetItem[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
|
||||
function itemKey(it: ClothingSetItem): string {
|
||||
return `${it.setType}-${it.setId}`;
|
||||
return `${it.setType}-${it.setId}`
|
||||
}
|
||||
|
||||
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(pageNum) });
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/admin/import/clothing/sets?${params}`);
|
||||
const data = await res.json();
|
||||
const params = new URLSearchParams({ page: String(pageNum) })
|
||||
if (search) params.set('search', search)
|
||||
const res = await fetch(`/api/admin/import/clothing/sets?${params}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
setError(data.error || 'Failed to load')
|
||||
return
|
||||
}
|
||||
setItems(data.item || []);
|
||||
setTotal(data.meta?.total || 0);
|
||||
setSelected(new Set());
|
||||
setItems(data.item || [])
|
||||
setTotal(data.meta?.total || 0)
|
||||
setSelected(new Set())
|
||||
} catch {
|
||||
setError("Network error");
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems("", 1);
|
||||
}, [fetchItems]);
|
||||
fetchItems('', 1)
|
||||
}, [fetchItems])
|
||||
|
||||
function doSearch() {
|
||||
setActiveSearch(searchTerm);
|
||||
setPage(1);
|
||||
fetchItems(searchTerm, 1);
|
||||
setActiveSearch(searchTerm)
|
||||
setPage(1)
|
||||
fetchItems(searchTerm, 1)
|
||||
}
|
||||
|
||||
function goPage(dir: "prev" | "next") {
|
||||
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
|
||||
setPage(next);
|
||||
fetchItems(activeSearch, next);
|
||||
function goPage(dir: 'prev' | 'next') {
|
||||
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
|
||||
setPage(next)
|
||||
fetchItems(activeSearch, next)
|
||||
}
|
||||
|
||||
const importable = useMemo(() => items.filter((it) => !it.present), [items]);
|
||||
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items]);
|
||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||
const importable = useMemo(() => items.filter((it) => !it.present), [items])
|
||||
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items])
|
||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
|
||||
|
||||
function toggle(key: string) {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
const n = new Set(prev)
|
||||
if (n.has(key)) n.delete(key)
|
||||
else n.add(key)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function markDone(label: string) {
|
||||
setItems((prev) => prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)));
|
||||
setItems((prev) => prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)))
|
||||
}
|
||||
|
||||
async function importViaSse(toImport: ClothingSetItem[]) {
|
||||
setBatchProgress({ done: 0, total: toImport.length });
|
||||
let done = 0;
|
||||
setBatchProgress({ done: 0, total: toImport.length })
|
||||
let done = 0
|
||||
await runSseImport(
|
||||
"/api/admin/import/clothing/sets/batch",
|
||||
'/api/admin/import/clothing/sets/batch',
|
||||
{
|
||||
items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })),
|
||||
concurrency: 3,
|
||||
},
|
||||
(label) => {
|
||||
done++;
|
||||
markDone(label);
|
||||
setBatchProgress({ done, total: toImport.length });
|
||||
done++
|
||||
markDone(label)
|
||||
setBatchProgress({ done, total: toImport.length })
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} sets imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
setBatchProgress(null)
|
||||
setSelected(new Set())
|
||||
if (succeeded > 0) toast.success(`${succeeded} sets imported`)
|
||||
if (failed > 0) toast.error(`${failed} failed`)
|
||||
},
|
||||
);
|
||||
setBatchProgress(null);
|
||||
)
|
||||
setBatchProgress(null)
|
||||
}
|
||||
|
||||
async function importOne(it: ClothingSetItem) {
|
||||
const key = itemKey(it);
|
||||
setBusyKey(key);
|
||||
const key = itemKey(it)
|
||||
setBusyKey(key)
|
||||
try {
|
||||
await importViaSse([it]);
|
||||
await importViaSse([it])
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function importSelected() {
|
||||
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present);
|
||||
if (toImport.length === 0) return;
|
||||
await importViaSse(toImport);
|
||||
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present)
|
||||
if (toImport.length === 0) return
|
||||
await importViaSse(toImport)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -592,12 +611,12 @@ function SetsView() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doSearch();
|
||||
if (e.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setPage(1);
|
||||
fetchItems("", 1);
|
||||
if (e.key === 'Enter') doSearch()
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setPage(1)
|
||||
fetchItems('', 1)
|
||||
}
|
||||
}}
|
||||
className="pl-9 pr-8"
|
||||
@@ -606,10 +625,10 @@ function SetsView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
setPage(1);
|
||||
fetchItems("", 1);
|
||||
setSearchTerm('')
|
||||
setActiveSearch('')
|
||||
setPage(1)
|
||||
fetchItems('', 1)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -631,11 +650,15 @@ function SetsView() {
|
||||
disabled={!!batchProgress}
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev.size === importable.length ? new Set() : new Set(importable.map((it) => itemKey(it))),
|
||||
prev.size === importable.length
|
||||
? new Set()
|
||||
: new Set(importable.map((it) => itemKey(it))),
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
|
||||
{selected.size === importable.length
|
||||
? 'Deselect page'
|
||||
: `Select page (${importable.length})`}
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||
@@ -665,22 +688,27 @@ function SetsView() {
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No sets found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||
<p className="text-sm">No sets found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
||||
loading && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{items.map((it) => {
|
||||
const key = itemKey(it);
|
||||
const isSelected = selected.has(key);
|
||||
const key = itemKey(it)
|
||||
const isSelected = selected.has(key)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||
it.present && "ring-1 ring-[var(--admin-success)]/30",
|
||||
isSelected && "ring-2 ring-primary border-primary",
|
||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
||||
it.present && 'ring-1 ring-[var(--admin-success)]/30',
|
||||
isSelected && 'ring-2 ring-primary border-primary',
|
||||
)}
|
||||
>
|
||||
{!it.present && (
|
||||
@@ -688,10 +716,10 @@ function SetsView() {
|
||||
type="button"
|
||||
onClick={() => toggle(key)}
|
||||
className={cn(
|
||||
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
@@ -744,7 +772,7 @@ function SetsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -759,7 +787,7 @@ function SetsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => goPage("prev")}
|
||||
onClick={() => goPage('prev')}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -767,7 +795,7 @@ function SetsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage("next")}
|
||||
onClick={() => goPage('next')}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
@@ -775,36 +803,36 @@ function SetsView() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Root component with mode toggle ──────────────────────────────────────────
|
||||
|
||||
export function ImportClothingClient() {
|
||||
const [mode, setMode] = useState<Mode>("libs");
|
||||
const [mode, setMode] = useState<Mode>('libs')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-1 rounded-lg border p-1 w-fit">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === "libs" ? "default" : "ghost"}
|
||||
variant={mode === 'libs' ? 'default' : 'ghost'}
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setMode("libs")}
|
||||
onClick={() => setMode('libs')}
|
||||
>
|
||||
Librerie
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === "sets" ? "default" : "ghost"}
|
||||
variant={mode === 'sets' ? 'default' : 'ghost'}
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setMode("sets")}
|
||||
onClick={() => setMode('sets')}
|
||||
>
|
||||
Vestiti (set)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mode === "libs" ? <LibsView /> : <SetsView />}
|
||||
{mode === 'libs' ? <LibsView /> : <SetsView />}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -9,5 +9,5 @@ export default function Loading() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportClothingClient } from "./import-clothing-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportClothingClient } from './import-clothing-client'
|
||||
|
||||
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
return <ImportClothingClient />;
|
||||
return <ImportClothingClient />
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -12,160 +12,165 @@ import {
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EffectItem {
|
||||
id: string;
|
||||
lib: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
present: boolean;
|
||||
nitroExists: boolean;
|
||||
id: string
|
||||
lib: string
|
||||
type: string
|
||||
revision: number
|
||||
present: boolean
|
||||
nitroExists: boolean
|
||||
}
|
||||
|
||||
type Filter = "all" | "fx" | "dance";
|
||||
type Filter = 'all' | 'fx' | 'dance'
|
||||
|
||||
export function ImportEffectsClient() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [effects, setEffects] = useState<EffectItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [effects, setEffects] = useState<EffectItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null)
|
||||
|
||||
const fetchEffects = useCallback(async (search: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/admin/import/effects?${params}`);
|
||||
const data = await res.json();
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
const res = await fetch(`/api/admin/import/effects?${params}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
setError(data.error || 'Failed to load')
|
||||
return
|
||||
}
|
||||
setEffects(data.effects || []);
|
||||
setSelected(new Set());
|
||||
setEffects(data.effects || [])
|
||||
setSelected(new Set())
|
||||
} catch {
|
||||
setError("Network error");
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEffects("");
|
||||
}, [fetchEffects]);
|
||||
fetchEffects('')
|
||||
}, [fetchEffects])
|
||||
|
||||
const visible = useMemo(
|
||||
() => effects.filter((e) => filter === "all" || e.type === filter),
|
||||
() => effects.filter((e) => filter === 'all' || e.type === filter),
|
||||
[effects, filter],
|
||||
);
|
||||
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible]);
|
||||
const importedCount = useMemo(() => visible.filter((e) => e.present && e.nitroExists).length, [visible]);
|
||||
)
|
||||
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible])
|
||||
const importedCount = useMemo(
|
||||
() => visible.filter((e) => e.present && e.nitroExists).length,
|
||||
[visible],
|
||||
)
|
||||
|
||||
function toggle(lib: string) {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(lib)) n.delete(lib);
|
||||
else n.add(lib);
|
||||
return n;
|
||||
});
|
||||
const n = new Set(prev)
|
||||
if (n.has(lib)) n.delete(lib)
|
||||
else n.add(lib)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function markDone(lib: string) {
|
||||
setEffects((prev) => prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)));
|
||||
setEffects((prev) =>
|
||||
prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)),
|
||||
)
|
||||
}
|
||||
|
||||
async function importViaSse(items: EffectItem[]) {
|
||||
setBatchProgress({ done: 0, total: items.length });
|
||||
const res = await fetch("/api/admin/import/effects/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
setBatchProgress({ done: 0, total: items.length })
|
||||
const res = await fetch('/api/admin/import/effects/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items, concurrency: 3 }),
|
||||
});
|
||||
})
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
setBatchProgress(null);
|
||||
return;
|
||||
toast.error('No response stream')
|
||||
setBatchProgress(null)
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let done = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
let done = 0
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
const { value, done: streamDone } = await reader.read()
|
||||
if (streamDone) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split('\n\n')
|
||||
buf = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (evt.type === "item_progress" && evt.status === "done") {
|
||||
done++;
|
||||
succeeded++;
|
||||
markDone(evt.classname);
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||
done++;
|
||||
failed++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
if (!part.startsWith('data: ')) continue
|
||||
const evt = JSON.parse(part.slice(6))
|
||||
if (evt.type === 'item_progress' && evt.status === 'done') {
|
||||
done++
|
||||
succeeded++
|
||||
markDone(evt.classname)
|
||||
setBatchProgress({ done, total: items.length })
|
||||
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
|
||||
done++
|
||||
failed++
|
||||
setBatchProgress({ done, total: items.length })
|
||||
} else if (evt.type === 'batch_complete') {
|
||||
succeeded = evt.succeeded
|
||||
failed = evt.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
setBatchProgress(null)
|
||||
setSelected(new Set())
|
||||
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`)
|
||||
if (failed > 0) toast.error(`${failed} failed`)
|
||||
}
|
||||
|
||||
async function importOne(e: EffectItem) {
|
||||
setBusyLib(e.lib);
|
||||
setBusyLib(e.lib)
|
||||
try {
|
||||
await importViaSse([e]);
|
||||
await importViaSse([e])
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
setBusyLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function importSelected() {
|
||||
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists));
|
||||
if (items.length === 0) return;
|
||||
await importViaSse(items);
|
||||
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists))
|
||||
if (items.length === 0) return
|
||||
await importViaSse(items)
|
||||
}
|
||||
|
||||
async function remove(e: EffectItem) {
|
||||
setBusyLib(e.lib);
|
||||
setBusyLib(e.lib)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
method: 'DELETE',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success(`${e.lib} removed`);
|
||||
toast.success(`${e.lib} removed`)
|
||||
setEffects((prev) =>
|
||||
prev.map((x) => (x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x)),
|
||||
);
|
||||
)
|
||||
} else {
|
||||
toast.error(data.error || "Delete failed");
|
||||
toast.error(data.error || 'Delete failed')
|
||||
}
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
setBusyLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +206,10 @@ export function ImportEffectsClient() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") fetchEffects(searchTerm);
|
||||
if (e.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
fetchEffects("");
|
||||
if (e.key === 'Enter') fetchEffects(searchTerm)
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
fetchEffects('')
|
||||
}
|
||||
}}
|
||||
className="pl-9 pr-8"
|
||||
@@ -213,8 +218,8 @@ export function ImportEffectsClient() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
fetchEffects("");
|
||||
setSearchTerm('')
|
||||
fetchEffects('')
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -225,11 +230,11 @@ export function ImportEffectsClient() {
|
||||
<Button onClick={() => fetchEffects(searchTerm)} size="sm" disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
</Button>
|
||||
{(["all", "fx", "dance"] as Filter[]).map((f) => (
|
||||
{(['all', 'fx', 'dance'] as Filter[]).map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant={filter === f ? "default" : "outline"}
|
||||
variant={filter === f ? 'default' : 'outline'}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
@@ -251,7 +256,9 @@ export function ImportEffectsClient() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
|
||||
{selected.size === importable.length
|
||||
? 'Deselect All'
|
||||
: `Select All (${importable.length})`}
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||
@@ -289,17 +296,22 @@ export function ImportEffectsClient() {
|
||||
|
||||
{/* Grid */}
|
||||
{visible.length > 0 && (
|
||||
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
||||
loading && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{visible.map((e) => {
|
||||
const imported = e.present && e.nitroExists;
|
||||
const isSelected = selected.has(e.lib);
|
||||
const imported = e.present && e.nitroExists
|
||||
const isSelected = selected.has(e.lib)
|
||||
return (
|
||||
<div
|
||||
key={e.lib}
|
||||
className={cn(
|
||||
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||
imported && "ring-1 ring-[var(--admin-success)]/30",
|
||||
isSelected && "ring-2 ring-primary border-primary",
|
||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
||||
imported && 'ring-1 ring-[var(--admin-success)]/30',
|
||||
isSelected && 'ring-2 ring-primary border-primary',
|
||||
)}
|
||||
>
|
||||
{!imported && (
|
||||
@@ -307,10 +319,10 @@ export function ImportEffectsClient() {
|
||||
type="button"
|
||||
onClick={() => toggle(e.lib)}
|
||||
className={cn(
|
||||
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
@@ -318,13 +330,13 @@ export function ImportEffectsClient() {
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center h-20 gap-1",
|
||||
e.type === "dance"
|
||||
? "bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10"
|
||||
: "bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10",
|
||||
'flex flex-col items-center justify-center h-20 gap-1',
|
||||
e.type === 'dance'
|
||||
? 'bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10'
|
||||
: 'bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10',
|
||||
)}
|
||||
>
|
||||
{e.type === "dance" ? (
|
||||
{e.type === 'dance' ? (
|
||||
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
|
||||
) : (
|
||||
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
|
||||
@@ -373,10 +385,10 @@ export function ImportEffectsClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export default function Loading() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// src/app/[locale]/admin/import/effects/page.tsx
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportEffectsClient } from "./import-effects-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportEffectsClient } from './import-effects-client'
|
||||
|
||||
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
return <ImportEffectsClient />;
|
||||
return <ImportEffectsClient />
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ImportFurniLoading() {
|
||||
return (
|
||||
@@ -17,5 +17,5 @@ export default function ImportFurniLoading() {
|
||||
<Skeleton className="h-10 w-24 mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -14,65 +14,71 @@ import {
|
||||
Ruler,
|
||||
Save,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
interface NitroEditorDialogProps {
|
||||
classname: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
classname: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
function updateNested(obj: any, path: string, value: unknown): any {
|
||||
const clone = JSON.parse(JSON.stringify(obj));
|
||||
const keys = path.split(".");
|
||||
let current = clone;
|
||||
const clone = JSON.parse(JSON.stringify(obj))
|
||||
const keys = path.split('.')
|
||||
let current = clone
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) current[keys[i]] = {};
|
||||
current = current[keys[i]];
|
||||
if (!current[keys[i]]) current[keys[i]] = {}
|
||||
current = current[keys[i]]
|
||||
}
|
||||
current[keys[keys.length - 1]] = value;
|
||||
return clone;
|
||||
current[keys[keys.length - 1]] = value
|
||||
return clone
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
function getNested(obj: any, path: string, fallback?: unknown): any {
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
const keys = path.split('.')
|
||||
let current = obj
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== "object") return fallback;
|
||||
current = current[key];
|
||||
if (current == null || typeof current !== 'object') return fallback
|
||||
current = current[key]
|
||||
}
|
||||
return current ?? fallback;
|
||||
return current ?? fallback
|
||||
}
|
||||
|
||||
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7];
|
||||
const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
const DIR_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
|
||||
function hexToColor(hex: string): string {
|
||||
return `#${hex.padStart(6, "0")}`;
|
||||
return `#${hex.padStart(6, '0')}`
|
||||
}
|
||||
|
||||
function CopyClassname({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{value}</span>
|
||||
@@ -82,177 +88,178 @@ function CopyClassname({ value }: { value: string }) {
|
||||
<Copy className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
const [metadata, setMetadata] = useState<any>(null);
|
||||
const [metadata, setMetadata] = useState<any>(null)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
const [originalMetadata, setOriginalMetadata] = useState<any>(null);
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>("visual");
|
||||
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false });
|
||||
const [originalMetadata, setOriginalMetadata] = useState<any>(null)
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<string>('visual')
|
||||
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false })
|
||||
const [originalFlags, setOriginalFlags] = useState({
|
||||
canstandon: false,
|
||||
cansiton: false,
|
||||
canlayon: false,
|
||||
});
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
})
|
||||
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||
|
||||
const fetchMetadata = useCallback(async () => {
|
||||
if (!classname) return;
|
||||
setLoading(true);
|
||||
setJsonError(null);
|
||||
if (!classname) return
|
||||
setLoading(true)
|
||||
setJsonError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
|
||||
);
|
||||
)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err.error || "Failed to load .nitro metadata");
|
||||
onOpenChange(false);
|
||||
return;
|
||||
const err = await res.json().catch(() => ({}))
|
||||
toast.error(err.error || 'Failed to load .nitro metadata')
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json();
|
||||
setMetadata(data.metadata);
|
||||
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)));
|
||||
setJsonText(JSON.stringify(data.metadata, null, 2));
|
||||
setFlags(data.flags);
|
||||
setOriginalFlags({ ...data.flags });
|
||||
const data = await res.json()
|
||||
setMetadata(data.metadata)
|
||||
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)))
|
||||
setJsonText(JSON.stringify(data.metadata, null, 2))
|
||||
setFlags(data.flags)
|
||||
setOriginalFlags({ ...data.flags })
|
||||
} catch {
|
||||
toast.error("Network error loading .nitro metadata");
|
||||
onOpenChange(false);
|
||||
toast.error('Network error loading .nitro metadata')
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, [classname, onOpenChange]);
|
||||
}, [classname, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && classname) {
|
||||
setActiveTab("visual");
|
||||
fetchMetadata();
|
||||
setActiveTab('visual')
|
||||
fetchMetadata()
|
||||
}
|
||||
}, [open, classname, fetchMetadata]);
|
||||
}, [open, classname, fetchMetadata])
|
||||
|
||||
const handleTabChange = (newTab: string) => {
|
||||
if (activeTab === "json" && newTab === "visual") {
|
||||
if (activeTab === 'json' && newTab === 'visual') {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setMetadata(parsed);
|
||||
setJsonError(null);
|
||||
const parsed = JSON.parse(jsonText)
|
||||
setMetadata(parsed)
|
||||
setJsonError(null)
|
||||
} catch {
|
||||
toast.error("Invalid JSON — fix it before switching to Visual tab");
|
||||
return;
|
||||
toast.error('Invalid JSON — fix it before switching to Visual tab')
|
||||
return
|
||||
}
|
||||
} else if (activeTab === "visual" && newTab === "json") {
|
||||
setJsonText(JSON.stringify(metadata, null, 2));
|
||||
} else if (activeTab === 'visual' && newTab === 'json') {
|
||||
setJsonText(JSON.stringify(metadata, null, 2))
|
||||
}
|
||||
setActiveTab(newTab);
|
||||
};
|
||||
setActiveTab(newTab)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (!originalMetadata) return;
|
||||
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
|
||||
setFlags({ ...originalFlags });
|
||||
setJsonText(JSON.stringify(originalMetadata, null, 2));
|
||||
toast.info("Reset to original values");
|
||||
};
|
||||
if (!originalMetadata) return
|
||||
setMetadata(JSON.parse(JSON.stringify(originalMetadata)))
|
||||
setFlags({ ...originalFlags })
|
||||
setJsonText(JSON.stringify(originalMetadata, null, 2))
|
||||
toast.info('Reset to original values')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
let metadataToSave = metadata;
|
||||
let metadataToSave = metadata
|
||||
|
||||
if (activeTab === "json") {
|
||||
if (activeTab === 'json') {
|
||||
try {
|
||||
metadataToSave = JSON.parse(jsonText);
|
||||
metadataToSave = JSON.parse(jsonText)
|
||||
} catch {
|
||||
toast.error("Invalid JSON");
|
||||
return;
|
||||
toast.error('Invalid JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/admin/import/furni/nitro-editor", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const res = await fetch('/api/admin/import/furni/nitro-editor', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
classname,
|
||||
metadata: metadataToSave,
|
||||
...flags,
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err.error || "Save failed");
|
||||
return;
|
||||
const err = await res.json().catch(() => ({}))
|
||||
toast.error(err.error || 'Save failed')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Nitro metadata salvato");
|
||||
setMetadata(metadataToSave);
|
||||
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)));
|
||||
setOriginalFlags({ ...flags });
|
||||
setJsonText(JSON.stringify(metadataToSave, null, 2));
|
||||
toast.success('Nitro metadata salvato')
|
||||
setMetadata(metadataToSave)
|
||||
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)))
|
||||
setOriginalFlags({ ...flags })
|
||||
setJsonText(JSON.stringify(metadataToSave, null, 2))
|
||||
} catch {
|
||||
toast.error("Network error");
|
||||
toast.error('Network error')
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Visual tab helpers ──
|
||||
|
||||
const dims = metadata
|
||||
? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 })
|
||||
: { x: 1, y: 1, z: 0 };
|
||||
const directions: number[] = metadata ? getNested(metadata, "logic.model.directions", [0]) : [0];
|
||||
? getNested(metadata, 'logic.model.dimensions', { x: 1, y: 1, z: 0 })
|
||||
: { x: 1, y: 1, z: 0 }
|
||||
const directions: number[] = metadata ? getNested(metadata, 'logic.model.directions', [0]) : [0]
|
||||
|
||||
const setDim = (axis: "x" | "y" | "z", value: string) => {
|
||||
const num = axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0;
|
||||
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num));
|
||||
};
|
||||
const setDim = (axis: 'x' | 'y' | 'z', value: string) => {
|
||||
const num = axis === 'z' ? parseFloat(value) || 0 : parseInt(value, 10) || 0
|
||||
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num))
|
||||
}
|
||||
|
||||
const toggleDirection = (dir: number) => {
|
||||
const current: number[] = getNested(metadata, "logic.model.directions", [0]);
|
||||
const current: number[] = getNested(metadata, 'logic.model.directions', [0])
|
||||
const next = current.includes(dir)
|
||||
? current.filter((d: number) => d !== dir)
|
||||
: [...current, dir].sort((a: number, b: number) => a - b);
|
||||
if (next.length === 0) return;
|
||||
setMetadata((prev: unknown) => updateNested(prev, "logic.model.directions", next));
|
||||
};
|
||||
: [...current, dir].sort((a: number, b: number) => a - b)
|
||||
if (next.length === 0) return
|
||||
setMetadata((prev: unknown) => updateNested(prev, 'logic.model.directions', next))
|
||||
}
|
||||
|
||||
// Get visualization with size=64 for layer/color editing
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64);
|
||||
const viz64Index = metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1;
|
||||
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64)
|
||||
const viz64Index =
|
||||
metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1
|
||||
|
||||
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0;
|
||||
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0;
|
||||
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0;
|
||||
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0
|
||||
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0
|
||||
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0
|
||||
|
||||
const handleJsonTextChange = (value: string) => {
|
||||
setJsonText(value);
|
||||
setJsonText(value)
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setJsonError(null);
|
||||
JSON.parse(value)
|
||||
setJsonError(null)
|
||||
} catch (e) {
|
||||
setJsonError((e as Error).message);
|
||||
setJsonError((e as Error).message)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges =
|
||||
metadata &&
|
||||
originalMetadata &&
|
||||
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
|
||||
JSON.stringify(flags) !== JSON.stringify(originalFlags));
|
||||
JSON.stringify(flags) !== JSON.stringify(originalFlags))
|
||||
|
||||
// Furni preview URL
|
||||
const starIdx = classname.indexOf("*");
|
||||
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
|
||||
const starIdx = classname.indexOf('*')
|
||||
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -264,18 +271,18 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<div
|
||||
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: "url(/images/furni_floor.png)",
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundPosition: "center",
|
||||
backgroundImage: 'url(/images/furni_floor.png)',
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
|
||||
alt={classname}
|
||||
className="max-w-[60px] max-h-[60px] drop-shadow-md"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "0";
|
||||
;(e.target as HTMLImageElement).style.opacity = '0'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -311,7 +318,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
)}
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Eye className="w-2.5 h-2.5" />
|
||||
{metadata.visualizationType || "?"}
|
||||
{metadata.visualizationType || '?'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -324,7 +331,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : metadata ? (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div className="px-6 pt-3 pb-0">
|
||||
<TabsList className="w-fit">
|
||||
<TabsTrigger value="visual" className="gap-1.5">
|
||||
@@ -332,7 +343,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
Visual
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-1.5">
|
||||
{"{ }"}
|
||||
{'{ }'}
|
||||
JSON
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -358,7 +369,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
type="number"
|
||||
min={1}
|
||||
value={dims.x}
|
||||
onChange={(e) => setDim("x", e.target.value)}
|
||||
onChange={(e) => setDim('x', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -371,7 +382,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
type="number"
|
||||
min={1}
|
||||
value={dims.y}
|
||||
onChange={(e) => setDim("y", e.target.value)}
|
||||
onChange={(e) => setDim('y', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -385,7 +396,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
step={0.01}
|
||||
min={0}
|
||||
value={dims.z}
|
||||
onChange={(e) => setDim("z", e.target.value)}
|
||||
onChange={(e) => setDim('z', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -429,7 +440,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Compass className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Direzioni</h3>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{directions.length} attive</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{directions.length} attive
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-48 h-48">
|
||||
@@ -438,19 +451,19 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<div className="absolute inset-4 rounded-full border border-muted/50" />
|
||||
{/* Direction buttons arranged in circle */}
|
||||
{ALL_DIRECTIONS.map((dir) => {
|
||||
const angle = (dir * 45 - 90) * (Math.PI / 180);
|
||||
const r = 80;
|
||||
const x = 96 + r * Math.cos(angle) - 18;
|
||||
const y = 96 + r * Math.sin(angle) - 18;
|
||||
const isActive = directions.includes(dir);
|
||||
const angle = (dir * 45 - 90) * (Math.PI / 180)
|
||||
const r = 80
|
||||
const x = 96 + r * Math.cos(angle) - 18
|
||||
const y = 96 + r * Math.sin(angle) - 18
|
||||
const isActive = directions.includes(dir)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={dir}
|
||||
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
|
||||
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
|
||||
? 'bg-primary text-primary-foreground border-primary shadow-md scale-110'
|
||||
: 'bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105'
|
||||
}`}
|
||||
style={{ left: x, top: y }}
|
||||
onClick={() => toggleDirection(dir)}
|
||||
@@ -458,11 +471,13 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<span className="text-[9px] leading-none">{DIR_LABELS[dir]}</span>
|
||||
<span className="text-[8px] leading-none opacity-60">{dir}</span>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{/* Center label */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground font-medium">{directions.length}/8</span>
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{directions.length}/8
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,10 +496,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
</Label>
|
||||
<Input
|
||||
id="viz-type"
|
||||
value={metadata.visualizationType || ""}
|
||||
value={metadata.visualizationType || ''}
|
||||
onChange={(e) =>
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(prev, "visualizationType", e.target.value),
|
||||
updateNested(prev, 'visualizationType', e.target.value),
|
||||
)
|
||||
}
|
||||
className="mt-1 font-mono text-sm"
|
||||
@@ -496,9 +511,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
</Label>
|
||||
<Input
|
||||
id="logic-type"
|
||||
value={metadata.logicType || ""}
|
||||
value={metadata.logicType || ''}
|
||||
onChange={(e) =>
|
||||
setMetadata((prev: unknown) => updateNested(prev, "logicType", e.target.value))
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(prev, 'logicType', e.target.value),
|
||||
)
|
||||
}
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
@@ -525,12 +542,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<span>Ink</span>
|
||||
</div>
|
||||
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
|
||||
const layer = layerData as Record<string, unknown>;
|
||||
const layer = layerData as Record<string, unknown>
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
|
||||
idx % 2 === 0 ? "bg-card" : "bg-muted/20"
|
||||
idx % 2 === 0 ? 'bg-card' : 'bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs">
|
||||
@@ -549,7 +566,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
`visualizations.${viz64Index}.layers.${layerId}.z`,
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
),
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
@@ -565,12 +582,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
),
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
value={(layer.ink as string) ?? ""}
|
||||
value={(layer.ink as string) ?? ''}
|
||||
placeholder="—"
|
||||
onChange={(e) => {
|
||||
setMetadata((prev: unknown) =>
|
||||
@@ -579,11 +596,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
`visualizations.${viz64Index}.layers.${layerId}.ink`,
|
||||
e.target.value || undefined,
|
||||
),
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
@@ -606,20 +623,22 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<span className="text-xs font-medium">Variante #{colorId}</span>
|
||||
{/* Color preview dots */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{Object.values(colorLayers as Record<string, { color: number }>).map((lc, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||
style={{
|
||||
backgroundColor: hexToColor(
|
||||
typeof lc.color === "number"
|
||||
? lc.color.toString(16).padStart(6, "0")
|
||||
: "000000",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{Object.values(colorLayers as Record<string, { color: number }>).map(
|
||||
(lc, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||
style={{
|
||||
backgroundColor: hexToColor(
|
||||
typeof lc.color === 'number'
|
||||
? lc.color.toString(16).padStart(6, '0')
|
||||
: '000000',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
@@ -633,20 +652,25 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<Input
|
||||
className="h-7 text-xs font-mono pl-8"
|
||||
value={
|
||||
typeof layerColor.color === "number"
|
||||
? layerColor.color.toString(16).padStart(6, "0").toUpperCase()
|
||||
typeof layerColor.color === 'number'
|
||||
? layerColor.color
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
.toUpperCase()
|
||||
: String(layerColor.color)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
|
||||
const num = parseInt(hex, 16) || 0;
|
||||
const hex = e.target.value
|
||||
.replace(/[^0-9a-fA-F]/g, '')
|
||||
.slice(0, 6)
|
||||
const num = parseInt(hex, 16) || 0
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(
|
||||
prev,
|
||||
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
|
||||
num,
|
||||
),
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/* Color swatch inside input */}
|
||||
@@ -654,9 +678,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
|
||||
style={{
|
||||
backgroundColor: hexToColor(
|
||||
typeof layerColor.color === "number"
|
||||
? layerColor.color.toString(16).padStart(6, "0")
|
||||
: "000000",
|
||||
typeof layerColor.color === 'number'
|
||||
? layerColor.color.toString(16).padStart(6, '0')
|
||||
: '000000',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -694,7 +718,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{hasChanges && (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
|
||||
>
|
||||
Modifiche non salvate
|
||||
</Badge>
|
||||
)}
|
||||
@@ -712,7 +739,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || loading || !metadata} className="gap-1.5">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !metadata}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Salva
|
||||
</Button>
|
||||
@@ -720,5 +751,5 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportFurniClient } from "./import-furni-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportFurniClient } from './import-furni-client'
|
||||
|
||||
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
return <ImportFurniClient />;
|
||||
return <ImportFurniClient />
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { AlertCircle, Check, Download, Loader2, Package, PawPrint, Search, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Loader2,
|
||||
Package,
|
||||
PawPrint,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PetItem {
|
||||
lib: string;
|
||||
nitroExists: boolean;
|
||||
lib: string
|
||||
nitroExists: boolean
|
||||
}
|
||||
|
||||
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
|
||||
function PetIcon({ lib }: { lib: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
|
||||
<img
|
||||
@@ -26,135 +36,135 @@ function PetIcon({ lib }: { lib: string }) {
|
||||
style={{ maxHeight: 56, maxWidth: 56 }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function ImportPetsClient() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [pets, setPets] = useState<PetItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [pets, setPets] = useState<PetItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null)
|
||||
|
||||
const fetchPets = useCallback(async (search: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/admin/import/pets?${params}`);
|
||||
const data = await res.json();
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
const res = await fetch(`/api/admin/import/pets?${params}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to load");
|
||||
return;
|
||||
setError(data.error || 'Failed to load')
|
||||
return
|
||||
}
|
||||
setPets(data.pets || []);
|
||||
setSelected(new Set());
|
||||
setPets(data.pets || [])
|
||||
setSelected(new Set())
|
||||
} catch {
|
||||
setError("Network error");
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPets("");
|
||||
}, [fetchPets]);
|
||||
fetchPets('')
|
||||
}, [fetchPets])
|
||||
|
||||
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
|
||||
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]);
|
||||
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets])
|
||||
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets])
|
||||
|
||||
function toggle(lib: string) {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(lib)) n.delete(lib);
|
||||
else n.add(lib);
|
||||
return n;
|
||||
});
|
||||
const n = new Set(prev)
|
||||
if (n.has(lib)) n.delete(lib)
|
||||
else n.add(lib)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function markDone(lib: string) {
|
||||
setPets((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)));
|
||||
setPets((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)))
|
||||
}
|
||||
|
||||
async function importViaSse(items: PetItem[]) {
|
||||
setBatchProgress({ done: 0, total: items.length });
|
||||
const res = await fetch("/api/admin/import/pets/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
setBatchProgress({ done: 0, total: items.length })
|
||||
const res = await fetch('/api/admin/import/pets/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items, concurrency: 3 }),
|
||||
});
|
||||
})
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
setBatchProgress(null);
|
||||
return;
|
||||
toast.error('No response stream')
|
||||
setBatchProgress(null)
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let done = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
let done = 0
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
const { value, done: streamDone } = await reader.read()
|
||||
if (streamDone) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split('\n\n')
|
||||
buf = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (evt.type === "item_progress" && evt.status === "done") {
|
||||
done++;
|
||||
markDone(evt.classname);
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
if (!part.startsWith('data: ')) continue
|
||||
const evt = JSON.parse(part.slice(6))
|
||||
if (evt.type === 'item_progress' && evt.status === 'done') {
|
||||
done++
|
||||
markDone(evt.classname)
|
||||
setBatchProgress({ done, total: items.length })
|
||||
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
|
||||
done++
|
||||
setBatchProgress({ done, total: items.length })
|
||||
} else if (evt.type === 'batch_complete') {
|
||||
succeeded = evt.succeeded
|
||||
failed = evt.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
setBatchProgress(null)
|
||||
setSelected(new Set())
|
||||
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`)
|
||||
if (failed > 0) toast.error(`${failed} failed`)
|
||||
}
|
||||
|
||||
async function importOne(p: PetItem) {
|
||||
setBusyLib(p.lib);
|
||||
setBusyLib(p.lib)
|
||||
try {
|
||||
await importViaSse([p]);
|
||||
await importViaSse([p])
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
setBusyLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function importSelected() {
|
||||
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
|
||||
if (items.length === 0) return;
|
||||
await importViaSse(items);
|
||||
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists)
|
||||
if (items.length === 0) return
|
||||
await importViaSse(items)
|
||||
}
|
||||
|
||||
async function remove(p: PetItem) {
|
||||
setBusyLib(p.lib);
|
||||
setBusyLib(p.lib)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
method: 'DELETE',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success(`${p.lib} removed`);
|
||||
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
|
||||
toast.success(`${p.lib} removed`)
|
||||
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)))
|
||||
} else {
|
||||
toast.error(data.error || "Delete failed");
|
||||
toast.error(data.error || 'Delete failed')
|
||||
}
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
setBusyLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +198,10 @@ export function ImportPetsClient() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") fetchPets(searchTerm);
|
||||
if (e.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
fetchPets("");
|
||||
if (e.key === 'Enter') fetchPets(searchTerm)
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
fetchPets('')
|
||||
}
|
||||
}}
|
||||
className="pl-9 pr-8"
|
||||
@@ -200,8 +210,8 @@ export function ImportPetsClient() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
fetchPets("");
|
||||
setSearchTerm('')
|
||||
fetchPets('')
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -227,7 +237,9 @@ export function ImportPetsClient() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
|
||||
{selected.size === importable.length
|
||||
? 'Deselect All'
|
||||
: `Select All (${importable.length})`}
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||
@@ -262,16 +274,21 @@ export function ImportPetsClient() {
|
||||
)}
|
||||
|
||||
{pets.length > 0 && (
|
||||
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
||||
loading && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{pets.map((p) => {
|
||||
const isSelected = selected.has(p.lib);
|
||||
const isSelected = selected.has(p.lib)
|
||||
return (
|
||||
<div
|
||||
key={p.lib}
|
||||
className={cn(
|
||||
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
|
||||
isSelected && "ring-2 ring-primary border-primary",
|
||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
||||
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
|
||||
isSelected && 'ring-2 ring-primary border-primary',
|
||||
)}
|
||||
>
|
||||
{!p.nitroExists && (
|
||||
@@ -279,10 +296,10 @@ export function ImportPetsClient() {
|
||||
type="button"
|
||||
onClick={() => toggle(p.lib)}
|
||||
className={cn(
|
||||
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
@@ -330,10 +347,10 @@ export function ImportPetsClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -9,5 +9,5 @@ export default function Loading() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportPetsClient } from "./import-pets-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportPetsClient } from './import-pets-client'
|
||||
|
||||
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
return <ImportPetsClient />;
|
||||
return <ImportPetsClient />
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { RepairClient } from "./repair-client";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { RepairClient } from './repair-client'
|
||||
|
||||
export default async function RepairImportedPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
return <RepairClient />;
|
||||
return <RepairClient />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
Trash2,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteImportedFurni } from "@/actions/import-furni";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { deleteImportedFurni } from '@/actions/import-furni'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,238 +24,245 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
type MissingKind = "nitro" | "icon" | "both";
|
||||
type MissingKind = 'nitro' | 'icon' | 'both'
|
||||
|
||||
interface MissingItem {
|
||||
id: number;
|
||||
classname: string;
|
||||
baseClassname: string;
|
||||
revision: number;
|
||||
type: "s" | "i";
|
||||
publicName: string;
|
||||
hasSwf: boolean;
|
||||
hasIcon: boolean;
|
||||
hasNitro: boolean;
|
||||
missing: MissingKind;
|
||||
unrepairable: boolean;
|
||||
id: number
|
||||
classname: string
|
||||
baseClassname: string
|
||||
revision: number
|
||||
type: 's' | 'i'
|
||||
publicName: string
|
||||
hasSwf: boolean
|
||||
hasIcon: boolean
|
||||
hasNitro: boolean
|
||||
missing: MissingKind
|
||||
unrepairable: boolean
|
||||
}
|
||||
|
||||
interface AuditSummary {
|
||||
total: number;
|
||||
missingBoth: number;
|
||||
missingNitro: number;
|
||||
missingIcon: number;
|
||||
unrepairable: number;
|
||||
items: MissingItem[];
|
||||
total: number
|
||||
missingBoth: number
|
||||
missingNitro: number
|
||||
missingIcon: number
|
||||
unrepairable: number
|
||||
items: MissingItem[]
|
||||
}
|
||||
|
||||
type ItemStatus = "idle" | "working" | "done" | "partial" | "failed";
|
||||
type ItemStatus = 'idle' | 'working' | 'done' | 'partial' | 'failed'
|
||||
|
||||
interface ItemProgress {
|
||||
status: ItemStatus;
|
||||
message?: string;
|
||||
nitroRepaired?: boolean;
|
||||
iconRepaired?: boolean;
|
||||
unrepairable?: boolean;
|
||||
status: ItemStatus
|
||||
message?: string
|
||||
nitroRepaired?: boolean
|
||||
iconRepaired?: boolean
|
||||
unrepairable?: boolean
|
||||
}
|
||||
|
||||
interface RepairSummary {
|
||||
succeeded: number;
|
||||
partial: number;
|
||||
failed: number;
|
||||
unrepairable: number;
|
||||
duration: number;
|
||||
succeeded: number
|
||||
partial: number
|
||||
failed: number
|
||||
unrepairable: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = {
|
||||
all: () => true,
|
||||
both: (m) => m.missing === "both",
|
||||
nitro: (m) => m.missing === "nitro",
|
||||
icon: (m) => m.missing === "icon",
|
||||
both: (m) => m.missing === 'both',
|
||||
nitro: (m) => m.missing === 'nitro',
|
||||
icon: (m) => m.missing === 'icon',
|
||||
unrepairable: (m) => m.unrepairable,
|
||||
};
|
||||
}
|
||||
|
||||
export function RepairClient() {
|
||||
const [summary, setSummary] = useState<AuditSummary | null>(null);
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
const [repairing, setRepairing] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>("all");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map());
|
||||
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null);
|
||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
||||
const [summary, setSummary] = useState<AuditSummary | null>(null)
|
||||
const [auditing, setAuditing] = useState(false)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>('all')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map())
|
||||
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null)
|
||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false)
|
||||
|
||||
// ── Audit ──────────────────────────────────────────────────────────
|
||||
const runAudit = useCallback(async () => {
|
||||
setAuditing(true);
|
||||
setLastSummary(null);
|
||||
setAuditing(true)
|
||||
setLastSummary(null)
|
||||
try {
|
||||
const res = await fetch("/api/admin/import/repair/audit", { method: "POST" });
|
||||
const res = await fetch('/api/admin/import/repair/audit', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
toast.error("Audit failed");
|
||||
return;
|
||||
toast.error('Audit failed')
|
||||
return
|
||||
}
|
||||
const data = await res.json();
|
||||
setSummary(data);
|
||||
setSelected(new Set());
|
||||
setProgress(new Map());
|
||||
const data = await res.json()
|
||||
setSummary(data)
|
||||
setSelected(new Set())
|
||||
setProgress(new Map())
|
||||
toast.success(
|
||||
`Audit: ${data.items.length} item${data.items.length === 1 ? "" : "s"} need repair (${data.total} scanned)`,
|
||||
);
|
||||
`Audit: ${data.items.length} item${data.items.length === 1 ? '' : 's'} need repair (${data.total} scanned)`,
|
||||
)
|
||||
} catch {
|
||||
toast.error("Audit network error");
|
||||
toast.error('Audit network error')
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
setAuditing(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// ── Filters ────────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
if (!summary) return [];
|
||||
const s = search.trim().toLowerCase();
|
||||
if (!summary) return []
|
||||
const s = search.trim().toLowerCase()
|
||||
return summary.items.filter((it) => {
|
||||
if (!TAB_FILTERS[tab](it)) return false;
|
||||
if (!s) return true;
|
||||
if (!TAB_FILTERS[tab](it)) return false
|
||||
if (!s) return true
|
||||
return (
|
||||
it.classname.toLowerCase().includes(s) ||
|
||||
it.publicName.toLowerCase().includes(s) ||
|
||||
String(it.id).includes(s)
|
||||
);
|
||||
});
|
||||
}, [summary, search, tab]);
|
||||
)
|
||||
})
|
||||
}, [summary, search, tab])
|
||||
|
||||
// ── Selection ──────────────────────────────────────────────────────
|
||||
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname));
|
||||
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname))
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) => {
|
||||
if (allSelected) {
|
||||
const next = new Set(prev);
|
||||
const next = new Set(prev)
|
||||
for (const i of filtered) {
|
||||
next.delete(i.classname);
|
||||
next.delete(i.classname)
|
||||
}
|
||||
return next;
|
||||
return next
|
||||
}
|
||||
const next = new Set(prev);
|
||||
const next = new Set(prev)
|
||||
for (const i of filtered) {
|
||||
next.add(i.classname);
|
||||
next.add(i.classname)
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleOne = (cn: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cn)) next.delete(cn);
|
||||
else next.add(cn);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const next = new Set(prev)
|
||||
if (next.has(cn)) next.delete(cn)
|
||||
else next.add(cn)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// ── Repair ─────────────────────────────────────────────────────────
|
||||
const runRepair = useCallback(async (classnames: string[]) => {
|
||||
if (classnames.length === 0) return;
|
||||
setRepairing(true);
|
||||
setLastSummary(null);
|
||||
if (classnames.length === 0) return
|
||||
setRepairing(true)
|
||||
setLastSummary(null)
|
||||
|
||||
// Init progress for these items
|
||||
setProgress((prev) => {
|
||||
const next = new Map(prev);
|
||||
const next = new Map(prev)
|
||||
for (const cn of classnames) {
|
||||
next.set(cn, { status: "working" });
|
||||
next.set(cn, { status: 'working' })
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/import/repair", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const res = await fetch('/api/admin/import/repair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ classnames, concurrency: 6 }),
|
||||
});
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error("Failed to start repair");
|
||||
setRepairing(false);
|
||||
return;
|
||||
toast.error('Failed to start repair')
|
||||
setRepairing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.type === "repair_progress") {
|
||||
const event = JSON.parse(line.slice(6))
|
||||
if (event.type === 'repair_progress') {
|
||||
setProgress((prev) => {
|
||||
const next = new Map(prev);
|
||||
const next = new Map(prev)
|
||||
next.set(event.classname, {
|
||||
status: event.status as ItemStatus,
|
||||
message: event.error || (event.warnings?.[0] ?? undefined),
|
||||
nitroRepaired: event.nitroRepaired,
|
||||
iconRepaired: event.iconRepaired,
|
||||
unrepairable: event.unrepairable,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
})
|
||||
return next
|
||||
})
|
||||
// Promote newly-discovered unrepairable flags onto the audit list
|
||||
if (event.unrepairable) {
|
||||
setSummary((prev) => {
|
||||
if (!prev) return prev;
|
||||
const item = prev.items.find((i) => i.classname === event.classname);
|
||||
if (!item || item.unrepairable) return prev;
|
||||
if (!prev) return prev
|
||||
const item = prev.items.find((i) => i.classname === event.classname)
|
||||
if (!item || item.unrepairable) return prev
|
||||
return {
|
||||
...prev,
|
||||
unrepairable: prev.unrepairable + 1,
|
||||
items: prev.items.map((i) =>
|
||||
i.classname === event.classname ? { ...i, unrepairable: true } : i,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
// Remove repaired items from the audit list on "done"
|
||||
if (event.status === "done") {
|
||||
if (event.status === 'done') {
|
||||
setSummary((prev) => {
|
||||
if (!prev) return prev;
|
||||
const items = prev.items.filter((i) => i.classname !== event.classname);
|
||||
const diff = prev.items.length - items.length;
|
||||
if (diff === 0) return prev;
|
||||
const removed = prev.items.find((i) => i.classname === event.classname);
|
||||
if (!prev) return prev
|
||||
const items = prev.items.filter((i) => i.classname !== event.classname)
|
||||
const diff = prev.items.length - items.length
|
||||
if (diff === 0) return prev
|
||||
const removed = prev.items.find((i) => i.classname === event.classname)
|
||||
return {
|
||||
...prev,
|
||||
items,
|
||||
missingBoth: prev.missingBoth - (removed?.missing === "both" ? 1 : 0),
|
||||
missingNitro: prev.missingNitro - (removed?.missing === "nitro" ? 1 : 0),
|
||||
missingIcon: prev.missingIcon - (removed?.missing === "icon" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
missingBoth: prev.missingBoth - (removed?.missing === 'both' ? 1 : 0),
|
||||
missingNitro: prev.missingNitro - (removed?.missing === 'nitro' ? 1 : 0),
|
||||
missingIcon: prev.missingIcon - (removed?.missing === 'icon' ? 1 : 0),
|
||||
}
|
||||
})
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(event.classname);
|
||||
return next;
|
||||
});
|
||||
const next = new Set(prev)
|
||||
next.delete(event.classname)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} else if (event.type === "repair_complete") {
|
||||
} else if (event.type === 'repair_complete') {
|
||||
setLastSummary({
|
||||
succeeded: event.succeeded,
|
||||
partial: event.partial,
|
||||
failed: event.failed,
|
||||
unrepairable: event.unrepairable ?? 0,
|
||||
duration: event.duration,
|
||||
});
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
@@ -263,72 +270,72 @@ export function RepairClient() {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("Connection lost during repair");
|
||||
toast.error('Connection lost during repair')
|
||||
} finally {
|
||||
setRepairing(false);
|
||||
setRepairing(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// ── Delete unrepairable items ──────────────────────────────────────
|
||||
const deleteSelected = useCallback(async () => {
|
||||
const toDelete = Array.from(selected);
|
||||
if (toDelete.length === 0) return;
|
||||
setConfirmDeleteAll(false);
|
||||
const toDelete = Array.from(selected)
|
||||
if (toDelete.length === 0) return
|
||||
setConfirmDeleteAll(false)
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
let ok = 0
|
||||
let fail = 0
|
||||
for (const cn of toDelete) {
|
||||
setProgress((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(cn, { status: "working", message: "Deleting…" });
|
||||
return next;
|
||||
});
|
||||
const next = new Map(prev)
|
||||
next.set(cn, { status: 'working', message: 'Deleting…' })
|
||||
return next
|
||||
})
|
||||
try {
|
||||
const result = await deleteImportedFurni({ classname: cn });
|
||||
const result = await deleteImportedFurni({ classname: cn })
|
||||
if (result.ok) {
|
||||
ok++;
|
||||
ok++
|
||||
setSummary((prev) => {
|
||||
if (!prev) return prev;
|
||||
const removed = prev.items.find((i) => i.classname === cn);
|
||||
if (!removed) return prev;
|
||||
if (!prev) return prev
|
||||
const removed = prev.items.find((i) => i.classname === cn)
|
||||
if (!removed) return prev
|
||||
return {
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
items: prev.items.filter((i) => i.classname !== cn),
|
||||
missingBoth: prev.missingBoth - (removed.missing === "both" ? 1 : 0),
|
||||
missingNitro: prev.missingNitro - (removed.missing === "nitro" ? 1 : 0),
|
||||
missingIcon: prev.missingIcon - (removed.missing === "icon" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
missingBoth: prev.missingBoth - (removed.missing === 'both' ? 1 : 0),
|
||||
missingNitro: prev.missingNitro - (removed.missing === 'nitro' ? 1 : 0),
|
||||
missingIcon: prev.missingIcon - (removed.missing === 'icon' ? 1 : 0),
|
||||
}
|
||||
})
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(cn);
|
||||
return next;
|
||||
});
|
||||
const next = new Set(prev)
|
||||
next.delete(cn)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
fail++;
|
||||
fail++
|
||||
setProgress((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(cn, { status: "failed", message: "Delete failed" });
|
||||
return next;
|
||||
});
|
||||
const next = new Map(prev)
|
||||
next.set(cn, { status: 'failed', message: 'Delete failed' })
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
fail++;
|
||||
fail++
|
||||
setProgress((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(cn, { status: "failed", message: "Delete error" });
|
||||
return next;
|
||||
});
|
||||
const next = new Map(prev)
|
||||
next.set(cn, { status: 'failed', message: 'Delete error' })
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`);
|
||||
}, [selected]);
|
||||
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ''}`)
|
||||
}, [selected])
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────
|
||||
|
||||
const selectedArray = Array.from(selected);
|
||||
const selectedCount = selectedArray.length;
|
||||
const selectedArray = Array.from(selected)
|
||||
const selectedCount = selectedArray.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -337,8 +344,9 @@ export function RepairClient() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and icons for
|
||||
items already imported. Items with no public asset on habbo.com can be deleted from here.
|
||||
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and
|
||||
icons for items already imported. Items with no public asset on habbo.com can be deleted
|
||||
from here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -349,7 +357,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>
|
||||
@@ -360,10 +368,12 @@ export function RepairClient() {
|
||||
{!summary && !auditing && (
|
||||
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
||||
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="font-medium">Click “Scan Assets” to audit imported furniture.</p>
|
||||
<p className="font-medium">
|
||||
Click “Scan Assets” to audit imported furniture.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k items takes
|
||||
a few seconds.
|
||||
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k
|
||||
items takes a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -378,7 +388,12 @@ export function RepairClient() {
|
||||
tone="danger"
|
||||
sub="no .nitro · no icon"
|
||||
/>
|
||||
<StatCard label="Missing .nitro" value={summary.missingNitro} tone="warn" sub="shows black cube" />
|
||||
<StatCard
|
||||
label="Missing .nitro"
|
||||
value={summary.missingNitro}
|
||||
tone="warn"
|
||||
sub="shows black cube"
|
||||
/>
|
||||
<StatCard
|
||||
label="Missing icon"
|
||||
value={summary.missingIcon}
|
||||
@@ -422,7 +437,9 @@ export function RepairClient() {
|
||||
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
||||
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
|
||||
<p className="font-medium">All imported items have their assets on disk.</p>
|
||||
<p className="text-sm text-muted-foreground">{summary.total} items scanned, nothing to repair.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.total} items scanned, nothing to repair.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -444,15 +461,16 @@ export function RepairClient() {
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
// Skip unrepairable items — no point paying the network cost for them
|
||||
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname);
|
||||
const unrep = summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? [];
|
||||
const unrepSet = new Set(unrep);
|
||||
const targets = pool.filter((cn) => !unrepSet.has(cn));
|
||||
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname)
|
||||
const unrep =
|
||||
summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? []
|
||||
const unrepSet = new Set(unrep)
|
||||
const targets = pool.filter((cn) => !unrepSet.has(cn))
|
||||
if (targets.length === 0) {
|
||||
toast.info("Nothing to repair — selected items are all flagged unrepairable.");
|
||||
return;
|
||||
toast.info('Nothing to repair — selected items are all flagged unrepairable.')
|
||||
return
|
||||
}
|
||||
runRepair(targets);
|
||||
runRepair(targets)
|
||||
}}
|
||||
disabled={repairing || filtered.length === 0}
|
||||
>
|
||||
@@ -484,9 +502,9 @@ export function RepairClient() {
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
// Auto-select every unrepairable item, then open the same confirm dialog
|
||||
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname);
|
||||
setSelected(new Set(unrep));
|
||||
setConfirmDeleteAll(true);
|
||||
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname)
|
||||
setSelected(new Set(unrep))
|
||||
setConfirmDeleteAll(true)
|
||||
}}
|
||||
disabled={repairing}
|
||||
>
|
||||
@@ -511,7 +529,11 @@ export function RepairClient() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">ID</TableHead>
|
||||
<TableHead>Classname</TableHead>
|
||||
@@ -554,12 +576,13 @@ export function RepairClient() {
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Delete {selectedCount} imported item{selectedCount === 1 ? "" : "s"}?
|
||||
Delete {selectedCount} imported item{selectedCount === 1 ? '' : 's'}?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the entry in{" "}
|
||||
<code>FurnitureData.json</code>, and any remaining asset files on disk. Use this for items that
|
||||
have no public asset on habbo.com and therefore cannot be repaired. This cannot be undone.
|
||||
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the
|
||||
entry in <code>FurnitureData.json</code>, and any remaining asset files on disk. Use
|
||||
this for items that have no public asset on habbo.com and therefore cannot be
|
||||
repaired. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -573,7 +596,7 @@ export function RepairClient() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ── Small cell helpers ────────────────────────────────────────────────
|
||||
@@ -584,24 +607,24 @@ function StatCard({
|
||||
tone,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "default" | "danger" | "warn" | "info";
|
||||
sub?: string;
|
||||
label: string
|
||||
value: number
|
||||
tone: 'default' | 'danger' | 'warn' | 'info'
|
||||
sub?: string
|
||||
}) {
|
||||
const tones = {
|
||||
default: "border-border",
|
||||
danger: "border-destructive/40 bg-destructive/5",
|
||||
warn: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
|
||||
info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5",
|
||||
};
|
||||
default: 'border-border',
|
||||
danger: 'border-destructive/40 bg-destructive/5',
|
||||
warn: 'border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]',
|
||||
info: 'border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5',
|
||||
}
|
||||
return (
|
||||
<div className={`rounded-md border p-4 ${tones[tone]}`}>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function FileBadge({ present }: { present: boolean }) {
|
||||
@@ -616,49 +639,49 @@ function FileBadge({ present }: { present: boolean }) {
|
||||
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
|
||||
<XCircle className="mr-1 h-3 w-3" /> Missing
|
||||
</Badge>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function StatusCell({ progress }: { progress?: ItemProgress }) {
|
||||
if (!progress || progress.status === "idle")
|
||||
return <span className="text-muted-foreground text-xs">—</span>;
|
||||
if (progress.status === "working") {
|
||||
if (!progress || progress.status === 'idle')
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
if (progress.status === 'working') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{progress.message || "Working…"}
|
||||
{progress.message || 'Working…'}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
if (progress.status === "done") {
|
||||
if (progress.status === 'done') {
|
||||
const parts = [
|
||||
progress.nitroRepaired ? ".nitro ✓" : null,
|
||||
progress.iconRepaired ? "icon ✓" : null,
|
||||
].filter(Boolean);
|
||||
progress.nitroRepaired ? '.nitro ✓' : null,
|
||||
progress.iconRepaired ? 'icon ✓' : null,
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{parts.length > 0 ? parts.join(" · ") : "Repaired"}
|
||||
{parts.length > 0 ? parts.join(' · ') : 'Repaired'}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
if (progress.status === "partial") {
|
||||
if (progress.status === 'partial') {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
|
||||
title={progress.message}
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Partial · {progress.message || "check logs"}
|
||||
Partial · {progress.message || 'check logs'}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}>
|
||||
<XCircle className="h-3 w-3" />
|
||||
{progress.message || "Failed"}
|
||||
{progress.message || 'Failed'}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ItemRow({
|
||||
@@ -669,32 +692,39 @@ function ItemRow({
|
||||
onRepair,
|
||||
disabled,
|
||||
}: {
|
||||
item: MissingItem;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
progress?: ItemProgress;
|
||||
onRepair: () => void;
|
||||
disabled: boolean;
|
||||
item: MissingItem
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
progress?: ItemProgress
|
||||
onRepair: () => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
const isUnrepairable = item.unrepairable || progress?.unrepairable;
|
||||
const isUnrepairable = item.unrepairable || progress?.unrepairable
|
||||
return (
|
||||
<TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
|
||||
<TableRow className={isUnrepairable ? 'bg-destructive/5' : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox checked={selected} onCheckedChange={onToggle} aria-label={`Select ${item.classname}`} />
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`Select ${item.classname}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{item.classname}</span>
|
||||
{isUnrepairable && (
|
||||
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-destructive/40 bg-destructive/10 text-destructive"
|
||||
>
|
||||
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.publicName}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.revision || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{item.revision || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<FileBadge present={item.hasNitro} />
|
||||
</TableCell>
|
||||
@@ -712,10 +742,10 @@ function ItemRow({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRepair}
|
||||
disabled={disabled || progress?.status === "working" || isUnrepairable}
|
||||
title={isUnrepairable ? "habbo.com does not have this asset" : "Retry download + convert"}
|
||||
disabled={disabled || progress?.status === 'working' || isUnrepairable}
|
||||
title={isUnrepairable ? 'habbo.com does not have this asset' : 'Retry download + convert'}
|
||||
>
|
||||
{progress?.status === "working" ? (
|
||||
{progress?.status === 'working' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Wrench className="h-3 w-3" />
|
||||
@@ -723,5 +753,5 @@ function ItemRow({
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -188,9 +188,7 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
|
||||
{initial}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
|
||||
{staff.username}
|
||||
</div>
|
||||
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">{staff.username}</div>
|
||||
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
||||
{t("rankLabel", { rank: staff.rank })}
|
||||
</span>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import { useState } from 'react'
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
username: string;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
diff: string | null;
|
||||
createdAt: string;
|
||||
id: number
|
||||
username: string
|
||||
action: string
|
||||
target: string
|
||||
targetId: number | null
|
||||
diff: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff) return <span className="text-muted-foreground">-</span>;
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!diff) return <span className="text-muted-foreground">-</span>
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>
|
||||
try {
|
||||
parsed = JSON.parse(diff);
|
||||
parsed = JSON.parse(diff)
|
||||
} catch {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-muted-foreground">-</span>
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
const keys = Object.keys(parsed)
|
||||
if (keys.length === 0) return <span className="text-muted-foreground">-</span>
|
||||
|
||||
const summary =
|
||||
keys.length === 1
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||
: `${keys.length} changes`;
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? '∅')} → ${String(parsed[keys[0]].to ?? '∅')}`
|
||||
: `${keys.length} changes`
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -50,53 +50,53 @@ function DiffCell({ diff }: { diff: string | null }) {
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="flex gap-1">
|
||||
<span className="font-medium">{k}:</span>
|
||||
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? "∅")}</span>
|
||||
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? '∅')}</span>
|
||||
<span>→</span>
|
||||
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? "∅")}</span>
|
||||
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? '∅')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "username", label: "User", sortable: true },
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'username', label: 'User', sortable: true },
|
||||
{
|
||||
key: "action",
|
||||
label: "Action",
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||
if (v.includes("create") || v.includes("add")) variant = "default";
|
||||
if (v.includes("delete") || v.includes("remove") || v.includes("ban")) variant = "destructive";
|
||||
return <Badge variant={variant}>{v}</Badge>;
|
||||
const v = String(value)
|
||||
let variant: 'default' | 'secondary' | 'destructive' = 'secondary'
|
||||
if (v.includes('create') || v.includes('add')) variant = 'default'
|
||||
if (v.includes('delete') || v.includes('remove') || v.includes('ban')) variant = 'destructive'
|
||||
return <Badge variant={variant}>{v}</Badge>
|
||||
},
|
||||
},
|
||||
{ key: "target", label: "Target", sortable: true },
|
||||
{ key: 'target', label: 'Target', sortable: true },
|
||||
{
|
||||
key: "targetId",
|
||||
label: "Target ID",
|
||||
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
|
||||
key: 'targetId',
|
||||
label: 'Target ID',
|
||||
render: (value) => <span>{value != null ? String(value) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
key: "diff",
|
||||
label: "Details",
|
||||
key: 'diff',
|
||||
label: 'Details',
|
||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Date",
|
||||
key: 'createdAt',
|
||||
label: 'Date',
|
||||
sortable: true,
|
||||
render: (value) => <span>{String(value) || "N/A"}</span>,
|
||||
render: (value) => <span>{String(value) || 'N/A'}</span>,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
interface AuditTableProps {
|
||||
data: PaginatedResult<AuditRow>;
|
||||
data: PaginatedResult<AuditRow>
|
||||
}
|
||||
|
||||
export function AuditTable({ data }: AuditTableProps) {
|
||||
@@ -107,5 +107,5 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
searchPlaceholder="Search by action or target..."
|
||||
exportUrl="/api/admin/export?type=audit"
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function AuditLogsLoading() {
|
||||
return (
|
||||
@@ -24,5 +24,5 @@ export default function AuditLogsLoading() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { AuditTable } from "./audit-table";
|
||||
import { redirect } from 'next/navigation'
|
||||
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { getAuditLogs } from '@/lib/services/audit'
|
||||
import { AuditTable } from './audit-table'
|
||||
|
||||
export default async function AuditLogsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
await paramsPromise;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
await paramsPromise
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page } = parseListParams(sp);
|
||||
const params = await searchParams
|
||||
const sp = new URLSearchParams(params)
|
||||
const { search, perPage, page } = parseListParams(sp)
|
||||
|
||||
const result = await getAuditLogs({ search, page, perPage });
|
||||
const result = await getAuditLogs({ search, page, perPage })
|
||||
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage)
|
||||
|
||||
const rows = result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -33,7 +33,7 @@ export default async function AuditLogsPage({
|
||||
targetId: r.targetId,
|
||||
diff: r.diff,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -44,5 +44,5 @@ export default async function AuditLogsPage({
|
||||
|
||||
<AuditTable data={{ rows, ...pagination }} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,36 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { formatTimestamp } from '@/lib/admin-helpers'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface ChatLogRow {
|
||||
id: number;
|
||||
username: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
roomId: number;
|
||||
id: number
|
||||
username: string
|
||||
message: string
|
||||
timestamp: number
|
||||
roomId: number
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<ChatLogRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "username", label: "User" },
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'username', label: 'User' },
|
||||
{
|
||||
key: "message",
|
||||
label: "Message",
|
||||
key: 'message',
|
||||
label: 'Message',
|
||||
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
|
||||
},
|
||||
{
|
||||
key: "timestamp",
|
||||
label: "Time",
|
||||
key: 'timestamp',
|
||||
label: 'Time',
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||
},
|
||||
{ key: "roomId", label: "Room ID", sortable: true },
|
||||
];
|
||||
{ key: 'roomId', label: 'Room ID', sortable: true },
|
||||
]
|
||||
|
||||
interface ChatTableProps {
|
||||
data: PaginatedResult<ChatLogRow>;
|
||||
data: PaginatedResult<ChatLogRow>
|
||||
}
|
||||
|
||||
export function ChatTable({ data }: ChatTableProps) {
|
||||
@@ -41,5 +41,5 @@ export function ChatTable({ data }: ChatTableProps) {
|
||||
searchPlaceholder="Search by username or message..."
|
||||
exportUrl="/api/admin/export?type=chatlogs"
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ChatLogsLoading() {
|
||||
return (
|
||||
@@ -24,5 +24,5 @@ export default function ChatLogsLoading() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { ChatTable } from "./chat-table";
|
||||
import { fetchAdminList } from '@/lib/admin-list'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { ChatTable } from './chat-table'
|
||||
|
||||
export default async function ChatLogsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
model: "chatLog",
|
||||
searchFields: ["message", "user.username"],
|
||||
model: 'chatLog',
|
||||
searchFields: ['message', 'user.username'],
|
||||
defaultPerPage: 50,
|
||||
include: { user: { select: { username: true } } },
|
||||
mapRow: (l) => ({
|
||||
@@ -26,7 +26,7 @@ export default async function ChatLogsPage({
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -45,5 +45,5 @@ export default async function ChatLogsPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { formatTimestamp } from '@/lib/admin-helpers'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface CommandLogRow {
|
||||
id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
params: string;
|
||||
timestamp: number;
|
||||
id: number
|
||||
username: string
|
||||
command: string
|
||||
params: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<CommandLogRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "username", label: "User" },
|
||||
{ key: "command", label: "Command", sortable: true },
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'username', label: 'User' },
|
||||
{ key: 'command', label: 'Command', sortable: true },
|
||||
{
|
||||
key: "params",
|
||||
label: "Params",
|
||||
key: 'params',
|
||||
label: 'Params',
|
||||
render: (value) => (
|
||||
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || "-"}</span>
|
||||
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "timestamp",
|
||||
label: "Time",
|
||||
key: 'timestamp',
|
||||
label: 'Time',
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
interface CommandsTableProps {
|
||||
data: PaginatedResult<CommandLogRow>;
|
||||
data: PaginatedResult<CommandLogRow>
|
||||
}
|
||||
|
||||
export function CommandsTable({ data }: CommandsTableProps) {
|
||||
@@ -43,5 +43,5 @@ export function CommandsTable({ data }: CommandsTableProps) {
|
||||
searchPlaceholder="Search by username, command, or params..."
|
||||
exportUrl="/api/admin/export?type=commands"
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CommandLogsLoading() {
|
||||
return (
|
||||
@@ -24,5 +24,5 @@ export default function CommandLogsLoading() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { CommandsTable } from "./commands-table";
|
||||
import { fetchAdminList } from '@/lib/admin-list'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { CommandsTable } from './commands-table'
|
||||
|
||||
export default async function CommandLogsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
model: "commandLog",
|
||||
searchFields: ["command", "params", "user.username"],
|
||||
model: 'commandLog',
|
||||
searchFields: ['command', 'params', 'user.username'],
|
||||
defaultPerPage: 50,
|
||||
include: { user: { select: { username: true } } },
|
||||
mapRow: (l) => ({
|
||||
@@ -26,7 +26,7 @@ export default async function CommandLogsPage({
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -45,5 +45,5 @@ export default async function CommandLogsPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -15,13 +15,7 @@ function fromDate(d: Date | null | undefined): string {
|
||||
export default async function AdminStaffActivities({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
page?: string;
|
||||
staffId?: string;
|
||||
action?: string;
|
||||
authorization?: string;
|
||||
}>;
|
||||
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string; authorization?: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.logs");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function TradeLogsLoading() {
|
||||
return (
|
||||
@@ -24,5 +24,5 @@ export default function TradeLogsLoading() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { TradesTable } from "./trades-table";
|
||||
import { fetchAdminList } from '@/lib/admin-list'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { TradesTable } from './trades-table'
|
||||
|
||||
export default async function TradeLogsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
model: "trade",
|
||||
searchFields: ["userOne.username", "userTwo.username"],
|
||||
model: 'trade',
|
||||
searchFields: ['userOne.username', 'userTwo.username'],
|
||||
defaultPerPage: 50,
|
||||
include: {
|
||||
userOne: { select: { username: true } },
|
||||
@@ -29,7 +29,7 @@ export default async function TradeLogsPage({
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -48,5 +48,5 @@ export default async function TradeLogsPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,44 +1,45 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatTimestamp } from '@/lib/admin-helpers'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface TradeLogRow {
|
||||
id: number;
|
||||
userOneName: string;
|
||||
userTwoName: string;
|
||||
status: string;
|
||||
timestamp: number;
|
||||
id: number
|
||||
userOneName: string
|
||||
userTwoName: string
|
||||
status: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<TradeLogRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "userOneName", label: "User One" },
|
||||
{ key: "userTwoName", label: "User Two" },
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'userOneName', label: 'User One' },
|
||||
{ key: 'userTwoName', label: 'User Two' },
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
const variant = v === "completed" ? "default" : v === "cancelled" ? "destructive" : "secondary";
|
||||
return <Badge variant={variant}>{v || "unknown"}</Badge>;
|
||||
const v = String(value)
|
||||
const variant =
|
||||
v === 'completed' ? 'default' : v === 'cancelled' ? 'destructive' : 'secondary'
|
||||
return <Badge variant={variant}>{v || 'unknown'}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "timestamp",
|
||||
label: "Time",
|
||||
key: 'timestamp',
|
||||
label: 'Time',
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
interface TradesTableProps {
|
||||
data: PaginatedResult<TradeLogRow>;
|
||||
data: PaginatedResult<TradeLogRow>
|
||||
}
|
||||
|
||||
export function TradesTable({ data }: TradesTableProps) {
|
||||
return <DataTable data={data} columns={columns} searchPlaceholder="Search by username..." />;
|
||||
return <DataTable data={data} columns={columns} searchPlaceholder="Search by username..." />
|
||||
}
|
||||
@@ -94,10 +94,7 @@ export default async function AdminMaintenance() {
|
||||
</label>
|
||||
|
||||
<div className="mb-4 max-w-[220px]">
|
||||
<label
|
||||
htmlFor="mt-min-rank"
|
||||
className="text-sm theme-text-muted dark:theme-text-muted block mb-1"
|
||||
>
|
||||
<label htmlFor="mt-min-rank" className="text-sm theme-text-muted dark:theme-text-muted block mb-1">
|
||||
{t("minLoginRankLabel")}
|
||||
</label>
|
||||
<input
|
||||
|
||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user