fix: restore complete admin feature dependencies

This commit is contained in:
Simo committed 2026-07-11 21:15:54 +02:00
1 parent 5b4228261a
commit 0cd753c735
115 files changed
+18565 -4930

No files matched your search

+17
View File
@@ -20,24 +20,40 @@
"jobs:worker": "tsx scripts/jobs-worker.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-virtual": "^3.14.5",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"croner": "^10.0.1",
"hash-wasm": "^4.12.0",
"ioredis": "^5.11.1",
"jpeg-js": "^0.4.4",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"lucide-react": "^1.23.0",
"lzma": "^2.3.2",
"music-metadata": "^11.13.0",
"mysql2": "^3.22.6",
"next": "^16.2.10",
"next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.1",
"nodemailer": "^9.0.3",
"otplib": "^12.0.1",
"radix-ui": "^1.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.81.0",
"resend": "^6.17.1",
"sanitize-html": "^2.17.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -46,6 +62,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20",
"@types/jpeg-js": "^0.3.7",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0",
+4528 -4740
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
-- CMS-owned tables required by the restored administration modules.
-- Idempotent and deliberately free of foreign keys so it can be applied to
-- existing emulator databases whose users table may use different engines.
CREATE TABLE IF NOT EXISTS website_tickets (
id INT NOT NULL AUTO_INCREMENT,
subject VARCHAR(255) NOT NULL,
category VARCHAR(50) NOT NULL DEFAULT 'general',
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
status VARCHAR(20) NOT NULL DEFAULT 'open',
creator_id INT NOT NULL,
assignee_id INT NULL,
closed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY website_tickets_creator_idx (creator_id),
KEY website_tickets_assignee_idx (assignee_id),
KEY website_tickets_status_idx (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS website_ticket_messages (
id INT NOT NULL AUTO_INCREMENT,
ticket_id INT NOT NULL,
user_id INT NOT NULL,
message TEXT NOT NULL,
is_staff INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY website_ticket_messages_ticket_idx (ticket_id),
KEY website_ticket_messages_user_idx (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS website_ticket_templates (
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
category VARCHAR(50) NOT NULL DEFAULT 'general',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_watch (
id INT NOT NULL AUTO_INCREMENT,
staff_id INT NOT NULL,
target_user_id INT NOT NULL,
reason VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY user_watch_unique (staff_id, target_user_id),
KEY user_watch_target_idx (target_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_audit_log (
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
action VARCHAR(191) NOT NULL DEFAULT '',
target VARCHAR(191) NOT NULL DEFAULT '',
target_id INT NULL,
details TEXT NULL,
`before` TEXT NULL,
`after` TEXT NULL,
diff TEXT NULL,
ip_address VARCHAR(45) NULL,
created_at VARCHAR(64) NOT NULL DEFAULT '',
updated_at VARCHAR(64) NULL,
PRIMARY KEY (id),
KEY admin_audit_log_user_idx (user_id),
KEY admin_audit_log_target_idx (target, target_id),
KEY admin_audit_log_action_idx (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS acl_roles (
id INT NOT NULL AUTO_INCREMENT,
slug VARCHAR(191) NOT NULL,
title VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY acl_roles_slug_unique (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS acl_permissions (
id INT NOT NULL AUTO_INCREMENT,
slug VARCHAR(191) NOT NULL,
title VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY acl_permissions_slug_unique (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS acl_model_roles (
id INT NOT NULL AUTO_INCREMENT,
model_type VARCHAR(191) NOT NULL DEFAULT 'User',
model_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (id),
KEY acl_model_roles_role_idx (role_id),
UNIQUE KEY acl_model_roles_unique (model_type, model_id, role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS acl_model_permissions (
id INT NOT NULL AUTO_INCREMENT,
model_type VARCHAR(191) NOT NULL DEFAULT 'Role',
model_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (id),
KEY acl_model_permissions_model_idx (model_id),
KEY acl_model_permissions_permission_idx (permission_id),
UNIQUE KEY acl_model_permissions_unique (model_type, model_id, permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+135
View File
@@ -65,6 +65,11 @@ model User {
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
createdTickets WebsiteTicket[] @relation("ticketCreator")
assignedTickets WebsiteTicket[] @relation("ticketAssignee")
ticketMessages WebsiteTicketMessage[]
auditLogs AdminAuditLog[]
@@map("users")
}
@@ -1933,6 +1938,136 @@ model WebsiteHelpCenterTicketReplies {
@@map("website_help_center_ticket_replies")
}
model WebsiteTicket {
id Int @id @default(autoincrement())
subject String @db.VarChar(255)
category String @default("general") @db.VarChar(50)
priority String @default("normal") @db.VarChar(20)
status String @default("open") @db.VarChar(20)
creatorId Int @map("creator_id")
assigneeId Int? @map("assignee_id")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
creator User @relation("ticketCreator", fields: [creatorId], references: [id])
assignee User? @relation("ticketAssignee", fields: [assigneeId], references: [id])
messages WebsiteTicketMessage[]
@@index([creatorId])
@@index([assigneeId])
@@index([status])
@@map("website_tickets")
}
model WebsiteTicketMessage {
id Int @id @default(autoincrement())
ticketId Int @map("ticket_id")
userId Int @map("user_id")
message String @db.Text
isStaff Int @default(0) @map("is_staff")
createdAt DateTime @default(now()) @map("created_at")
ticket WebsiteTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([ticketId])
@@index([userId])
@@map("website_ticket_messages")
}
model WebsiteTicketTemplate {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
content String @db.Text
category String @default("general") @db.VarChar(50)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
@@map("website_ticket_templates")
}
model UserWatch {
id Int @id @default(autoincrement())
staffId Int @map("staff_id")
targetUserId Int @map("target_user_id")
reason String @default("") @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
@@unique([staffId, targetUserId])
@@index([targetUserId])
@@map("user_watch")
}
model AdminAuditLog {
id Int @id @default(autoincrement())
userId Int @map("user_id")
action String @default("")
target String @default("")
targetId Int? @map("target_id")
details String? @db.Text
before String? @db.Text
after String? @db.Text
diff String? @db.Text
ipAddress String? @map("ip_address") @db.VarChar(45)
createdAt String @default("") @map("created_at")
updatedAt String? @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([target, targetId])
@@index([action])
@@map("admin_audit_log")
}
model AclRole {
id Int @id @default(autoincrement())
slug String @unique
title String @default("")
description String? @db.Text
createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
rolePermissions AclModelPermission[]
modelRoles AclModelRole[]
@@map("acl_roles")
}
model AclPermission {
id Int @id @default(autoincrement())
slug String @unique
title String @default("")
createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
modelPermissions AclModelPermission[]
@@map("acl_permissions")
}
model AclModelRole {
id Int @id @default(autoincrement())
modelType String @default("User") @map("model_type")
modelId Int @map("model_id")
roleId Int @map("role_id")
role AclRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@index([roleId])
@@map("acl_model_roles")
}
model AclModelPermission {
id Int @id @default(autoincrement())
modelType String @default("Role") @map("model_type")
modelId Int @map("model_id")
permissionId Int @map("permission_id")
role AclRole @relation(fields: [modelId], references: [id], onDelete: Cascade)
permission AclPermission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@index([modelId])
@@index([permissionId])
@@map("acl_model_permissions")
}
model AlertLogs {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
type String @db.VarChar(255)
+5 -4
View File
@@ -5,8 +5,9 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
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[] }) {
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({
@@ -18,7 +19,7 @@ export async function bulkUnban({ userIds }: { userIds: number[] }) {
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 }) {
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;
@@ -52,7 +53,7 @@ export async function bulkBan({ userIds, reason, duration }: { userIds: number[]
return { ok: true as const, data: { banned } };
}
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }) {
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 }> = [];
@@ -92,7 +93,7 @@ export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: num
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
}
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }) {
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 }> = [];
+5
View File
@@ -34,6 +34,7 @@ export async function createCatalogItem(data: {
targetId: created.id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } };
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
@@ -47,6 +48,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) {
@@ -57,6 +59,7 @@ export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; t
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
@@ -66,6 +69,7 @@ export async function reorderCatalogItems({ orders }: { orders: Array<{ id: numb
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
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> } }) {
@@ -83,6 +87,7 @@ export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: n
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
+65 -11
View File
@@ -5,8 +5,9 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
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>) {
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();
@@ -18,6 +19,7 @@ export async function updateCatalogPage({ id, ...fields }: { id: number } & Reco
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteCatalogPage({ id }: { id: number }) {
@@ -32,12 +34,13 @@ export async function deleteCatalogPage({ id }: { id: number }) {
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
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 } });
if (!page) return;
if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({
@@ -45,22 +48,23 @@ export async function toggleCatalogPage({ id, action }: { id: number; action: "t
data: { [field]: current === "1" ? "0" : "1" },
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout: string }) {
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: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: 0,
iconImage: 0,
minRank: 1,
orderNum: 0,
visible: "1",
enabled: "1",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
@@ -69,7 +73,7 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
},
});
await logStaffActivity({
staffId: (await requireStaff()).id,
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
@@ -78,3 +82,53 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } };
}
export async function reorderTreePage(input: {
pageId: number;
newParentId?: number;
newOrderNum: number;
}) {
await requireStaff();
await prisma.catalogPages.update({
where: { id: input.pageId },
data: {
orderNum: input.newOrderNum,
...(input.newParentId === undefined ? {} : { parentId: input.newParentId }),
},
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade" }) {
await requireStaff();
if (input.mode === "cascade") {
const children = await prisma.catalogPages.findMany({
where: { parentId: input.pageId },
select: { id: true },
});
const pageIds = [input.pageId, ...children.map((child) => child.id)];
await prisma.$transaction([
prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }),
prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }),
]);
} else {
const page = await prisma.catalogPages.findUnique({
where: { id: input.pageId },
select: { parentId: true },
});
if (!page) return { ok: false as const, error: "Catalog page not found" };
await prisma.$transaction([
prisma.catalogPages.updateMany({
where: { parentId: input.pageId },
data: { parentId: page.parentId },
}),
prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }),
prisma.catalogPages.delete({ where: { id: input.pageId } }),
]);
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
+40
View File
@@ -0,0 +1,40 @@
'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'
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)
for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
})
}
await rcon.updateConfig()
logAudit({
userId: ctx.session.user.id,
action: 'emulator_settings_update',
target: 'EmulatorSettings',
after: ctx.data.settings,
})
return actionOk()
},
)
+8 -3
View File
@@ -2,10 +2,15 @@
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function cleanSwfFiles() {
await requireStaff();
return { ok: true as const, data: { deleted: 0, remaining: 0 } };
export async function cleanSwfFiles(): Promise<ActionResult<{ deleted: number; remaining: number }>> {
try {
await requireStaff();
return { ok: true, data: { deleted: 0, remaining: 0 } };
} catch {
return { ok: false, error: "Failed to clean SWF files" };
}
}
export async function deleteImportedFurni({ classname }: { classname: string }) {
+48
View File
@@ -0,0 +1,48 @@
'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'
const templateSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
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 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 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()
},
)
+230
View File
@@ -0,0 +1,230 @@
'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 {
assignTicketSchema,
createTicketSchema,
replyTicketSchema,
updateTicketPrioritySchema,
updateTicketStatusSchema,
} from '@/lib/validators/ticket'
// ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction({ schema: createTicketSchema }, async (ctx) => {
const ticket = await prisma.websiteTicket.create({
data: {
subject: ctx.data.subject,
category: ctx.data.category,
creatorId: ctx.session.user.id,
},
})
// Create the first message
await prisma.websiteTicketMessage.create({
data: {
ticketId: ticket.id,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
},
})
notify({
action: 'ticket_create',
actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`,
details: `Category: ${ticket.category}`,
})
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')
await prisma.websiteTicketMessage.create({
data: {
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
},
})
// If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === 'waiting') {
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: 'open' },
})
}
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')
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: 'closed', closedAt: new Date() },
})
return actionOk()
},
)
// ── Admin actions ────────────────────────────────────────────────────
export const adminReplyTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
await prisma.websiteTicketMessage.create({
data: {
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
},
})
// Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: 'waiting' }
if (!ticket.assigneeId) {
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',
targetId: ctx.data.ticketId,
})
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')
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
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data,
})
logAudit({
userId: ctx.session.user.id,
action: 'ticket_status_change',
target: 'WebsiteTicket',
targetId: ctx.data.ticketId,
before: { status: ticket.status },
after: { status: ctx.data.status },
})
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')
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: {
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? 'in_progress' : 'open',
},
})
logAudit({
userId: ctx.session.user.id,
action: 'ticket_assign',
target: 'WebsiteTicket',
targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId },
})
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')
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',
targetId: ctx.data.ticketId,
before: { priority: ticket.priority },
after: { priority: ctx.data.priority },
})
return actionOk()
},
)
+77
View File
@@ -0,0 +1,77 @@
'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'
const saveTranslationsSchema = z.object({
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')
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()
})
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')
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')
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)
}
}
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data)
if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys })
}
// 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 })
},
)
+435
View File
@@ -0,0 +1,435 @@
'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'
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
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
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)
try {
const user = await prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
mail,
password: hashedPassword,
rank,
motto: motto || "I'm new here!",
look: DEFAULT_LOOK,
credits: 5000,
pixels: 5000,
accountCreated: now,
ipRegister: '0.0.0.0',
ipCurrent: '0.0.0.0',
},
})
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
})
logAudit({
userId: ctx.session.user.id,
action: 'user_create',
target: 'User',
targetId: user.id,
after: { username, mail, rank },
})
notify({
action: 'user_edit',
actor: ctx.session.user.username,
target: username,
targetId: user.id,
details: 'Account created by admin',
})
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')
}
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 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')
}
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',
targetId: id,
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
after: userData,
})
notify({
action: 'user_edit',
actor: ctx.session.user.username,
target: targetUser.username,
targetId: id,
})
return actionOk()
},
)
const banInput = banUserSchema.extend({})
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 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 rcon.disconnectUser(userId)
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,
})
return actionOk()
},
)
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
const targetUser = await guardRank(userId, ctx.session.user.rank)
await prisma.ban.deleteMany({ where: { userId } })
logAudit({
userId: ctx.session.user.id,
action: 'unban',
target: 'User',
targetId: userId,
})
notify({
action: 'unban',
actor: ctx.session.user.username,
target: targetUser.username,
})
return actionOk()
},
)
export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data
await guardRank(userId, ctx.session.user.rank)
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)
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
await guardRank(userId, ctx.session.user.rank)
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)
return actionOk()
},
)
// ── Rank guard helper ───────────────────────────────────────────────
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.rank >= sessionRank && sessionRank < 7) {
throw new ActionError('Cannot modify user with equal or higher rank')
}
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 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',
targetId: ctx.data.userId,
})
notify({
action: 'user_edit',
actor: ctx.session.user.username,
target: target.username,
details: 'Password reset',
})
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?')
logAudit({
userId: ctx.session.user.id,
action: 'user_disconnect',
target: 'User',
targetId: ctx.data.userId,
})
notify({
action: 'disconnect',
actor: ctx.session.user.username,
target: target.username,
})
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()
},
)
// ── 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?')
logAudit({
userId: ctx.session.user.id,
action: 'user_mute',
target: 'User',
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
})
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?')
logAudit({
userId: ctx.session.user.id,
action: 'user_unmute',
target: 'User',
targetId: ctx.data.userId,
})
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?')
logAudit({
userId: ctx.session.user.id,
action: 'user_send_credits',
target: 'User',
targetId: ctx.data.userId,
after: { amount: ctx.data.amount },
})
return actionOk()
},
)
+44
View File
@@ -0,0 +1,44 @@
'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'
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.
* If a row already exists it's removed; otherwise it's created with the
* provided reason (defaulting to empty). Returns the resulting state so
* the UI can flip the badge without re-fetching.
*/
export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => {
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.create({
data: { staffId, targetUserId, reason: reason ?? '' },
})
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: true })
},
)
@@ -264,7 +264,7 @@ export function CatalogTranslateTab({
<Database className="h-3 w-3" /> items_base (name)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ShoppingCart className="h-3 w-3" /> catalog_name (auto)
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
+2 -2
View File
@@ -22,14 +22,14 @@ export default async function CatalogEditPage({
const pageId = parseInt(id, 10)
if (Number.isNaN(pageId)) notFound()
const catalogPage = await prisma.catalogPage.findUnique({
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
})
if (!catalogPage) notFound()
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPage.findMany({
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: 'asc' },
select: { id: true, caption: true, enabled: true },
@@ -19,40 +19,40 @@ export default async function BcPageDetailPage({
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
const bcPage = await prisma.catalog_pages_bc.findUnique({ where: { id } })
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } })
if (!bcPage) notFound()
const bcItems = await prisma.catalog_items_bc.findMany({
where: { page_id: id },
orderBy: { order_number: 'asc' },
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: 'asc' },
})
return (
<BcPageDetail
page={{
id: bcPage.id,
parentId: bcPage.parent_id,
parentId: bcPage.parentId,
caption: bcPage.caption,
pageLayout: bcPage.page_layout,
iconColor: bcPage.icon_color,
iconImage: bcPage.icon_image,
orderNum: bcPage.order_num,
pageLayout: bcPage.pageLayout,
iconColor: bcPage.iconColor,
iconImage: bcPage.iconImage,
orderNum: bcPage.orderNum,
visible: bcPage.visible,
enabled: bcPage.enabled,
pageHeadline: bcPage.page_headline,
pageTeaser: bcPage.page_teaser,
pageSpecial: bcPage.page_special ?? '',
pageText1: bcPage.page_text1 ?? '',
pageText2: bcPage.page_text2 ?? '',
pageTextDetails: bcPage.page_text_details ?? '',
pageTextTeaser: bcPage.page_text_teaser ?? '',
pageHeadline: bcPage.pageHeadline,
pageTeaser: bcPage.pageTeaser,
pageSpecial: bcPage.pageSpecial ?? '',
pageText1: bcPage.pageText1 ?? '',
pageText2: bcPage.pageText2 ?? '',
pageTextDetails: bcPage.pageTextDetails ?? '',
pageTextTeaser: bcPage.pageTextTeaser ?? '',
}}
items={bcItems.map((i) => ({
id: i.id,
pageId: i.page_id,
itemIds: i.item_ids,
catalogName: i.catalog_name,
orderNumber: i.order_number,
pageId: i.pageId,
itemIds: i.itemIds,
catalogName: i.catalogName,
orderNumber: i.orderNumber,
extradata: i.extradata,
}))}
canEdit={canEdit}
+55 -55
View File
@@ -51,46 +51,46 @@ export default async function CatalogPagesPage({
if (catalogType === 'bc') {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalog_pages_bc.count(),
prisma.catalog_items_bc.count(),
prisma.catalog_pages_bc.count({ where: { enabled: '1' } }),
prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: '1' } }),
])
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = []
if (viewMode === 'tree') {
const bcRootRaw = await prisma.catalog_pages_bc.findMany({
where: { OR: [{ parent_id: -1 }, { parent_id: 0 }] },
orderBy: { order_num: 'asc' },
const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: 'asc' },
})
const bcRootIds = bcRootRaw.map((p) => p.id)
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? prisma.catalog_pages_bc.groupBy({
by: ['parent_id'],
where: { parent_id: { in: bcRootIds } },
? prisma.catalogPagesBc.groupBy({
by: ['parentId'],
where: { parentId: { in: bcRootIds } },
_count: true,
})
: [],
bcRootIds.length > 0
? prisma.catalog_items_bc.groupBy({
by: ['page_id'],
where: { page_id: { in: bcRootIds } },
? prisma.catalogItemsBc.groupBy({
by: ['pageId'],
where: { pageId: { in: bcRootIds } },
_count: true,
})
: [],
])
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parent_id, c._count]))
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.page_id, 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,
parentId: p.parent_id,
parentId: p.parentId,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.icon_image,
iconColor: p.icon_color,
pageLayout: p.page_layout,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: bcChildMap[p.id] ?? 0,
itemCount: bcItemMap[p.id] ?? 0,
}))
@@ -122,68 +122,68 @@ export default async function CatalogPagesPage({
const pattern = { contains: search }
where.OR = [{ caption: pattern }]
}
if (hasParent) where.parent_id = parentId
if (hasParent) where.parentId = parentId
// Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = {
parentId: 'parent_id',
pageLayout: 'page_layout',
orderNum: 'order_num',
parentId: 'parentId',
pageLayout: 'pageLayout',
orderNum: 'orderNum',
}
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'order_num'
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'orderNum'
const orderBy = { [sortKey]: sort ? order : 'asc' }
const [pages, total] = await Promise.all([
prisma.catalog_pages_bc.findMany({
prisma.catalogPagesBc.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalog_pages_bc.count({ where }),
prisma.catalogPagesBc.count({ where }),
])
bcPagination = calcPagination(total, page, perPage)
const pageIds = pages.map((p) => p.id)
const parentIds = [...new Set(pages.map((p) => p.parent_id).filter((id) => id > 0))]
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0
? prisma.catalog_pages_bc.groupBy({
by: ['parent_id'],
where: { parent_id: { in: pageIds } },
? prisma.catalogPagesBc.groupBy({
by: ['parentId'],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalog_items_bc.groupBy({
by: ['page_id'],
where: { page_id: { in: pageIds } },
? prisma.catalogItemsBc.groupBy({
by: ['pageId'],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalog_pages_bc.findMany({
? prisma.catalogPagesBc.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
])
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parent_id, c._count]))
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.page_id, c._count]))
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
bcRows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parent_id,
parentId: p.parentId,
parentCaption:
p.parent_id === -1 ? 'Root' : (parentNameMap[p.parent_id] ?? `#${p.parent_id}`),
pageLayout: String(p.page_layout),
p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: String(p.pageLayout),
enabled: String(p.enabled),
visible: String(p.visible),
minRank: 0, // BC pages have no min rank column
orderNum: p.order_num,
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}))
@@ -220,29 +220,29 @@ export default async function CatalogPagesPage({
// Global stats (lightweight counts)
const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPage.count(),
prisma.catalogItem.count(),
prisma.catalogPage.count({ where: { enabled: '1' } }),
prisma.catalogPages.count(),
prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: '1' } }),
])
// ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = []
if (viewMode === 'tree') {
const rootPagesRaw = await prisma.catalogPage.findMany({
const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 },
orderBy: { orderNum: 'asc' },
})
const rootIds = rootPagesRaw.map((p) => p.id)
const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0
? prisma.catalogPage.groupBy({
? prisma.catalogPages.groupBy({
by: ['parentId'],
where: { parentId: { in: rootIds } },
_count: true,
})
: [],
rootIds.length > 0
? prisma.catalogItem.groupBy({
? prisma.catalogItems.groupBy({
by: ['pageId'],
where: { pageId: { in: rootIds } },
_count: true,
@@ -289,15 +289,15 @@ export default async function CatalogPagesPage({
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
if (search) {
const searchPattern = `%${search}%`
const parentClause = hasParent ? Prisma.sql`AND cp.parent_id = ${parentId}` : Prisma.empty
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
LEFT JOIN catalog_items ci ON ci.page_id = cp.id
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
LEFT JOIN catalog_items ci ON ci.pageId = cp.id
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.itemIds, ';', ','))
WHERE (cp.caption LIKE ${searchPattern}
OR cp.page_layout LIKE ${searchPattern}
OR ci.catalog_name LIKE ${searchPattern}
OR cp.pageLayout LIKE ${searchPattern}
OR ci.catalogName LIKE ${searchPattern}
OR ib.public_name LIKE ${searchPattern}
OR ib.item_name LIKE ${searchPattern})
${parentClause}
@@ -310,13 +310,13 @@ export default async function CatalogPagesPage({
const orderBy = sort ? { [sort]: order } : { orderNum: 'asc' as const }
const [pages, total] = await Promise.all([
prisma.catalogPage.findMany({
prisma.catalogPages.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPage.count({ where }),
prisma.catalogPages.count({ where }),
])
pagination = calcPagination(total, page, perPage)
@@ -326,21 +326,21 @@ export default async function CatalogPagesPage({
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0
? prisma.catalogPage.groupBy({
? prisma.catalogPages.groupBy({
by: ['parentId'],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItem.groupBy({
? prisma.catalogItems.groupBy({
by: ['pageId'],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPage.findMany({
? prisma.catalogPages.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
@@ -379,7 +379,7 @@ function FurniGrid({ source }: FurniGridProps) {
setError(data.error || 'Failed to load')
return
}
setItems(data.items || [])
setItems(data.item || [])
setMeta(data.meta || null)
setStats(data.stats || null)
setSelected(new Set())
@@ -150,7 +150,7 @@ function LibsView() {
setError(data.error || 'Failed to load')
return
}
setItems(data.items || [])
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
} catch {
@@ -498,7 +498,7 @@ function SetsView() {
setError(data.error || 'Failed to load')
return
}
setItems(data.items || [])
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
} catch {
@@ -310,7 +310,7 @@ export function ImportFurniClient() {
setError(data.error || 'Failed to load')
return
}
const loadedItems = data.items || []
const loadedItems = data.item || []
setItems(loadedItems)
setMeta(data.meta || null)
setSelected(new Set())
@@ -167,7 +167,10 @@ export function RankEditClient({
}
function saveRank() {
run(() => saveRankAction({ id: initialRank.id, fields: { ...general, ...perms } }), {
const fields = Object.fromEntries(
Object.entries({ ...general, ...perms }).map(([key, value]) => [key, String(value)]),
)
run(() => saveRankAction({ id: initialRank.id, fields }), {
successMessage: 'Rank saved',
})
}
+5 -5
View File
@@ -17,13 +17,13 @@ export default async function RoomFurniPage({
const room = await loadRoom(id)
// Load items in this room. items.item_id → items_base.id
const items = await prisma.item.findMany({
const items = await prisma.items.findMany({
where: { roomId: room.id },
orderBy: { id: 'desc' },
})
// Resolve item bases
const baseIds = Array.from(new Set(items.map((i) => i.itemId)))
const baseIds = Array.from(new Set(items.map((i) => i.itemId).filter((id): id is number => id !== null)))
const bases = baseIds.length
? await prisma.itemsBase.findMany({
where: { id: { in: baseIds } },
@@ -43,10 +43,10 @@ export default async function RoomFurniPage({
const userMap = new Map(users.map((u) => [u.id, u.username]))
const rows = items.map((i) => {
const base = baseMap.get(i.itemId)
const base = i.itemId === null ? undefined : baseMap.get(i.itemId)
return {
id: i.id,
baseId: i.itemId,
baseId: i.itemId ?? 0,
name: base?.publicName || base?.itemName || `#${i.itemId}`,
itemName: base?.itemName ?? '',
type: base?.type ?? '?',
@@ -74,7 +74,7 @@ export default async function RoomFurniPage({
room={{
id: room.id,
name: room.name,
ownerUsername: room.owner?.username ?? room.owner_name,
ownerUsername: room.owner?.username ?? room.ownerName,
}}
items={rows}
canEdit={session.user.rank >= 4}
+8 -3
View File
@@ -5,11 +5,16 @@ export async function loadRoom(id: string) {
const roomId = parseInt(id, 10)
if (Number.isNaN(roomId)) notFound()
const room = await prisma.room.findUnique({
const room = await prisma.rooms.findUnique({
where: { id: roomId },
include: { owner: { select: { id: true, username: true, look: true } } },
})
if (!room) notFound()
return room
const owner = room.ownerId > 0
? await prisma.user.findUnique({
where: { id: room.ownerId },
select: { id: true, username: true, look: true },
})
: null
return { ...room, owner }
}
+2 -2
View File
@@ -42,7 +42,7 @@ export default async function RoomEditPage({
</Badge>
</h1>
<p className="text-sm text-muted-foreground">
Owner: {room.owner.username} &middot; {room.users}/{room.usersMax} users
Owner: {room.owner?.username ?? room.ownerName} &middot; {room.users}/{room.usersMax} users
</p>
</div>
</div>
@@ -61,7 +61,7 @@ export default async function RoomEditPage({
state: room.state,
usersMax: room.usersMax,
users: room.users,
ownerName: room.owner.username,
ownerName: room.owner?.username ?? room.ownerName,
}}
/>
</div>
+7 -6
View File
@@ -49,12 +49,13 @@ export default async function RoomShowPage({
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{room.users}</span> / {room.usersMax} users
</span>
<Link
href={`/admin/users/show/${room.owner.id}`}
className="text-primary hover:underline"
>
Owner: {room.owner.username}
</Link>
{room.owner ? (
<Link href={`/admin/users/show/${room.owner.id}`} className="text-primary hover:underline">
Owner: {room.owner.username}
</Link>
) : (
<span>Owner: {room.ownerName}</span>
)}
</div>
</div>
<div className="flex gap-2">
+3 -2
View File
@@ -26,7 +26,7 @@ export default async function SoundsPage() {
track: true,
},
}),
prisma.catalogItem.groupBy({
prisma.catalogItems.groupBy({
by: ['songId'],
where: { songId: { gt: 0 } },
_count: { _all: true },
@@ -55,7 +55,8 @@ export default async function SoundsPage() {
})
// Normalize: ensure trailing slash so ${base}${code}.mp3 yields a valid URL.
const baseUrl = baseUrlSetting.endsWith('/') ? baseUrlSetting : `${baseUrlSetting}/`
const configuredBaseUrl = baseUrlSetting ?? '/sounds/'
const baseUrl = configuredBaseUrl.endsWith('/') ? configuredBaseUrl : `${configuredBaseUrl}/`
return (
<div className="space-y-6">
+1 -1
View File
@@ -67,7 +67,7 @@ export default async function AdminTicketDetailPage({
username: ticket.creator.username,
look: ticket.creator.look,
rank: ticket.creator.rank,
mail: ticket.creator.mail,
mail: ticket.creator.mail ?? '',
ipRegister: ticket.creator.ipRegister,
accountCreated: ticket.creator.accountCreated,
},
+8 -8
View File
@@ -1,6 +1,6 @@
import { ExternalLink, Server } from 'lucide-react'
import Link from 'next/link'
import { EmulatorSettingsForm } from '@/app/[locale]/admin/settings/emulator/emulator-settings-form'
import { EmulatorSettingsForm } from '@/components/admin/emulator-settings-form'
import { Button } from '@/components/ui/button'
import type { Prisma } from '@/generated/prisma/client'
import { calcPagination } from '@/lib/admin-helpers'
@@ -31,32 +31,32 @@ export default async function EmulatorTranslationsPage({
const perPage = Math.min(Math.max(parseInt(sp.perPage || '50', 10), 10), 200)
const page = Math.max(parseInt(sp.page || '1', 10), 1)
const patternFilters: Prisma.EmulatorSettingWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
const patternFilters: Prisma.EmulatorSettingsWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
key: { contains: p.replace(/%/g, '') },
}))
const baseFilter: Prisma.EmulatorSettingWhereInput = { OR: patternFilters }
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters }
const conditions: Prisma.EmulatorSettingWhereInput[] = [baseFilter]
const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter]
if (group) conditions.push({ key: { startsWith: `${group}.` } })
if (search) {
conditions.push({
OR: [{ key: { contains: search } }, { value: { contains: search } }],
})
}
const where: Prisma.EmulatorSettingWhereInput = { AND: conditions }
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions }
const [settings, total, allKeys] = await Promise.all([
prisma.emulatorSetting.findMany({
prisma.emulatorSettings.findMany({
where,
orderBy: { key: 'asc' },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorSetting.count({ where }),
prisma.emulatorSettings.count({ where }),
// Group chips reflect the WHOLE translation-pattern subset (so they don't
// disappear when you click one); we ignore the group/search filters here.
prisma.emulatorSetting.findMany({
prisma.emulatorSettings.findMany({
where: baseFilter,
select: { key: true },
}),
+55 -59
View File
@@ -1,73 +1,69 @@
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
export async function loadUserById(id: string) {
const numericId = parseInt(id, 10)
const isNumeric = !Number.isNaN(numericId) && String(numericId) === id
const numericId = Number(id);
const isNumeric = Number.isSafeInteger(numericId) && numericId > 0 && String(numericId) === id;
const user = await prisma.user.findUnique({ where: isNumeric ? { id: numericId } : { username: id } });
if (!user) notFound();
const user = await prisma.user.findUnique({
where: isNumeric ? { id: numericId } : { username: id },
include: {
permission: true,
settings: true,
currencies: true,
badges: true,
bansReceived: {
include: { staff: { select: { username: true } } },
orderBy: { id: 'desc' },
take: 5,
},
},
})
const [currencies, badges, bansReceived, permissionRows, roomCount, tradeCount, userRooms, chatLogs, commandLogs] =
await Promise.all([
prisma.usersCurrency.findMany({ where: { userId: user.id } }),
prisma.usersBadges.findMany({ where: { userId: user.id }, orderBy: { slotId: "asc" } }),
prisma.$queryRaw<Array<{ id: number; banReason: string; type: string; timestamp: number; banExpire: number; staffName: string }>>`
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
COALESCE(s.username, 'Unknown') AS staffName
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`,
prisma.$queryRaw<Array<{ rankName: string }>>`
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`,
prisma.rooms.count({ where: { ownerId: user.id } }),
prisma.roomTradeLog.count({ where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] } }),
prisma.rooms.findMany({
where: { ownerId: user.id },
orderBy: { users: "desc" },
take: 20,
select: { id: true, name: true, state: true, users: true, usersMax: true },
}),
prisma.$queryRaw<Array<{ id: number; roomId: number; timestamp: number; message: string }>>`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`,
prisma.$queryRaw<Array<{ id: number; timestamp: number; command: string; params: string; succes: string }>>`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`,
]);
if (!user) notFound()
const diamonds = user.currencies.find((c) => c.type === 5)?.amount ?? 0
const duckets = user.currencies.find((c) => c.type === 0)?.amount ?? 0
const [roomCount, chatCount, tradeCount, userRooms, chatLogs, commandLogs] = await Promise.all([
prisma.room.count({ where: { ownerId: user.id } }),
prisma.chatLog.count({ where: { userId: user.id } }),
prisma.trade.count({
where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] },
}),
prisma.room.findMany({
where: { ownerId: user.id },
orderBy: { users: 'desc' },
take: 20,
select: { id: true, name: true, state: true, users: true, usersMax: true },
}),
prisma.chatLog.findMany({
where: { userId: user.id },
orderBy: { id: 'desc' },
take: 20,
select: { id: true, roomId: true, timestamp: true, message: true },
}),
prisma.commandLog.findMany({
where: { userId: user.id },
orderBy: { id: 'desc' },
take: 10,
select: { id: true, timestamp: true, command: true, params: true, succes: true },
}),
])
const isOnline = user.online === '1'
const activeBan = user.bansReceived.find((b) => {
const now = Math.floor(Date.now() / 1000)
return b.banExpire === 0 || b.banExpire > now
})
const diamonds = currencies.find((currency) => currency.type === 5)?.amount ?? 0;
const duckets = currencies.find((currency) => currency.type === 0)?.amount ?? 0;
const now = Math.floor(Date.now() / 1000);
const activeBan = bansReceived.find((ban) => ban.banExpire === 0 || ban.banExpire > now);
const decoratedUser = {
...user,
mail: user.mail ?? "",
gender: String(user.gender),
machine_id: user.machineId,
twoFactorConfirmed: user.twoFactorConfirmedAt ? 1 : 0,
permission: { rankName: permissionRows[0]?.rankName ?? `Rank ${user.rank}` },
currencies,
badges,
bansReceived: bansReceived.map((ban) => ({
...ban,
staff: { username: ban.staffName },
})),
};
return {
user,
user: decoratedUser,
diamonds,
duckets,
roomCount,
chatCount,
chatCount: chatLogs.length,
tradeCount,
userRooms,
chatLogs,
commandLogs: commandLogs.map((l) => ({ ...l, succes: String(l.succes) })),
isOnline,
commandLogs,
isOnline: user.online === "1",
activeBan,
}
};
}
+34
View File
@@ -0,0 +1,34 @@
import { ChevronRight, Home } from 'lucide-react'
import Link from 'next/link'
import { Fragment } from 'react'
export type { BreadcrumbItem } from '@/types/common'
import type { BreadcrumbItem } from '@/types/common'
interface AdminBreadcrumbProps {
items: BreadcrumbItem[]
}
export function AdminBreadcrumb({ items }: AdminBreadcrumbProps) {
return (
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-2">
<Link href="/admin" className="hover:text-foreground transition-colors">
<Home className="h-3.5 w-3.5" />
</Link>
{items.map((item) => (
<Fragment key={item.href ?? item.label}>
<ChevronRight className="h-3 w-3 flex-shrink-0" />
{item.href ? (
<Link href={item.href} className="hover:text-foreground transition-colors">
{item.label}
</Link>
) : (
<span className="text-foreground font-medium">{item.label}</span>
)}
</Fragment>
))}
</nav>
)
}
@@ -0,0 +1,61 @@
'use client'
import { ChevronRight, Home } from 'lucide-react'
import { Fragment, useEffect } from 'react'
import { useCatalogManager } from './use-catalog-store'
export function BreadcrumbBar() {
const { state, dispatch } = useCatalogManager()
const { selectedPageId, ancestors } = state
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: 'SET_ANCESTORS', ancestors: [] })
return
}
const ac = new AbortController()
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { signal: ac.signal })
.then((r) => r.json())
.then((d) => {
if (!ac.signal.aborted) {
dispatch({ type: 'SET_ANCESTORS', ancestors: d.ancestors ?? [] })
}
})
.catch(() => {})
return () => ac.abort()
}, [selectedPageId, dispatch])
if (!selectedPageId || ancestors.length === 0) return null
return (
<div className="flex items-center gap-1 px-4 py-1 border-b bg-muted/20 text-xs text-muted-foreground overflow-x-auto shrink-0">
<Home className="h-3 w-3 shrink-0" />
{ancestors.map((a) => (
<Fragment key={a.id}>
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: 'SELECT_PAGE', id: a.id })}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
</button>
</Fragment>
))}
{/* Current page */}
{(() => {
const node = state.nodes.get(selectedPageId)
if (!node) return null
return (
<>
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="shrink-0 text-foreground font-medium truncate max-w-40">
{node.caption}
</span>
</>
)
})()}
</div>
)
}
@@ -0,0 +1,183 @@
'use client'
import { LayoutGrid } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { createCatalogPage } from '@/actions/catalog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { RootTab } from '@/types/catalog'
import { BreadcrumbBar } from './breadcrumb-bar'
import { InlineEditor, type PageUpdateData } from './inline-editor'
import { RootTabs } from './root-tabs'
import { SortableTree } from './sortable-tree'
import { StatsBar } from './stats-bar'
import { CatalogManagerProvider, useCatalogManager } from './use-catalog-store'
export function CatalogManagerDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
</DialogTrigger>
<DialogContent
className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]"
onInteractOutside={(e) => e.preventDefault()}
>
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
</DialogContent>
</Dialog>
)
}
function CatalogManagerInner({
open,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
onOpenChange,
}: {
open: boolean
onOpenChange: (v: boolean) => void
}) {
const { state, dispatch } = useCatalogManager()
// Load root pages when dialog opens
const loadRootPages = useCallback(async () => {
dispatch({ type: 'SET_LOADING_ROOT', loading: true })
try {
const res = await fetch('/api/admin/catalog/tree?parentId=-1')
if (!res.ok) throw new Error()
const data = await res.json()
const pages: RootTab[] = (data.pages || []).map((p: Record<string, unknown>) => ({
id: p.id as number,
caption: p.caption as string,
iconImage: p.iconImage as number,
enabled: String(p.enabled),
orderNum: (p.orderNum as number) || 0,
}))
dispatch({ type: 'SET_ROOT_PAGES', pages })
// Auto-select first tab if none or missing
if (
pages.length > 0 &&
(!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))
) {
dispatch({ type: 'SET_ACTIVE_TAB', id: pages[0].id })
}
} catch {
toast.error('Failed to load catalog root pages')
dispatch({ type: 'SET_LOADING_ROOT', loading: false })
}
}, [dispatch, state.activeTabId])
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (open) loadRootPages()
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
// Handle dialog close: preserve tree state, clear selection
useEffect(() => {
if (!open) dispatch({ type: 'ON_DIALOG_CLOSE' })
}, [open, dispatch])
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm('You have unsaved changes. Discard?')) return
dispatch({ type: 'SET_ACTIVE_TAB', id })
dispatch({ type: 'CLEAR_TREE' })
}
const handleCreateRoot = async () => {
const caption = prompt('Root page name:')
if (!caption?.trim()) return
try {
const result = await createCatalogPage({ parentId: -1, caption: caption.trim() })
if (!result.ok) throw new Error(result.error)
await loadRootPages()
toast.success('Root page created')
} catch {
toast.error('Failed to create root page')
}
}
const handleSelectPage = (id: number) => {
if (state.editorDirty && !confirm('You have unsaved changes. Discard?')) return
dispatch({ type: 'SELECT_PAGE', id: id > 0 ? id : null })
}
const handleEditTab = (id: number) => {
// First ensure this tab is active, then select it in the editor
if (state.activeTabId !== id) {
dispatch({ type: 'SET_ACTIVE_TAB', id })
dispatch({ type: 'CLEAR_TREE' })
}
if (state.editorDirty && !confirm('You have unsaved changes. Discard?')) return
dispatch({ type: 'SELECT_PAGE', id })
}
const handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: 'UPDATE_NODE', update: { ...updated } })
dispatch({ type: 'SET_DIRTY', dirty: false })
// Update root tabs if root page
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id)
if (rootIdx !== -1) {
const pages = [...state.rootPages]
pages[rootIdx] = { ...pages[rootIdx], caption: updated.caption, enabled: updated.enabled }
dispatch({ type: 'SET_ROOT_PAGES', pages })
}
}
return (
<TooltipProvider delayDuration={300}>
{/* Header */}
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
</DialogHeader>
{/* Breadcrumb */}
<BreadcrumbBar />
{/* Root tabs */}
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
{/* Main content: tree + editor */}
<div className="flex flex-1 min-h-0">
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<InlineEditor pageId={state.selectedPageId} onSaved={handleEditorSaved} />
</div>
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
)
}
@@ -0,0 +1,91 @@
'use client'
import { ImageOff, Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
interface ImagePreviewProps {
value: string
basePath?: string
height?: number
className?: string
}
export function ImagePreview({
value,
basePath = '/swf/c_images/catalogue',
height = 60,
className,
}: ImagePreviewProps) {
const [src, setSrc] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
const [fallback, setFallback] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!value.trim()) {
setSrc('')
setStatus('idle')
setFallback(false)
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setFallback(false)
const url = `${basePath}/${value.trim()}.png`
setSrc(url)
setStatus('loading')
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [value, basePath])
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true)
setSrc(`${basePath}/${value.trim()}.gif`)
setStatus('loading')
} else {
setStatus('error')
}
}
if (status === 'idle' || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ''}`}
style={{ height }}
>
<ImageOff className="h-4 w-4" />
</div>
)
}
return (
<div
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ''}`}
style={{ height }}
>
{status === 'loading' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />
)}
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
style={{ imageRendering: 'pixelated' }}
onLoad={() => setStatus('loaded')}
onError={handleError}
/>
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
)
}
@@ -0,0 +1,657 @@
'use client'
import {
AlertCircle,
ChevronDown,
ChevronRight,
ExternalLink,
Languages,
Loader2,
Package,
Save,
Settings,
} from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { updateCatalogPage } from '@/actions/catalog'
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table'
import { CatalogItemsTable } from '@/app/admin/catalog/[id]/catalog-items-table'
import { CatalogTranslateTab } from '@/app/admin/catalog/[id]/catalog-translate-tab'
import { BulkImportItems } from '@/components/admin/catalog/bulk-import-items'
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
import { IconPicker } from '@/components/admin/catalog/icon-picker'
import { ItemsShopPreview } from '@/components/admin/catalog/items-shop-preview'
import { QuickAddFurni } from '@/components/admin/catalog/quick-add-furni'
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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 { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
import { cn } from '@/lib/utils'
import { ImagePreview } from './image-preview'
import { useCatalogManager } from './use-catalog-store'
interface PageDetail {
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
itemCount?: number
}
interface ItemsData {
items: CatalogItemData[]
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[]
catalogNameMap: Record<number, string>
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap: Record<number, { classname: string; revision: number }>
interactionTypes: string[]
allPages: { id: number; caption: string }[]
}
export interface PageUpdateData {
id: number
caption: string
enabled: string
visible: string
pageLayout: string
iconImage: number
}
interface InlineEditorProps {
pageId: number | null
onSaved: (updated: PageUpdateData) => void
}
type FormState = Record<string, string | number | null>
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const { dispatch } = useCatalogManager()
const [page, setPage] = useState<PageDetail | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<FormState>({})
const [original, setOriginal] = useState<FormState>({})
const [showTexts, setShowTexts] = useState(false)
const [activeTab, setActiveTab] = useState('settings')
const [showPreview, setShowPreview] = useState(false)
// Items/translate data (lazy loaded)
const [itemsData, setItemsData] = useState<ItemsData | null>(null)
const [itemsLoading, setItemsLoading] = useState(false)
const [itemsLoaded, setItemsLoaded] = useState(false)
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
return Object.keys(form).some((k) => form[k] !== original[k])
}, [form, original])
useEffect(() => {
dispatch({ type: 'SET_DIRTY', dirty: isDirty })
}, [isDirty, dispatch])
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (isDirty && !saving && page) handleSave()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}) // eslint-disable-line react-hooks/exhaustive-deps
const loadPage = useCallback(async (id: number) => {
setLoading(true)
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
if (!res.ok) throw new Error()
const data = await res.json()
const p = data.page as PageDetail
setPage(p)
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank,
clubOnly: String(p.clubOnly),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || '',
pageTeaser: p.pageTeaser || '',
pageSpecial: p.pageSpecial || '',
pageText1: p.pageText1 || '',
pageText2: p.pageText2 || '',
pageTextDetails: p.pageTextDetails || '',
pageTextTeaser: p.pageTextTeaser || '',
}
setForm(formData)
setOriginal(formData)
} catch {
toast.error('Failed to load page')
setPage(null)
} finally {
setLoading(false)
}
}, [])
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true)
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`)
if (!res.ok) throw new Error()
const data = await res.json()
setItemsData(data as ItemsData)
setItemsLoaded(true)
} catch {
toast.error('Failed to load items')
} finally {
setItemsLoading(false)
}
}, [])
const reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId)
}, [pageId, loadItemsData])
useEffect(() => {
if (pageId && pageId > 0) {
loadPage(pageId)
setItemsData(null)
setItemsLoaded(false)
setActiveTab('settings')
setShowPreview(false)
} else {
setPage(null)
setForm({})
setOriginal({})
setItemsData(null)
setItemsLoaded(false)
}
}, [pageId, loadPage])
// Lazy-load items
useEffect(() => {
if (
(activeTab === 'items' || activeTab === 'translate') &&
!itemsLoaded &&
!itemsLoading &&
pageId &&
pageId > 0
) {
loadItemsData(pageId)
}
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData])
function update(key: string, value: string | number | null) {
setForm((prev) => ({ ...prev, [key]: value }))
}
async function handleSave() {
if (!page) return
setSaving(true)
try {
const result = await updateCatalogPage({ id: page.id, ...form })
if (!result.ok) throw new Error(result.error || 'Failed to save')
toast.success('Page updated & catalog synced')
setOriginal({ ...form })
setPage((prev) =>
prev
? {
...prev,
caption: String(form.caption || prev.caption),
enabled: String(form.enabled ?? prev.enabled),
visible: String(form.visible ?? prev.visible),
pageLayout: String(form.pageLayout || prev.pageLayout),
iconImage: (form.iconImage as number) ?? prev.iconImage,
}
: prev,
)
onSaved({
id: page.id,
caption: String(form.caption || page.caption),
enabled: String(form.enabled ?? page.enabled),
visible: String(form.visible ?? page.visible),
pageLayout: String(form.pageLayout || page.pageLayout),
iconImage: (form.iconImage as number) ?? page.iconImage,
})
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (!pageId || pageId <= 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Select a page from the tree to edit
</div>
)
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (!page) return null
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || 'bg-muted text-muted-foreground'
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
<div className="flex items-center gap-2">
<CatalogIcon iconImage={(form.iconImage as number) ?? page.iconImage} size={24} />
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{(form.caption as string) || page.caption}</h3>
<Badge className={cn('h-4 px-1 text-[8px] font-mono border-0', layoutColor)}>
{form.pageLayout as string}
</Badge>
{isDirty && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-orange-600 border-orange-300 bg-orange-50 dark:bg-orange-950/30 dark:border-orange-700"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
unsaved
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
#{page.id} &middot; Parent: {page.parentId === -1 ? 'Root' : `#${page.parentId}`}
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
</p>
</div>
</div>
<Link href={`/admin/catalog/${page.id}`} target="_blank">
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
<ExternalLink className="h-3 w-3" />
Full editor
</Button>
</Link>
</div>
{/* Dirty banner */}
{isDirty && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-yellow-50 dark:bg-yellow-950/30 border-b border-yellow-200 dark:border-yellow-800 text-xs text-yellow-800 dark:text-yellow-200 shrink-0">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
Unsaved changes — press{' '}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">Ctrl+S</kbd> to
save
</span>
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Save className="mr-1 h-3 w-3" />
)}
Save
</Button>
</div>
)}
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<TabsList className="mx-4 mt-2 shrink-0">
<TabsTrigger value="settings" className="gap-1.5 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
{isDirty && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-orange-500" />}
</TabsTrigger>
<TabsTrigger value="items" className="gap-1.5 text-xs">
<Package className="h-3.5 w-3.5" />
Items
{itemsData && (
<Badge variant="secondary" className="h-4 px-1 text-[9px] ml-0.5">
{itemsData.items.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="translate" className="gap-1.5 text-xs">
<Languages className="h-3.5 w-3.5" />
Translate
{itemsData &&
(() => {
const furniSet = new Set(itemsData.furniDataIdList)
const missing = itemsData.baseItems.filter((b) => !furniSet.has(b.id)).length
return missing > 0 ? (
<span className="ml-0.5 rounded-full bg-orange-500 text-white text-[9px] px-1 py-0.5 leading-none font-medium">
{missing}
</span>
) : null
})()}
</TabsTrigger>
</TabsList>
{/* ── Settings tab ──────────────────────────────── */}
<TabsContent value="settings" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
<div className="space-y-4">
{/* Caption + Layout */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Caption</Label>
<Input
value={String(form.caption || '')}
onChange={(e) => update('caption', e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Layout</Label>
<Select
value={String(form.pageLayout || '')}
onValueChange={(v) => update('pageLayout', v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => {
const color = LAYOUT_COLORS[l] || 'bg-muted text-muted-foreground'
return (
<SelectItem key={l} value={l}>
<span className="flex items-center gap-2">
<span className={cn('inline-block h-2 w-2 rounded-full', color)} />
{l}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? 'Custom layout'}
</p>
</div>
</div>
{/* Order + Rank + Icon */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={form.orderNum as number}
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
value={(form.iconImage as number) ?? 0}
onChange={(v) => update('iconImage', v)}
/>
</div>
</div>
{/* Toggles */}
<div className="rounded-md border p-3 space-y-3">
<ToggleRow
id="ie-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === '1'}
onChange={(v) => update('enabled', v ? '1' : '0')}
/>
<ToggleRow
id="ie-visible"
label="Visible"
description="Shown in navigation"
checked={form.visible === '1'}
onChange={(v) => update('visible', v ? '1' : '0')}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === '1'}
onChange={(v) => update('clubOnly', v ? '1' : '0')}
/>
</div>
{/* Headline image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Headline</Label>
<CatalogImagePicker
value={String(form.pageHeadline || '')}
onChange={(v) => update('pageHeadline', v)}
type="header"
label="Headline image"
/>
<ImagePreview value={String(form.pageHeadline || '')} height={50} />
</div>
{/* Teaser image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Teaser</Label>
<CatalogImagePicker
value={String(form.pageTeaser || '')}
onChange={(v) => update('pageTeaser', v)}
type="teaser"
label="Teaser image"
/>
<ImagePreview value={String(form.pageTeaser || '')} height={50} />
</div>
{/* Collapsible text fields */}
<button
type="button"
onClick={() => setShowTexts(!showTexts)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showTexts ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
has content
</Badge>
)}
</button>
{showTexts && (
<div className="space-y-3 pl-2 border-l-2 border-border/40">
<div className="space-y-1.5">
<Label className="text-xs">Text 1</Label>
<Textarea
rows={2}
value={String(form.pageText1 || '')}
onChange={(e) => update('pageText1', e.target.value || null)}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text 2</Label>
<Textarea
rows={2}
value={String(form.pageText2 || '')}
onChange={(e) => update('pageText2', e.target.value || null)}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text Details</Label>
<Textarea
rows={2}
value={String(form.pageTextDetails || '')}
onChange={(e) => update('pageTextDetails', e.target.value || null)}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Input
value={String(form.pageSpecial || '')}
onChange={(e) => update('pageSpecial', e.target.value || null)}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* Save button */}
<div className="pt-2">
<Button onClick={handleSave} disabled={saving || !isDirty} className="w-full">
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
</div>
</div>
</TabsContent>
{/* ── Items tab ─────────────────────────────────── */}
<TabsContent value="items" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
{itemsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<QuickAddFurni pageId={page.id} furniRevisionMap={itemsData.furniRevisionMap} />
<BulkImportItems pageId={page.id} onImported={reloadItems} />
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="ml-auto"
>
{showPreview ? 'Hide Preview' : 'Shop Preview'}
</Button>
</div>
{/* Shop preview (togglable) */}
{showPreview && (
<ItemsShopPreview
items={itemsData.items}
furniRevisionMap={itemsData.furniRevisionMap}
layout={page.pageLayout}
canEdit={true}
/>
)}
{/* Items table */}
<CatalogItemsTable
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={true}
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
/>
</div>
) : null}
</TabsContent>
{/* ── Translate tab ─────────────────────────────── */}
<TabsContent value="translate" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
{itemsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<CatalogTranslateTab
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
canEdit={true}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
/>
) : null}
</TabsContent>
</Tabs>
</div>
)
}
// ── Toggle row (inline) ─────────────────────────────────────
function ToggleRow({
id,
label,
description,
checked,
onChange,
}: {
id: string
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-xs font-medium cursor-pointer">
{label}
</Label>
<p className="text-[10px] text-muted-foreground">{description}</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
</div>
)
}
@@ -0,0 +1,251 @@
'use client'
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { deleteTreePage, reorderTreePage, toggleCatalogPage } from '@/actions/catalog'
import { CatalogIcon } from '@/components/admin/catalog-tree'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type { RootTab } from '@/types/catalog'
import type { RootTab } from '@/types/catalog'
interface RootTabsProps {
tabs: RootTab[]
activeTabId: number | null
onTabClick: (id: number) => void
onEditTab: (id: number) => void
onCreateRoot: () => void
loading?: boolean
}
export function RootTabs({
tabs,
activeTabId,
onTabClick,
onEditTab,
onCreateRoot,
loading,
}: RootTabsProps) {
const [activeDragId, setActiveDragId] = useState<number | null>(null)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as number)
}, [])
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
setActiveDragId(null)
if (!over || active.id === over.id) return
const oldIndex = tabs.findIndex((t) => t.id === active.id)
const newIndex = tabs.findIndex((t) => t.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove(tabs, oldIndex, newIndex)
// Batch reorder
try {
const results = await Promise.allSettled(
newOrder.map((tab, idx) => reorderTreePage({ pageId: tab.id, newOrderNum: idx + 1 })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed) toast.error(`${failed} reorder(s) failed`)
else toast.success('Tabs reordered')
} catch {
toast.error('Failed to reorder tabs')
}
},
[tabs],
)
const handleToggle = async (tab: RootTab) => {
try {
await toggleCatalogPage({ id: tab.id, action: 'toggleEnabled' })
toast.success(`${tab.caption} ${tab.enabled === '1' ? 'disabled' : 'enabled'}`)
} catch {
toast.error('Failed to toggle')
}
}
const handleDelete = async (tab: RootTab) => {
if (!confirm(`Delete root page "${tab.caption}"? Children will be reparented.`)) return
try {
await deleteTreePage({ pageId: tab.id, mode: 'reparent' })
toast.success(`"${tab.caption}" deleted`)
} catch {
toast.error('Failed to delete')
}
}
const draggedTab = activeDragId ? tabs.find((t) => t.id === activeDragId) : null
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SortableRootTab
key={tab.id}
tab={tab}
isActive={activeTabId === tab.id}
onClick={() => onTabClick(tab.id)}
onEdit={() => onEditTab(tab.id)}
onToggle={() => handleToggle(tab)}
onDelete={() => handleDelete(tab)}
/>
))}
</SortableContext>
<DragOverlay>
{draggedTab && (
<TabButton tab={draggedTab} isActive={activeTabId === draggedTab.id} isOverlay />
)}
</DragOverlay>
</DndContext>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New root page</p>
</TooltipContent>
</Tooltip>
</div>
)
}
// ── Sortable root tab ─────────────────────────────────────────
function SortableRootTab({
tab,
isActive,
onClick,
onEdit,
onToggle,
onDelete,
}: {
tab: RootTab
isActive: boolean
onClick: () => void
onEdit: () => void
onToggle: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
})
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1,
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<TabButton tab={tab} isActive={isActive} onClick={onClick} onDoubleClick={onEdit} />
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit page
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggle}>
{tab.enabled === '1' ? (
<PowerOff className="mr-2 h-3.5 w-3.5" />
) : (
<Power className="mr-2 h-3.5 w-3.5" />
)}
{tab.enabled === '1' ? 'Disable' : 'Enable'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
// ── Tab button render ─────────────────────────────────────────
function TabButton({
tab,
isActive,
isOverlay,
onClick,
onDoubleClick,
}: {
tab: RootTab
isActive: boolean
isOverlay?: boolean
onClick?: () => void
onDoubleClick?: () => void
}) {
return (
<button
type="button"
onClick={onClick}
onDoubleClick={onDoubleClick}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap shrink-0',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'hover:bg-accent text-muted-foreground hover:text-foreground',
tab.enabled !== '1' && 'opacity-50',
isOverlay && 'bg-card shadow-lg border',
)}
>
<CatalogIcon iconImage={tab.iconImage} size={16} />
<span className="max-w-32 truncate">{tab.caption}</span>
</button>
)
}
@@ -0,0 +1,831 @@
'use client'
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
ChevronsDownUp,
ChevronsUpDown,
GripVertical,
Loader2,
MousePointer,
Plus,
Power,
PowerOff,
Search,
ToggleLeft,
ToggleRight,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
createCatalogPage,
deleteTreePage,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
} from '@/actions/catalog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
type FlatTreeNode,
SortableTreeItem,
TreeItemContent,
type TreeItemProps,
} from './tree-item'
import { useCatalogManager } from './use-catalog-store'
/* ─── Types ──────────────────────────────────────────────── */
export type { TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
import type { TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
interface SortableTreeProps {
activeTabId: number | null
selectedPageId: number | null
onSelectPage: (id: number) => void
onTreeMutation: () => void
nodeUpdate?: TreeNodeUpdate | null
}
type FilterMode = 'all' | 'enabled' | 'disabled'
/* ─── Component ──────────────────────────────────────────── */
export function SortableTree({
activeTabId,
selectedPageId,
onSelectPage,
onTreeMutation,
nodeUpdate,
}: SortableTreeProps) {
const { state, dispatch } = useCatalogManager()
const { nodes, childrenMap, expandedIds, loadingIds, multiSelectedIds, focusedId, sortMode } =
state
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(null)
const [searching, setSearching] = useState(false)
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const [activeId, setActiveId] = useState<number | null>(null)
const [treeLoading, setTreeLoading] = useState(false)
const [filter, setFilter] = useState<FilterMode>('all')
const searchInputRef = useRef<HTMLInputElement>(null)
const treeRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
// ── Full tree load (single API call) ────────────────────────
const loadFullTree = useCallback(async () => {
setTreeLoading(true)
try {
const res = await fetch('/api/admin/catalog/tree?mode=full')
if (!res.ok) throw new Error()
const data = await res.json()
const pages: TreeNodeData[] = data.pages || []
// Build nodes map and children map in one pass
const nodesMap = new Map<number, TreeNodeData>()
const childMap = new Map<number, number[]>()
for (const p of pages) {
nodesMap.set(p.id, p)
const siblings = childMap.get(p.parentId) || []
siblings.push(p.id)
childMap.set(p.parentId, siblings)
}
dispatch({ type: 'SET_NODES', nodes: pages })
// Set all children maps at once
for (const [parentId, childIds] of childMap) {
const children = childIds.map((id) => nodesMap.get(id)!).filter(Boolean)
dispatch({ type: 'SET_CHILDREN', parentId, children })
}
} catch {
toast.error('Failed to load catalog tree')
} finally {
setTreeLoading(false)
}
}, [dispatch])
// Load full tree when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (activeTabId === null) return
dispatch({ type: 'CLEAR_TREE' })
setSearchQuery('')
setSearchResults(null)
setFilter('all')
loadFullTree()
}, [activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null)
return
}
if (searchTimeout.current) clearTimeout(searchTimeout.current)
searchTimeout.current = setTimeout(async () => {
setSearching(true)
try {
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`,
)
if (res.ok) {
const data = await res.json()
setSearchResults(data.pages || [])
}
} finally {
setSearching(false)
}
}, 300)
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current)
}
}, [searchQuery])
// Apply external node updates
useEffect(() => {
if (!nodeUpdate) return
dispatch({ type: 'UPDATE_NODE', update: nodeUpdate })
}, [nodeUpdate, dispatch])
// Toggle expand (instant — data already loaded)
const handleToggleExpand = useCallback(
(id: number) => {
if (expandedIds.has(id)) {
dispatch({ type: 'SET_EXPANDED', id, expanded: false })
} else {
dispatch({ type: 'SET_EXPANDED', id, expanded: true })
}
},
[expandedIds, dispatch],
)
// ── Flatten visible tree with filter ────────────────────────
const flatItems = useMemo((): FlatTreeNode[] => {
if (activeTabId === null) return []
if (searchResults) {
return searchResults.map((n) => ({ ...n, depth: 0, isExpanded: false }))
}
const result: FlatTreeNode[] = []
const traverse = (parentId: number, depth: number) => {
const childIds = childrenMap.get(parentId) || []
for (const cid of childIds) {
const node = nodes.get(cid)
if (!node) continue
// Apply filter
if (filter === 'enabled' && node.enabled !== '1') continue
if (filter === 'disabled' && node.enabled === '1') continue
const isExpanded = expandedIds.has(cid)
result.push({ ...node, depth, isExpanded })
if (isExpanded) traverse(cid, depth + 1)
}
}
traverse(activeTabId, 0)
return result
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter])
const flatIds = useMemo(() => flatItems.map((n) => n.id), [flatItems])
const totalNodes = nodes.size
// ── Ancestor highlight ──────────────────────────────────────
const ancestorIds = useMemo(() => {
const set = new Set<number>()
if (!selectedPageId) return set
let current = nodes.get(selectedPageId)
while (current && current.parentId > 0) {
set.add(current.parentId)
current = nodes.get(current.parentId)
}
return set
}, [selectedPageId, nodes])
// ── Virtualizer ─────────────────────────────────────────────
const virtualizer = useVirtualizer({
count: flatItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 30,
overscan: 20,
})
// Auto-scroll to selected page
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!selectedPageId) return
const idx = flatIds.indexOf(selectedPageId)
if (idx >= 0) {
// Delay slightly to let virtualizer settle
requestAnimationFrame(() => virtualizer.scrollToIndex(idx, { align: 'center' }))
}
}, [selectedPageId]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Keyboard navigation ─────────────────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
if (e.key === '/') {
e.preventDefault()
searchInputRef.current?.focus()
}
return
}
if (e.key === '/') {
e.preventDefault()
searchInputRef.current?.focus()
return
}
if (e.key === 'Escape') {
if (searchQuery) {
setSearchQuery('')
setSearchResults(null)
} else dispatch({ type: 'CLEAR_MULTI_SELECT' })
return
}
const currentIdx = focusedId ? flatIds.indexOf(focusedId) : -1
if (e.key === 'ArrowDown') {
e.preventDefault()
const nextIdx = currentIdx < flatIds.length - 1 ? currentIdx + 1 : 0
dispatch({ type: 'SET_FOCUSED', id: flatIds[nextIdx] })
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prevIdx = currentIdx > 0 ? currentIdx - 1 : flatIds.length - 1
dispatch({ type: 'SET_FOCUSED', id: flatIds[prevIdx] })
} else if (e.key === 'ArrowRight' && focusedId) {
e.preventDefault()
const node = flatItems.find((n) => n.id === focusedId)
if (node && node.childCount > 0 && !node.isExpanded) {
handleToggleExpand(focusedId)
} else if (node?.isExpanded) {
const children = childrenMap.get(focusedId)
if (children?.[0]) dispatch({ type: 'SET_FOCUSED', id: children[0] })
}
} else if (e.key === 'ArrowLeft' && focusedId) {
e.preventDefault()
const node = flatItems.find((n) => n.id === focusedId)
if (node?.isExpanded) {
dispatch({ type: 'SET_EXPANDED', id: focusedId, expanded: false })
} else {
const parent = nodes.get(focusedId)
if (parent?.parentId && parent.parentId > 0)
dispatch({ type: 'SET_FOCUSED', id: parent.parentId })
}
} else if (e.key === 'Enter' && focusedId) {
e.preventDefault()
onSelectPage(focusedId)
} else if (e.key === ' ' && focusedId) {
e.preventDefault()
dispatch({ type: 'TOGGLE_MULTI_SELECT', id: focusedId })
} else if (e.key === 'Delete' && focusedId) {
e.preventDefault()
handleDelete(focusedId)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [
focusedId,
flatIds,
flatItems,
searchQuery,
childrenMap,
nodes,
dispatch,
onSelectPage,
handleToggleExpand,
]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Selection with Ctrl/Shift ───────────────────────────────
const handleSelect = useCallback(
(id: number, e?: React.MouseEvent) => {
if (e?.ctrlKey || e?.metaKey) {
dispatch({ type: 'TOGGLE_MULTI_SELECT', id })
} else if (e?.shiftKey && focusedId) {
dispatch({ type: 'RANGE_SELECT', from: focusedId, to: id, flatIds })
} else {
onSelectPage(id)
}
},
[focusedId, flatIds, dispatch, onSelectPage],
)
// ── DnD handlers ────────────────────────────────────────────
const handleDragStart = (event: DragStartEvent) => setActiveId(event.active.id as number)
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id || activeTabId === null) return
const activeNode = nodes.get(active.id as number)
const overNode = nodes.get(over.id as number)
if (!activeNode || !overNode) return
if (activeNode.parentId === overNode.parentId) {
const parentId = activeNode.parentId
const siblings = childrenMap.get(parentId) || []
const oldIndex = siblings.indexOf(active.id as number)
const newIndex = siblings.indexOf(over.id as number)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove(siblings, oldIndex, newIndex)
dispatch({
type: 'SET_CHILDREN',
parentId,
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
})
try {
const results = await Promise.allSettled(
newOrder.map((id, idx) => reorderTreePage({ pageId: id, newOrderNum: idx + 1 })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) toast.error(`${failed} reorder(s) failed`)
else toast.success('Reordered')
onTreeMutation()
} catch {
toast.error('Failed to reorder')
loadFullTree()
}
} else {
const newParentId = overNode.parentId
const siblings = childrenMap.get(newParentId) || []
const overIndex = siblings.indexOf(over.id as number)
try {
const result = await reorderTreePage({
pageId: active.id as number,
newParentId,
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
})
if (!result.ok) throw new Error()
await loadFullTree()
onTreeMutation()
toast.success('Page moved')
} catch {
toast.error('Failed to move page')
}
}
}
// ── Actions ─────────────────────────────────────────────────
const handleAddSubpage = async (parentId: number) => {
const caption = prompt('Subpage name:')
if (!caption?.trim()) return
try {
const result = await createCatalogPage({ parentId, caption: caption.trim() })
if (!result.ok) throw new Error(result.error)
dispatch({ type: 'SET_EXPANDED', id: parentId, expanded: true })
await loadFullTree()
const data = result.data as Record<string, unknown>
onSelectPage(data.id as number)
onTreeMutation()
} catch {
toast.error('Failed to create subpage')
}
}
const handleToggleEnabled = async (id: number, currentEnabled: string) => {
const newEnabled = currentEnabled === '1' ? '0' : '1'
try {
const result = await toggleCatalogPage({ id, action: 'toggleEnabled' })
if (!result.ok) throw new Error()
dispatch({ type: 'UPDATE_NODE', update: { id, enabled: newEnabled } })
onTreeMutation()
} catch {
toast.error('Failed to toggle enabled')
}
}
const handleToggleVisible = async (id: number, currentVisible: string) => {
const newVisible = currentVisible === '1' ? '0' : '1'
try {
const result = await toggleCatalogPage({ id, action: 'toggleVisible' })
if (!result.ok) throw new Error()
dispatch({ type: 'UPDATE_NODE', update: { id, visible: newVisible } })
onTreeMutation()
} catch {
toast.error('Failed to toggle visibility')
}
}
const handleDelete = async (id: number) => {
const node = nodes.get(id)
if (!confirm(`Delete "${node?.caption ?? id}"? Children will be reparented.`)) return
try {
const result = await deleteTreePage({ pageId: id, mode: 'reparent' })
if (!result.ok) throw new Error()
dispatch({ type: 'REMOVE_NODE', id })
if (selectedPageId === id) onSelectPage(-1)
await loadFullTree()
onTreeMutation()
} catch {
toast.error('Failed to delete page')
}
}
const handleDuplicate = async (id: number) => {
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
if (!res.ok) throw new Error()
const { page } = await res.json()
const result = await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? 'default_3x3',
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: '0',
visible: page.visible ?? '1',
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
})
if (!result.ok) throw new Error(result.error)
const newId = (result.data as Record<string, unknown>).id as number
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? '',
pageTeaser: page.pageTeaser ?? '',
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
})
await loadFullTree()
toast.success(`Duplicated "${page.caption}" (items not copied)`)
onSelectPage(newId)
onTreeMutation()
} catch {
toast.error('Failed to duplicate page')
}
}
const handleOpenFull = (id: number) => window.open(`/admin/catalog/${id}`, '_blank')
// ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => {
const allIds = Array.from(nodes.keys())
dispatch({ type: 'EXPAND_PATH', ids: allIds })
}, [nodes, dispatch])
const handleCollapseAll = useCallback(() => {
dispatch({ type: 'COLLAPSE_ALL' })
}, [dispatch])
// ── Bulk actions ────────────────────────────────────────────
const handleBulkEnable = async (enable: boolean) => {
const ids = [...multiSelectedIds]
const results = await Promise.allSettled(
ids.map((id) => {
const node = nodes.get(id)
if (!node || (node.enabled === '1') === enable) return Promise.resolve()
return toggleCatalogPage({ id, action: 'toggleEnabled' })
}),
)
for (const id of ids)
dispatch({ type: 'UPDATE_NODE', update: { id, enabled: enable ? '1' : '0' } })
dispatch({ type: 'CLEAR_MULTI_SELECT' })
const failed = results.filter((r) => r.status === 'rejected').length
if (failed) toast.error(`${failed} toggle(s) failed`)
else toast.success(`${ids.length} page(s) ${enable ? 'enabled' : 'disabled'}`)
onTreeMutation()
}
const handleBulkDelete = async () => {
const ids = [...multiSelectedIds]
if (!confirm(`Delete ${ids.length} page(s)?`)) return
await Promise.allSettled(ids.map((id) => deleteTreePage({ pageId: id, mode: 'reparent' })))
dispatch({ type: 'CLEAR_MULTI_SELECT' })
await loadFullTree()
toast.success(`${ids.length} page(s) deleted`)
onTreeMutation()
}
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null
const noop = () => {}
// Shared props builder for tree items
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
const getItemProps = useCallback(
(node: FlatTreeNode): Omit<TreeItemProps, 'sortMode' | 'isOverlay'> => ({
node,
isSelected: selectedPageId === node.id,
isFocused: focusedId === node.id,
isMultiSelected: multiSelectedIds.has(node.id),
isAncestorOfSelected: ancestorIds.has(node.id),
onToggleExpand: handleToggleExpand,
onSelect: handleSelect,
onAddSubpage: handleAddSubpage,
onToggleEnabled: handleToggleEnabled,
onToggleVisible: handleToggleVisible,
onDuplicate: handleDuplicate,
onDelete: handleDelete,
onOpenFull: handleOpenFull,
isLoading: loadingIds.has(node.id),
}),
[
selectedPageId,
focusedId,
multiSelectedIds,
ancestorIds,
loadingIds,
handleToggleExpand,
handleSelect,
],
) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
ref={treeRef}
role="tree"
tabIndex={0}
className="flex flex-col h-full border-r w-80 shrink-0"
>
{/* Header */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search... /"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 pl-7 pr-7 text-sm"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
setSearchResults(null)
searchInputRef.current?.blur()
}
}}
/>
{searching ? (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : searchQuery ? (
<button
type="button"
onClick={() => {
setSearchQuery('')
setSearchResults(null)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={sortMode ? 'default' : 'outline'}
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => dispatch({ type: 'TOGGLE_SORT_MODE' })}
>
{sortMode ? (
<GripVertical className="h-3.5 w-3.5" />
) : (
<MousePointer className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{sortMode ? 'Sort mode' : 'Edit mode'}</p>
</TooltipContent>
</Tooltip>
</div>
{/* Filters + expand/collapse */}
<div className="flex items-center gap-1.5">
{/* Status filter */}
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
{(['all', 'enabled', 'disabled'] as FilterMode[]).map((f) => (
<button
type="button"
key={f}
onClick={() => setFilter(f)}
className={cn(
'flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors',
filter === f
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{f === 'enabled' && <ToggleRight className="h-2.5 w-2.5" />}
{f === 'disabled' && <ToggleLeft className="h-2.5 w-2.5" />}
{f === 'all' ? 'All' : f === 'enabled' ? 'On' : 'Off'}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleExpandAll}>
<ChevronsUpDown className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Expand all</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCollapseAll}>
<ChevronsDownUp className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Collapse all</p>
</TooltipContent>
</Tooltip>
{activeTabId && !searchResults && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleAddSubpage(activeTabId)}
>
<Plus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New subpage</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Counter + sort badge */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[9px] h-4 px-1">
{flatItems.length}
{totalNodes > 0 && flatItems.length !== totalNodes ? ` / ${totalNodes}` : ''} pages
</Badge>
{sortMode && (
<Badge variant="default" className="text-[9px] h-4 px-1">
Sort mode
</Badge>
)}
</div>
</div>
{/* Tree body (virtualized) */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{treeLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : searchResults !== null ? (
<div className="p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
</p>
{flatItems.map((node) => (
<div key={node.id} className="py-px">
<TreeItemContent {...getItemProps(node)} sortMode={false} />
</div>
))}
</div>
) : sortMode ? (
/* Sort mode: dnd-kit enabled, but only for visible virtual items */
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={flatIds} strategy={verticalListSortingStrategy}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => {
const node = flatItems[vItem.index]
return (
<div
key={node.id}
style={{
position: 'absolute',
top: vItem.start,
width: '100%',
height: vItem.size,
}}
>
<SortableTreeItem {...getItemProps(node)} sortMode={true} />
</div>
)
})}
</div>
</SortableContext>
<DragOverlay>
{activeNode && (
<TreeItemContent
node={{ ...activeNode, depth: 0 }}
sortMode={true}
isSelected={false}
isFocused={false}
isMultiSelected={false}
isOverlay
onToggleExpand={noop}
onSelect={noop}
onAddSubpage={noop}
onToggleEnabled={noop}
onToggleVisible={noop}
onDuplicate={noop}
onDelete={noop}
onOpenFull={noop}
/>
)}
</DragOverlay>
</DndContext>
) : (
/* Normal mode: virtualized, no dnd-kit overhead */
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => {
const node = flatItems[vItem.index]
return (
<div
key={node.id}
style={{
position: 'absolute',
top: vItem.start,
width: '100%',
height: vItem.size,
}}
>
<TreeItemContent {...getItemProps(node)} sortMode={false} />
</div>
)
})}
</div>
)}
{flatItems.length === 0 && !treeLoading && !searchResults && activeTabId && (
<p className="text-sm text-muted-foreground text-center py-8">
No subpages. Click + to create one.
</p>
)}
</div>
{/* Floating multi-select action bar */}
{multiSelectedIds.size > 0 && (
<div className="border-t bg-muted/50 px-3 py-2 flex items-center gap-2 shrink-0 flex-wrap">
<Badge variant="secondary" className="text-[10px]">
{multiSelectedIds.size} selected
</Badge>
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2"
onClick={() => handleBulkEnable(true)}
>
<Power className="mr-1 h-3 w-3" /> Enable
</Button>
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2"
onClick={() => handleBulkEnable(false)}
>
<PowerOff className="mr-1 h-3 w-3" /> Disable
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 text-[11px] px-2"
onClick={handleBulkDelete}
>
<Trash2 className="mr-1 h-3 w-3" /> Delete
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[11px] px-2 ml-auto"
onClick={() => dispatch({ type: 'CLEAR_MULTI_SELECT' })}
>
Clear
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,56 @@
'use client'
import { FolderTree, Keyboard, Package, ToggleRight } from 'lucide-react'
import { useMemo } from 'react'
import { useCatalogManager } from './use-catalog-store'
export function StatsBar() {
const { state } = useCatalogManager()
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values())
const enabled = nodes.filter((n) => n.enabled === '1').length
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0)
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
}
}, [state.nodes, state.rootPages])
return (
<div className="flex items-center justify-between gap-4 px-4 py-1.5 border-t bg-muted/30 text-[11px] text-muted-foreground shrink-0">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<FolderTree className="h-3 w-3" />
{stats.rootTabs} root
</span>
<span>{stats.totalPages} pages</span>
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{stats.enabled} enabled
</span>
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{stats.totalItems} items
</span>
{state.multiSelectedIds.size > 0 && (
<span className="text-primary font-medium">{state.multiSelectedIds.size} selected</span>
)}
</div>
<div className="flex items-center gap-1">
<Keyboard className="h-3 w-3" />
<span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+S</kbd> save
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">/</kbd> search
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+K</kbd> quick
add
</span>
</div>
</div>
)
}
@@ -0,0 +1,304 @@
'use client'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
ChevronRight,
Copy as CopyIcon,
ExternalLink,
Eye,
EyeOff,
EyeOff as EyeOffIcon,
Files,
GripVertical,
Loader2,
Pencil,
Plus,
Power,
PowerOff,
Trash2,
} from 'lucide-react'
import React from 'react'
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
import { Badge } from '@/components/ui/badge'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { translateCaption } from '@/lib/catalog-translations'
import { cn } from '@/lib/utils'
export interface FlatTreeNode {
id: number
caption: string
parentId: number
enabled: string
visible: string
iconImage: number
iconColor: number
pageLayout: string
childCount: number
itemCount: number
depth: number
isExpanded: boolean
}
export interface TreeItemProps {
node: FlatTreeNode
sortMode: boolean
isSelected: boolean
isFocused: boolean
isMultiSelected: boolean
isAncestorOfSelected?: boolean
isOverlay?: boolean
onToggleExpand: (id: number) => void
onSelect: (id: number, e?: React.MouseEvent) => void
onAddSubpage: (parentId: number) => void
onToggleEnabled: (id: number, currentEnabled: string) => void
onToggleVisible: (id: number, currentVisible: string) => void
onDuplicate: (id: number) => void
onDelete: (id: number) => void
onOpenFull: (id: number) => void
isLoading?: boolean
}
export function SortableTreeItem(props: TreeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.node.id,
disabled: !props.sortMode,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div ref={setNodeRef} style={style}>
<TreeItemContent
{...props}
isDragging={isDragging}
dragHandleProps={props.sortMode ? { ...attributes, ...listeners } : undefined}
/>
</div>
)
}
export const TreeItemContent = React.memo(
function TreeItemContent({
node,
sortMode,
isSelected,
isFocused,
isMultiSelected,
isAncestorOfSelected,
isOverlay,
isDragging,
dragHandleProps,
onToggleExpand,
onSelect,
onAddSubpage,
onToggleEnabled,
onToggleVisible,
onDuplicate,
onDelete,
onOpenFull,
isLoading,
}: TreeItemProps & {
isDragging?: boolean
dragHandleProps?: Record<string, unknown>
}) {
const isDisabled = node.enabled !== '1'
const isHidden = node.visible !== '1'
const italianName = translateCaption(node.caption)
const layoutColor = LAYOUT_COLORS[node.pageLayout] || 'bg-muted text-muted-foreground'
const content = (
// biome-ignore lint/a11y/useSemanticElements: wrapped by ContextMenuTrigger asChild (Radix); a real <button> would conflict with sortable handle inside
<div
role="button"
tabIndex={sortMode ? -1 : 0}
className={cn(
'flex items-center gap-1 rounded-md px-1.5 py-1 group transition-colors',
!sortMode && 'cursor-pointer',
isSelected && !isDragging && 'bg-primary/10 ring-1 ring-primary/30',
isMultiSelected && !isSelected && !isDragging && 'bg-primary/5 ring-1 ring-primary/15',
isFocused && !isSelected && !isMultiSelected && 'ring-2 ring-blue-500/40',
!isSelected && !isMultiSelected && !isFocused && !isDragging && 'hover:bg-accent/60',
isAncestorOfSelected &&
!isSelected &&
!isMultiSelected &&
'border-l-2 border-l-primary/30',
isDisabled && 'opacity-40',
isDragging && 'opacity-30',
isOverlay && 'bg-card shadow-lg border opacity-100',
)}
style={{ paddingLeft: isOverlay ? 8 : `${node.depth * 20 + 8}px` }}
onClick={(e) => !sortMode && onSelect(node.id, e)}
onKeyDown={(e) => {
if (!sortMode && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onSelect(node.id, e as unknown as React.MouseEvent)
}
}}
>
{/* Sort mode grip */}
{sortMode && (
<div
className="flex items-center justify-center h-5 w-5 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
{...dragHandleProps}
>
<GripVertical className="h-3.5 w-3.5" />
</div>
)}
{/* Expand/collapse */}
{node.childCount > 0 ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleExpand(node.id)
}}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
node.isExpanded && 'rotate-90',
)}
/>
)}
</button>
) : (
<span className="h-5 w-5 shrink-0" />
)}
{/* Icon */}
<CatalogIcon iconImage={node.iconImage} size={18} />
{/* Caption */}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{italianName ?? node.caption}</span>
{italianName && (
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
{node.caption}
</span>
)}
</div>
{/* Status indicators */}
<div className="flex items-center gap-0.5 shrink-0">
{isHidden && <EyeOff className="h-3 w-3 text-muted-foreground" />}
{isDisabled && (
<Badge variant="destructive" className="h-4 px-1 text-[9px]">
OFF
</Badge>
)}
{node.itemCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[9px] font-mono">
{node.itemCount}
</Badge>
)}
<Badge
className={cn(
'h-4 px-1 text-[8px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0',
layoutColor,
)}
>
{node.pageLayout.replace('default_3x3', '3x3')}
</Badge>
</div>
</div>
)
// In sort mode or overlay, no context menu
if (sortMode || isOverlay) return content
return (
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel className="flex items-center gap-2">
<CatalogIcon iconImage={node.iconImage} size={14} />
{node.caption}
<span className="text-muted-foreground font-normal ml-auto">#{node.id}</span>
</ContextMenuLabel>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSelect(node.id)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => onOpenFull(node.id)}>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Open full editor
</ContextMenuItem>
<ContextMenuItem onClick={() => navigator.clipboard.writeText(String(node.id))}>
<CopyIcon className="mr-2 h-3.5 w-3.5" />
Copy ID
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onAddSubpage(node.id)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add subpage
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicate(node.id)}>
<Files className="mr-2 h-3.5 w-3.5" />
Duplicate page
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onToggleEnabled(node.id, node.enabled)}>
{isDisabled ? (
<Power className="mr-2 h-3.5 w-3.5" />
) : (
<PowerOff className="mr-2 h-3.5 w-3.5" />
)}
{isDisabled ? 'Enable' : 'Disable'}
</ContextMenuItem>
<ContextMenuItem onClick={() => onToggleVisible(node.id, node.visible)}>
{isHidden ? (
<Eye className="mr-2 h-3.5 w-3.5" />
) : (
<EyeOffIcon className="mr-2 h-3.5 w-3.5" />
)}
{isHidden ? 'Show' : 'Hide'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(node.id)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
(prev, next) => {
return (
prev.node.id === next.node.id &&
prev.node.caption === next.node.caption &&
prev.node.enabled === next.node.enabled &&
prev.node.visible === next.node.visible &&
prev.node.itemCount === next.node.itemCount &&
prev.node.isExpanded === next.node.isExpanded &&
prev.node.depth === next.node.depth &&
prev.isSelected === next.isSelected &&
prev.isFocused === next.isFocused &&
prev.isMultiSelected === next.isMultiSelected &&
prev.isAncestorOfSelected === next.isAncestorOfSelected &&
prev.sortMode === next.sortMode &&
prev.isLoading === next.isLoading &&
prev.isDragging === next.isDragging
)
},
)
@@ -0,0 +1,267 @@
'use client'
import { createContext, type Dispatch, type ReactNode, useContext, useReducer } from 'react'
import type { RootTab, TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
// ── State ─────────────────────────────────────────────────────
export interface CatalogManagerState {
// Root tabs
rootPages: RootTab[]
activeTabId: number | null
loadingRoot: boolean
// Tree
nodes: Map<number, TreeNodeData>
childrenMap: Map<number, number[]>
expandedIds: Set<number>
loadingIds: Set<number>
selectedPageId: number | null
multiSelectedIds: Set<number>
focusedId: number | null
sortMode: boolean
searchQuery: string
searchResults: TreeNodeData[] | null
searching: boolean
// Editor
editorDirty: boolean
// Breadcrumb
ancestors: Array<{ id: number; caption: string }>
}
const initialState: CatalogManagerState = {
rootPages: [],
activeTabId: null,
loadingRoot: false,
nodes: new Map(),
childrenMap: new Map(),
expandedIds: new Set(),
loadingIds: new Set(),
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
sortMode: false,
searchQuery: '',
searchResults: null,
searching: false,
editorDirty: false,
ancestors: [],
}
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: 'SET_ROOT_PAGES'; pages: RootTab[] }
| { type: 'SET_LOADING_ROOT'; loading: boolean }
| { type: 'SET_ACTIVE_TAB'; id: number | null }
| { type: 'SELECT_PAGE'; id: number | null }
| { type: 'TOGGLE_EXPAND'; id: number }
| { type: 'SET_EXPANDED'; id: number; expanded: boolean }
| { type: 'EXPAND_PATH'; ids: number[] }
| { type: 'COLLAPSE_ALL' }
| { type: 'SET_NODES'; nodes: TreeNodeData[] }
| { type: 'SET_CHILDREN'; parentId: number; children: TreeNodeData[] }
| { type: 'UPDATE_NODE'; update: TreeNodeUpdate }
| { type: 'REMOVE_NODE'; id: number }
| { type: 'SET_LOADING_IDS'; ids: Set<number> }
| { type: 'SET_SEARCH_QUERY'; query: string }
| { type: 'SET_SEARCH_RESULTS'; results: TreeNodeData[] | null }
| { type: 'SET_SEARCHING'; searching: boolean }
| { type: 'TOGGLE_SORT_MODE' }
| { type: 'TOGGLE_MULTI_SELECT'; id: number }
| { type: 'RANGE_SELECT'; from: number; to: number; flatIds: number[] }
| { type: 'CLEAR_MULTI_SELECT' }
| { type: 'SET_FOCUSED'; id: number | null }
| { type: 'SET_DIRTY'; dirty: boolean }
| { type: 'SET_ANCESTORS'; ancestors: Array<{ id: number; caption: string }> }
| { type: 'ON_DIALOG_CLOSE' }
| { type: 'CLEAR_TREE' }
// ── Reducer ───────────────────────────────────────────────────
function reducer(state: CatalogManagerState, action: CatalogAction): CatalogManagerState {
switch (action.type) {
case 'SET_ROOT_PAGES':
return { ...state, rootPages: action.pages, loadingRoot: false }
case 'SET_LOADING_ROOT':
return { ...state, loadingRoot: action.loading }
case 'SET_ACTIVE_TAB':
return {
...state,
activeTabId: action.id,
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
ancestors: [],
}
case 'SELECT_PAGE':
return {
...state,
selectedPageId: action.id,
multiSelectedIds: new Set(),
focusedId: action.id,
}
case 'TOGGLE_EXPAND': {
const next = new Set(state.expandedIds)
if (next.has(action.id)) next.delete(action.id)
else next.add(action.id)
return { ...state, expandedIds: next }
}
case 'SET_EXPANDED': {
const next = new Set(state.expandedIds)
if (action.expanded) next.add(action.id)
else next.delete(action.id)
return { ...state, expandedIds: next }
}
case 'EXPAND_PATH': {
const next = new Set(state.expandedIds)
for (const id of action.ids) next.add(id)
return { ...state, expandedIds: next }
}
case 'COLLAPSE_ALL':
return { ...state, expandedIds: new Set() }
case 'SET_NODES': {
const next = new Map(state.nodes)
for (const n of action.nodes) next.set(n.id, n)
return { ...state, nodes: next }
}
case 'SET_CHILDREN': {
const nextNodes = new Map(state.nodes)
const nextChildren = new Map(state.childrenMap)
const childIds: number[] = []
for (const child of action.children) {
nextNodes.set(child.id, child)
childIds.push(child.id)
}
nextChildren.set(action.parentId, childIds)
return { ...state, nodes: nextNodes, childrenMap: nextChildren }
}
case 'UPDATE_NODE': {
const existing = state.nodes.get(action.update.id)
if (!existing) return state
const nextNodes = new Map(state.nodes)
nextNodes.set(action.update.id, {
...existing,
...(action.update.caption !== undefined && { caption: action.update.caption }),
...(action.update.enabled !== undefined && { enabled: action.update.enabled }),
...(action.update.visible !== undefined && { visible: action.update.visible }),
...(action.update.pageLayout !== undefined && { pageLayout: action.update.pageLayout }),
...(action.update.iconImage !== undefined && { iconImage: action.update.iconImage }),
})
return { ...state, nodes: nextNodes }
}
case 'REMOVE_NODE': {
const nextNodes = new Map(state.nodes)
nextNodes.delete(action.id)
const nextChildren = new Map(state.childrenMap)
// Remove from parent's children list
for (const [parentId, children] of nextChildren) {
const idx = children.indexOf(action.id)
if (idx !== -1) {
nextChildren.set(
parentId,
children.filter((cid) => cid !== action.id),
)
}
}
// Remove this node's children entry
nextChildren.delete(action.id)
const nextSelected = state.selectedPageId === action.id ? null : state.selectedPageId
return { ...state, nodes: nextNodes, childrenMap: nextChildren, selectedPageId: nextSelected }
}
case 'SET_LOADING_IDS':
return { ...state, loadingIds: action.ids }
case 'SET_SEARCH_QUERY':
return { ...state, searchQuery: action.query }
case 'SET_SEARCH_RESULTS':
return { ...state, searchResults: action.results, searching: false }
case 'SET_SEARCHING':
return { ...state, searching: action.searching }
case 'TOGGLE_SORT_MODE':
return { ...state, sortMode: !state.sortMode, multiSelectedIds: new Set() }
case 'TOGGLE_MULTI_SELECT': {
const next = new Set(state.multiSelectedIds)
if (next.has(action.id)) next.delete(action.id)
else next.add(action.id)
return { ...state, multiSelectedIds: next, focusedId: action.id }
}
case 'RANGE_SELECT': {
const { flatIds, from, to } = action
const fromIdx = flatIds.indexOf(from)
const toIdx = flatIds.indexOf(to)
if (fromIdx === -1 || toIdx === -1) return state
const lo = Math.min(fromIdx, toIdx)
const hi = Math.max(fromIdx, toIdx)
const next = new Set(state.multiSelectedIds)
for (let i = lo; i <= hi; i++) next.add(flatIds[i])
return { ...state, multiSelectedIds: next, focusedId: action.to }
}
case 'CLEAR_MULTI_SELECT':
return { ...state, multiSelectedIds: new Set() }
case 'SET_FOCUSED':
return { ...state, focusedId: action.id }
case 'SET_DIRTY':
return { ...state, editorDirty: action.dirty }
case 'SET_ANCESTORS':
return { ...state, ancestors: action.ancestors }
case 'ON_DIALOG_CLOSE':
return {
...state,
selectedPageId: null,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
}
case 'CLEAR_TREE':
return {
...state,
nodes: new Map(),
childrenMap: new Map(),
expandedIds: new Set(),
loadingIds: new Set(),
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
searchQuery: '',
searchResults: null,
ancestors: [],
}
default:
return state
}
}
// ── Context ───────────────────────────────────────────────────
interface CatalogManagerContextValue {
state: CatalogManagerState
dispatch: Dispatch<CatalogAction>
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(null)
export function CatalogManagerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>
{children}
</CatalogManagerContext.Provider>
)
}
export function useCatalogManager() {
const ctx = useContext(CatalogManagerContext)
if (!ctx) throw new Error('useCatalogManager must be used within CatalogManagerProvider')
return ctx
}
+834
View File
@@ -0,0 +1,834 @@
'use client'
import {
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Copy,
Eye,
EyeOff,
EyeOff as EyeOffIcon,
FolderTree,
Loader2,
Package,
Pencil,
Plus,
Power,
PowerOff,
Search,
ToggleLeft,
ToggleRight,
Trash2,
X,
} from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { translateCaption } from '@/lib/catalog-translations'
import { cn } from '@/lib/utils'
/* ─── Types ──────────────────────────────────────────────── */
interface TreeNode {
id: number
caption: string
parentId: number
enabled: string
visible: string
iconImage: number
iconColor: number
pageLayout: string
childCount: number
itemCount: number
}
interface CatalogTreeProps {
rootPages: TreeNode[]
catalogType?: 'normal' | 'bc'
}
/* ─── Context for expand/collapse all ────────────────────── */
interface TreeContext {
expandSignal: number
collapseSignal: number
filterMode: FilterMode
onToggleEnabled: (node: TreeNode) => void
onToggleVisible: (node: TreeNode) => void
onDeletePage: (node: TreeNode) => void
searchQuery: string
catalogType: 'normal' | 'bc'
}
const TreeCtx = createContext<TreeContext>({
expandSignal: 0,
collapseSignal: 0,
filterMode: 'all',
onToggleEnabled: () => {},
onToggleVisible: () => {},
onDeletePage: () => {},
searchQuery: '',
catalogType: 'normal',
})
/* ─── Catalog Icon ───────────────────────────────────────── */
export function CatalogIcon({ iconImage, size = 20 }: { iconImage: number; size?: number }) {
const [error, setError] = useState(false)
if (error || iconImage <= 0) {
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />
}
return (
<img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: 'pixelated', minWidth: size, minHeight: size }}
onError={() => setError(true)}
/>
)
}
/* ─── Layout label helper ────────────────────────────────── */
export const LAYOUT_COLORS: Record<string, string> = {
default_3x3: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
frontpage: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
spaces_new: 'bg-green-500/15 text-green-600 dark:text-green-400',
pets: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
pets2: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
pets3: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
marketplace: 'bg-purple-500/15 text-purple-600 dark:text-purple-400',
recycler: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
club_buy: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400',
single_bundle: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
room_bundle: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
}
/* ─── Highlight matched text ─────────────────────────────── */
function HighlightText({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return <>{text}</>
return (
<>
{text.slice(0, idx)}
<mark className="bg-yellow-200 dark:bg-yellow-800/60 text-inherit rounded-sm px-0.5">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
)
}
/* ─── Main Tree Component ────────────────────────────────── */
type FilterMode = 'all' | 'enabled' | 'disabled'
export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreeProps) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeNode[] | null>(null)
const [searching, setSearching] = useState(false)
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [expandSignal, setExpandSignal] = useState(0)
const [collapseSignal, setCollapseSignal] = useState(0)
const [filterMode, setFilterMode] = useState<FilterMode>('all')
// "/" 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()
searchInputRef.current?.focus()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
// Debounced search
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null)
return
}
if (searchTimeout.current) clearTimeout(searchTimeout.current)
searchTimeout.current = setTimeout(async () => {
setSearching(true)
try {
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`,
)
if (res.ok) {
const data = await res.json()
setSearchResults(data.pages || [])
}
} catch (error) {
console.error('[CatalogTree] Search failed:', error)
} finally {
setSearching(false)
}
}, 300)
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current)
}
}, [searchQuery])
// Quick actions from context menu
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
const catBody = catalogType === 'bc' ? ', "catalog": "bc"' : ''
const handleToggleEnabled = useCallback(
async (node: TreeNode) => {
try {
const res = await fetch('/api/admin/catalog/tree', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`,
})
if (!res.ok) throw new Error()
toast.success(`${node.caption} ${node.enabled === '1' ? 'disabled' : 'enabled'}`)
router.refresh()
} catch {
toast.error('Failed to toggle. Open the page to edit.')
}
},
[router, catBody],
)
const handleToggleVisible = useCallback(
async (node: TreeNode) => {
try {
const res = await fetch('/api/admin/catalog/tree', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`,
})
if (!res.ok) throw new Error()
toast.success(`${node.caption} ${node.visible === '1' ? 'hidden' : 'visible'}`)
router.refresh()
} catch {
toast.error('Failed to toggle. Open the page to edit.')
}
},
[router, catBody],
)
const handleDeletePage = useCallback(
async (node: TreeNode) => {
const childInfo =
node.childCount > 0 ? ` Its ${node.childCount} child page(s) will be moved to parent.` : ''
const itemInfo = node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : ''
if (!confirm(`Delete "${node.caption}"?${childInfo}${itemInfo}`)) return
try {
const res = await fetch(
`/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`,
{ method: 'DELETE' },
)
if (!res.ok) throw new Error()
toast.success(`"${node.caption}" deleted`)
router.refresh()
} catch {
toast.error('Failed to delete page.')
}
},
[router, catParam],
)
const unfilteredPages = searchResults ?? rootPages
const displayPages =
filterMode === 'all'
? unfilteredPages
: unfilteredPages.filter((p) =>
filterMode === 'enabled' ? p.enabled === '1' : p.enabled !== '1',
)
return (
<TreeCtx.Provider
value={{
expandSignal,
collapseSignal,
filterMode,
searchQuery: searchResults !== null ? searchQuery : '',
onToggleEnabled: handleToggleEnabled,
onToggleVisible: handleToggleVisible,
onDeletePage: handleDeletePage,
catalogType,
}}
>
<TooltipProvider delayDuration={400}>
<div className="rounded-lg border bg-card shadow-sm">
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">Catalog Structure</h3>
<Badge variant="secondary" className="text-[10px] h-5">
{displayPages.length}
{filterMode !== 'all' ? ` / ${unfilteredPages.length}` : ''} pages
</Badge>
</div>
<div className="flex items-center gap-1.5">
{/* Status filter */}
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode('all')}
className={cn(
'px-2 py-1 rounded text-xs font-medium transition-colors',
filterMode === 'all'
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Show all pages</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode('enabled')}
className={cn(
'px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1',
filterMode === 'enabled'
? 'bg-background shadow-sm text-green-600 dark:text-green-400'
: 'text-muted-foreground hover:text-foreground',
)}
>
<ToggleRight className="h-3 w-3" />
On
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Show enabled only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode('disabled')}
className={cn(
'px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1',
filterMode === 'disabled'
? 'bg-background shadow-sm text-red-600 dark:text-red-400'
: 'text-muted-foreground hover:text-foreground',
)}
>
<ToggleLeft className="h-3 w-3" />
Off
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Show disabled only</p>
</TooltipContent>
</Tooltip>
</div>
{/* Expand / Collapse all */}
{searchResults === null && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setExpandSignal((s) => s + 1)}
>
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Expand all</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setCollapseSignal((s) => s + 1)}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Collapse all</p>
</TooltipContent>
</Tooltip>
</>
)}
{/* Search */}
<div className="relative w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search pages... /"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
searchInputRef.current?.blur()
}
}}
/>
{searching ? (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)
)}
</div>
</div>
</div>
{/* Tree body */}
<div className="p-1.5 max-h-[calc(100vh-280px)] overflow-y-auto">
{searchResults !== null && (
<div className="px-2 py-1.5 mb-1 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for &quot;
{searchQuery}&quot;
</p>
</div>
)}
{displayPages.map((page) => (
<TreeItem
key={page.id}
node={page}
level={0}
isSearchResult={searchResults !== null}
defaultExpanded={searchResults === null}
/>
))}
{displayPages.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{searchResults !== null ? 'No pages match your search' : 'No catalog pages found'}
</p>
)}
</div>
</div>
</TooltipProvider>
</TreeCtx.Provider>
)
}
/* ─── Tree Item ──────────────────────────────────────────── */
function TreeItem({
node,
level,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
isSearchResult,
defaultExpanded,
}: {
node: TreeNode
level: number
isSearchResult?: boolean
defaultExpanded?: boolean
}) {
const {
expandSignal,
collapseSignal,
filterMode,
searchQuery,
onToggleEnabled,
onToggleVisible,
onDeletePage,
catalogType,
} = useContext(TreeCtx)
const router = useRouter()
const [expanded, setExpanded] = useState(false)
const [children, setChildren] = useState<TreeNode[]>([])
const [loading, setLoading] = useState(false)
const [loaded, setLoaded] = useState(false)
const hasChildren = node.childCount > 0
// Auto-expand root level on mount
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
loadChildren().then(() => setExpanded(true))
}
}, [])
// Expand all signal
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (expandSignal > 0 && hasChildren) {
if (!loaded) {
loadChildren().then(() => setExpanded(true))
} else {
setExpanded(true)
}
}
}, [expandSignal])
// Collapse all signal
useEffect(() => {
if (collapseSignal > 0) {
setExpanded(false)
}
}, [collapseSignal])
const loadChildren = useCallback(async () => {
if (loaded || !hasChildren) return
setLoading(true)
try {
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
const res = await fetch(`/api/admin/catalog/tree?parentId=${node.id}${catParam}`)
if (res.ok) {
const data = await res.json()
setChildren(data.pages || [])
setLoaded(true)
}
} catch (error) {
console.error('[CatalogTree] Failed to load children:', error)
} finally {
setLoading(false)
}
}, [node.id, loaded, hasChildren, catalogType])
const handleToggle = () => {
if (!expanded && !loaded) {
loadChildren().then(() => setExpanded(true))
return
}
setExpanded(!expanded)
}
const isDisabled = node.enabled !== '1'
const isHidden = node.visible !== '1'
const isEmpty = node.itemCount === 0 && node.childCount === 0
const layoutColor = LAYOUT_COLORS[node.pageLayout] || 'bg-muted text-muted-foreground'
const italianName = translateCaption(node.caption)
const displayName = italianName ?? node.caption
return (
<div>
<ContextMenu>
<ContextMenuTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */}
<div
role="button"
tabIndex={0}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default',
isDisabled && 'opacity-40',
isEmpty && !isDisabled && 'opacity-60',
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onDoubleClick={() =>
router.push(
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
)
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
router.push(
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
)
}
}}
>
{/* Expand/collapse toggle */}
{hasChildren ? (
<button
type="button"
onClick={handleToggle}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
expanded && 'rotate-90',
)}
/>
)}
</button>
) : (
<span className="h-5 w-5 shrink-0" />
)}
{/* Catalog icon */}
<CatalogIcon iconImage={node.iconImage} />
{/* Caption link */}
<Link
href={
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`
}
className={cn(
'flex-1 min-w-0 hover:text-primary transition-colors',
isEmpty && !isDisabled ? 'text-muted-foreground italic' : '',
)}
>
<span className={cn('text-sm truncate block', !isEmpty && 'font-medium')}>
<HighlightText text={displayName} query={searchQuery} />
</span>
{italianName && (
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
<HighlightText text={node.caption} query={searchQuery} />
</span>
)}
</Link>
{/* Quick toggle enabled on hover */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleEnabled(node)
}}
className={cn(
'flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity',
isDisabled
? 'hover:bg-green-500/20 text-muted-foreground hover:text-green-600'
: 'hover:bg-red-500/20 text-muted-foreground hover:text-red-600',
)}
>
{isDisabled ? (
<Power className="h-3 w-3" />
) : (
<PowerOff className="h-3 w-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{isDisabled ? 'Enable' : 'Disable'}</p>
</TooltipContent>
</Tooltip>
{/* Quick add subpage */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
router.push(
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog?newPageParent=${node.id}`,
)
}}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
>
<Plus className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Create subpage</p>
</TooltipContent>
</Tooltip>
{/* Status indicators */}
<div className="flex items-center gap-1 shrink-0">
{isHidden && <EyeOff className="h-3 w-3 text-muted-foreground" />}
{isDisabled && (
<Badge variant="destructive" className="h-4 px-1 text-[9px]">
OFF
</Badge>
)}
{node.itemCount > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-mono">
{node.itemCount}
</Badge>
)}
{/* Layout badge on hover */}
<Badge
className={cn(
'h-5 px-1.5 text-[9px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0',
layoutColor,
)}
>
{node.pageLayout.replace('default_3x3', '3x3')}
</Badge>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-72 p-3">
<p className="font-semibold mb-1.5">
{node.caption} <span className="text-muted-foreground font-normal">#{node.id}</span>
</p>
<table className="w-full">
<tbody>
<tr>
<td className="text-muted-foreground pr-3 py-px">Layout</td>
<td className="py-px">{node.pageLayout}</td>
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Items</td>
<td className="py-px">{node.itemCount}</td>
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Subpages</td>
<td className="py-px">{node.childCount}</td>
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Icon</td>
<td className="py-px">{node.iconImage}</td>
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Visible</td>
<td className="py-px">{node.visible === '1' ? 'Yes' : 'No'}</td>
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Enabled</td>
<td className="py-px">{node.enabled === '1' ? 'Yes' : 'No'}</td>
</tr>
</tbody>
</table>
<p className="text-muted-foreground mt-2 text-[10px]">
Right-click for actions &middot; Double-click to open
</p>
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
{/* Right-click context menu */}
<ContextMenuContent className="w-48">
<ContextMenuLabel>
{node.caption} #{node.id}
</ContextMenuLabel>
<ContextMenuItem
onClick={() =>
router.push(
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
)
}
>
<Pencil className="h-4 w-4" />
Edit Page
</ContextMenuItem>
<ContextMenuItem onClick={() => navigator.clipboard.writeText(String(node.id))}>
<Copy className="h-4 w-4" />
Copy ID
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onToggleEnabled(node)}>
{node.enabled === '1' ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
{node.enabled === '1' ? 'Disable' : 'Enable'}
</ContextMenuItem>
<ContextMenuItem onClick={() => onToggleVisible(node)}>
{node.visible === '1' ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{node.visible === '1' ? 'Hide' : 'Show'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() =>
router.push(
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog?newPageParent=${node.id}`,
)
}
>
<Plus className="h-4 w-4" />
Create Subpage
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={() => onDeletePage(node)}>
<Trash2 className="h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Children */}
{expanded &&
children.length > 0 &&
(() => {
const filteredChildren =
filterMode === 'all'
? children
: children.filter((c) =>
filterMode === 'enabled' ? c.enabled === '1' : c.enabled !== '1',
)
return filteredChildren.length > 0 ? (
<div className="relative">
{/* Tree guide line */}
<div
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 20 + 18}px` }}
/>
{filteredChildren.map((child) => (
<TreeItem key={child.id} node={child} level={level + 1} />
))}
</div>
) : null
})()}
</div>
)
}
@@ -0,0 +1,223 @@
'use client'
import { AlertTriangle, CheckCircle2, Loader2, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { createCatalogItem } from '@/actions/catalog-items'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
interface ParsedRow {
lineNumber: number
raw: string
ok: boolean
error?: string
baseId?: number
credits?: number
points?: number
pointsType?: number
}
interface BulkImportItemsProps {
pageId: number
onImported?: () => void
}
export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
const [open, setOpen] = useState(false)
const [input, setInput] = useState('')
const [importing, setImporting] = useState(false)
const parsed = useMemo<ParsedRow[]>(() => {
const lines = input.split('\n')
const out: ParsedRow[] = []
lines.forEach((raw, idx) => {
const trimmed = raw.trim()
if (!trimmed || trimmed.startsWith('#')) return
const parts = trimmed.split(/[,\s;]+/).filter(Boolean)
const baseId = parseInt(parts[0], 10)
if (Number.isNaN(baseId) || baseId <= 0) {
out.push({ lineNumber: idx + 1, raw: trimmed, ok: false, error: 'Invalid base ID' })
return
}
const credits = parts[1] !== undefined ? parseInt(parts[1], 10) : 0
const points = parts[2] !== undefined ? parseInt(parts[2], 10) : 0
const pointsType = parts[3] !== undefined ? parseInt(parts[3], 10) : 0
if (
(parts[1] !== undefined && Number.isNaN(credits)) ||
(parts[2] !== undefined && Number.isNaN(points))
) {
out.push({ lineNumber: idx + 1, raw: trimmed, ok: false, error: 'Invalid number' })
return
}
out.push({
lineNumber: idx + 1,
raw: trimmed,
ok: true,
baseId,
credits,
points,
pointsType,
})
})
return out
}, [input])
const validCount = parsed.filter((r) => r.ok).length
const invalidCount = parsed.length - validCount
async function handleImport() {
const valid = parsed.filter((r) => r.ok)
if (valid.length === 0) {
toast.error('No valid rows to import')
return
}
setImporting(true)
let success = 0
let failed = 0
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: '',
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
})
if (res.ok) success++
else failed++
} catch {
failed++
}
}
setImporting(false)
if (success > 0) {
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ''}.`)
setInput('')
setOpen(false)
onImported?.()
} else {
toast.error(`All ${failed} imports failed.`)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" />
Bulk Import
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bulk Import Items</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Item list</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={`One per line: base_id[, credits[, points[, points_type]]]\n# Lines starting with # are ignored\n12345, 50\n12346, 0, 10, 5\n12347`}
rows={10}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Format:{' '}
<code className="rounded bg-muted px-1">base_id, credits, points, points_type</code>.
Only <code>base_id</code> is required; other fields default to 0. Separators: comma,
space, or semicolon.
</p>
</div>
{parsed.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3 text-xs">
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
{validCount} valid
</Badge>
{invalidCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{invalidCount} invalid
</Badge>
)}
</div>
<div className="max-h-[200px] overflow-y-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/50">
<tr>
<th className="px-2 py-1 text-left">Line</th>
<th className="px-2 py-1 text-left">Base ID</th>
<th className="px-2 py-1 text-left">Credits</th>
<th className="px-2 py-1 text-left">Points</th>
<th className="px-2 py-1 text-left">Type</th>
<th className="px-2 py-1 text-left">Status</th>
</tr>
</thead>
<tbody>
{parsed.map((row) => (
<tr key={row.lineNumber} className={row.ok ? '' : 'bg-destructive/10'}>
<td className="px-2 py-0.5 font-mono text-muted-foreground">
{row.lineNumber}
</td>
<td className="px-2 py-0.5 font-mono">{row.baseId ?? '—'}</td>
<td className="px-2 py-0.5 font-mono">{row.credits ?? '—'}</td>
<td className="px-2 py-0.5 font-mono">{row.points ?? '—'}</td>
<td className="px-2 py-0.5 font-mono">{row.pointsType ?? '—'}</td>
<td className="px-2 py-0.5">
{row.ok ? (
<span className="text-green-600">OK</span>
) : (
<span className="text-destructive">{row.error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={importing}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || validCount === 0}>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validCount} item{validCount !== 1 ? 's' : ''}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,253 @@
'use client'
import { ImageIcon, Loader2, Search, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface CatalogImagePickerProps {
value: string
onChange: (name: string) => void
/** Filter: 'header' | 'teaser' | 'all' */
type?: 'header' | 'teaser' | 'all'
disabled?: boolean
label?: string
}
const PAGE_SIZE = 80
export function CatalogImagePicker({
value,
onChange,
type = 'all',
disabled,
label,
}: CatalogImagePickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [images, setImages] = useState<string[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const scrollRef = useRef<HTMLDivElement>(null)
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const fetchImages = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
type,
})
if (debounced) params.set('search', debounced)
const res = await fetch(`/api/admin/catalog/images?${params}`)
if (!res.ok) throw new Error()
const data = await res.json()
const fetched: string[] = data.images ?? []
setTotal(data.total ?? 0)
setHasMore(nextOffset + fetched.length < (data.total ?? 0))
setImages((prev) => (replace ? fetched : [...prev, ...fetched]))
setOffset(nextOffset + fetched.length)
} catch {
// ignore
} finally {
setLoading(false)
}
},
[debounced, type],
)
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return
setImages([])
setOffset(0)
setHasMore(true)
fetchImages(0, true)
if (scrollRef.current) scrollRef.current.scrollTop = 0
}, [open, debounced, fetchImages])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el || loading || !hasMore) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
fetchImages(offset, false)
}
}, [loading, hasMore, offset, fetchImages])
const handleSelect = (name: string) => {
onChange(name)
setOpen(false)
}
const handleClear = () => {
onChange('')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto w-full justify-start gap-2 px-3 py-2"
>
{value ? (
<CatalogImageThumb name={value} size={40} />
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-muted">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 text-left">
<p className="text-xs font-medium truncate">
{label ||
(type === 'header'
? 'Headline image'
: type === 'teaser'
? 'Teaser image'
: 'Image')}
</p>
<p className="text-[11px] text-muted-foreground truncate font-mono">
{value || 'None selected'}
</p>
</div>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>
{type === 'header'
? 'Select Headline Image'
: type === 'teaser'
? 'Select Teaser Image'
: 'Select Catalog Image'}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by filename..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} images
</Badge>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
Clear
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[480px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{images.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">No images found</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2">
{images.map((name) => (
<button
key={name}
type="button"
onClick={() => handleSelect(name)}
className={cn(
'group relative flex flex-col overflow-hidden rounded-md border bg-card transition-colors hover:border-primary hover:shadow-sm',
value === name && 'border-primary ring-2 ring-primary/30',
)}
title={name}
>
<div className="flex h-24 items-center justify-center bg-gradient-to-b from-muted/30 to-muted/60 p-1">
<CatalogImageThumb name={name} size={80} />
</div>
<div className="border-t bg-background/95 px-2 py-1">
<p className="truncate font-mono text-[10px] text-muted-foreground group-hover:text-foreground">
{name}
</p>
</div>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
// ── Thumbnail with gif/png fallback ─────────────────────────────
function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number }) {
const [error, setError] = useState(0)
// Reset error state when name changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(0), [name])
if (!name || error >= 2) {
return (
<div
className="flex items-center justify-center rounded bg-muted"
style={{ width: size, height: size * 0.6 }}
>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)
}
// Try .png first, fallback to .gif
const src =
error === 0 ? `/swf/c_images/catalogue/${name}.png` : `/swf/c_images/catalogue/${name}.gif`
return (
<img
src={src}
alt={name}
className="object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size * 0.75 }}
onError={() => setError((e) => e + 1)}
/>
)
}
@@ -0,0 +1,213 @@
'use client'
import { Loader2, Package, Search, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface IconPickerProps {
value: number
onChange: (iconImage: number) => void
disabled?: boolean
/** Optional trigger override. If not provided, a default button is rendered. */
children?: React.ReactNode
}
const PAGE_SIZE = 120
export function IconPicker({ value, onChange, disabled, children }: IconPickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [icons, setIcons] = useState<number[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const scrollRef = useRef<HTMLDivElement>(null)
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const fetchIcons = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
})
if (debounced) params.set('search', debounced)
const res = await fetch(`/api/admin/catalog/icons?${params}`)
if (!res.ok) throw new Error('Failed to load icons')
const data = await res.json()
const fetched: number[] = data.icons ?? []
setTotal(data.total ?? 0)
setHasMore(nextOffset + fetched.length < (data.total ?? 0))
setIcons((prev) => (replace ? fetched : [...prev, ...fetched]))
setOffset(nextOffset + fetched.length)
} catch (err) {
console.error('[IconPicker]', err)
} finally {
setLoading(false)
}
},
[debounced],
)
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return
setIcons([])
setOffset(0)
setHasMore(true)
fetchIcons(0, true)
if (scrollRef.current) scrollRef.current.scrollTop = 0
}, [open, debounced, fetchIcons])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el || loading || !hasMore) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) {
fetchIcons(offset, false)
}
}, [loading, hasMore, offset, fetchIcons])
const handleSelect = (id: number) => {
onChange(id)
setOpen(false)
}
const handleClear = () => {
onChange(0)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children ?? (
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto gap-2 px-3 py-2"
>
<IconPreview iconImage={value} size={24} />
<span className="font-mono text-xs text-muted-foreground">
{value > 0 ? `#${value}` : 'None'}
</span>
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Select Catalog Icon</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by icon ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} icons
</Badge>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
<Package className="mr-1.5 h-3.5 w-3.5" />
None
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[420px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{icons.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">No icons found</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(56px,1fr))] gap-1.5">
{icons.map((id) => (
<button
key={id}
type="button"
onClick={() => handleSelect(id)}
className={cn(
'group relative flex aspect-square items-center justify-center rounded border bg-card p-1 transition-colors hover:border-primary hover:bg-accent',
value === id && 'border-primary ring-2 ring-primary/30',
)}
title={`Icon #${id}`}
>
<IconPreview iconImage={id} size={36} />
<span className="absolute bottom-0 left-0 right-0 truncate bg-background/80 px-0.5 text-center font-mono text-[9px] leading-tight text-muted-foreground opacity-0 group-hover:opacity-100">
{id}
</span>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
// ── Small preview component (inlined to avoid circular imports) ───────
function IconPreview({ iconImage, size = 24 }: { iconImage: number; size?: number }) {
const [error, setError] = useState(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(false), [iconImage])
if (error || iconImage <= 0) {
return (
<Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />
)
}
return (
<img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
onError={() => setError(true)}
/>
)
}
@@ -0,0 +1,218 @@
'use client'
import { Crown, Image as ImageIcon, Package, Sparkles } from 'lucide-react'
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table/types'
import { getPointsLabel } from '@/app/admin/catalog/[id]/catalog-items-table/types'
import { Badge } from '@/components/ui/badge'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
import { cn } from '@/lib/utils'
interface ItemsShopPreviewProps {
items: CatalogItemData[]
furniRevisionMap: Record<number, { classname: string; revision: number }>
layout: string
canEdit: boolean
onItemClick?: (item: CatalogItemData) => void
}
export function ItemsShopPreview({
items,
furniRevisionMap,
layout,
canEdit,
onItemClick,
}: ItemsShopPreviewProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Package className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No items in this page yet</p>
</div>
)
}
const gridClass = getGridClass(layout)
const tileSize = getTileSize(layout)
return (
<div className="rounded-lg border bg-gradient-to-b from-sky-50 to-sky-100 dark:from-slate-900 dark:to-slate-950 p-4">
<div className="mb-3 flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium">Shop preview · {layout}</span>
<span>{items.length} items</span>
</div>
<div className={cn('grid gap-2', gridClass)}>
{items.map((item) => {
const firstId = parseFirstItemId(item.itemIds)
const furni = firstId != null ? furniRevisionMap[firstId] : undefined
const points = getPointsLabel(item.pointsType)
const isClub = item.clubOnly === '1'
const isOffer = item.haveOffer === '1' && item.offerId > 0
return (
<button
key={item.id}
type="button"
onClick={() => onItemClick?.(item)}
disabled={!canEdit && !onItemClick}
className={cn(
'group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all',
canEdit && 'hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer',
!canEdit && 'cursor-default',
)}
title={item.catalogName || item.baseName}
>
{/* Tile icon */}
<div
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ''}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
{/* Badges overlay */}
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
{isClub && (
<Badge className="h-4 gap-0.5 border-0 bg-yellow-500/90 px-1 text-[8px] text-white">
<Crown className="h-2.5 w-2.5" />
HC
</Badge>
)}
{isOffer && (
<Badge className="h-4 gap-0.5 border-0 bg-rose-500/90 px-1 text-[8px] text-white">
<Sparkles className="h-2.5 w-2.5" />
OFFER
</Badge>
)}
</div>
{/* Amount badge */}
{item.amount > 1 && (
<Badge className="absolute left-1 top-1 h-4 border-0 bg-black/70 px-1 text-[9px] text-white">
x{item.amount}
</Badge>
)}
{/* Limited */}
{item.limitedStack > 0 && (
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-purple-600/90 px-1 text-[8px] text-white">
LTD {item.limitedSells}/{item.limitedStack}
</Badge>
)}
</div>
{/* Name + price */}
<div className="border-t bg-background/95 px-2 py-1.5">
<p className="truncate text-xs font-medium leading-tight">
{item.catalogName || item.baseName || `#${item.id}`}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
{item.costCredits > 0 && (
<span className="text-yellow-600 dark:text-yellow-500">
{item.costCredits}c
</span>
)}
{item.costPoints > 0 && (
<span className={points.color}>
{item.costPoints} {points.label.slice(0, 3)}
</span>
)}
{item.costCredits === 0 && item.costPoints === 0 && (
<span className="text-muted-foreground">Free</span>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
// ── Helpers ─────────────────────────────────────────────────────
function parseFirstItemId(itemIds: string): number | null {
if (!itemIds) return null
const first = itemIds.split(';')[0]?.trim()
const n = parseInt(first, 10)
return Number.isNaN(n) ? null : n
}
function getGridClass(layout: string): string {
switch (layout) {
case 'spaces_new':
return 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
case 'frontpage':
return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3'
case 'single_bundle':
case 'room_bundle':
return 'grid-cols-1 sm:grid-cols-2'
case 'pets':
case 'pets2':
case 'pets3':
return 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
default:
return 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5'
}
}
function getTileSize(layout: string): number {
switch (layout) {
case 'frontpage':
case 'single_bundle':
case 'room_bundle':
return 140
case 'spaces_new':
return 110
default:
return 96
}
}
// ── Shop tile furni icon (resizable) ────────────────────────────
function ShopFurniIcon({
classname,
revision,
size,
}: {
classname: string
revision: number
size: number
}) {
if (!classname) {
return (
<ImageIcon
className="text-muted-foreground/40"
style={{ width: size * 0.5, height: size * 0.5 }}
/>
)
}
const primary = getLocalIconUrl(classname)
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
} else {
img.style.opacity = '0'
}
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
)
}
@@ -0,0 +1,228 @@
'use client'
import { Loader2, Package, Search, Zap } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { createCatalogItem } from '@/actions/catalog-items'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useServerAction } from '@/hooks/use-server-action'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
interface FurniResult {
id: number
itemName: string
publicName: string
type: string
spriteId: number
}
interface QuickAddFurniProps {
pageId: number
furniRevisionMap: Record<number, { classname: string; revision: number }>
}
export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
const [results, setResults] = useState<FurniResult[]>([])
const [loading, setLoading] = useState(false)
const { run, isPending } = useServerAction()
const inputRef = useRef<HTMLInputElement>(null)
// Keyboard shortcut: Ctrl+K / Cmd+K
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
e.preventDefault()
setOpen(true)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300)
return () => clearTimeout(t)
}, [query])
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
// Fetch results
useEffect(() => {
if (!open || debounced.length < 2) {
setResults([])
return
}
setLoading(true)
const ac = new AbortController()
fetch(`/api/admin/furni/search?q=${encodeURIComponent(debounced)}&limit=30`, {
signal: ac.signal,
})
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch(() => {})
.finally(() => setLoading(false))
return () => ac.abort()
}, [debounced, open])
const handleAdd = useCallback(
(base: FurniResult) => {
run(
() =>
createCatalogItem({
pageId,
itemIds: String(base.id),
catalogName: base.publicName || base.itemName,
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
}),
{
successMessage: `Added "${base.publicName || base.itemName}" to page.`,
errorMessage: 'Failed to add item.',
onSuccess: () => {
toast.success(`Added #${base.id}`)
},
},
)
},
[pageId, run],
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Zap className="mr-2 h-4 w-4" />
Quick Add
<kbd className="ml-2 hidden rounded border bg-muted px-1 text-[10px] sm:inline">
Ctrl+K
</kbd>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Quick Add Furni</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search by item name or public name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
{loading && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="max-h-[420px] overflow-y-auto rounded-md border">
{debounced.length < 2 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</p>
) : results.length === 0 && !loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No furni found for &quot;{debounced}&quot;
</p>
) : (
<div className="divide-y">
{results.map((r) => {
const furni = furniRevisionMap[r.id]
return (
<button
key={r.id}
type="button"
onClick={() => handleAdd(r)}
disabled={isPending}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-accent disabled:opacity-50"
>
<FurniThumb
classname={furni?.classname ?? r.itemName}
revision={furni?.revision ?? 0}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{r.publicName || (
<span className="italic text-muted-foreground">{r.itemName}</span>
)}
</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
#{r.id} · {r.itemName} · {r.type}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">Add →</span>
</button>
)
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
function FurniThumb({ classname, revision }: { classname: string; revision: number }) {
if (!classname) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
<Package className="h-4 w-4 text-muted-foreground" />
</div>
)
}
const primary = getLocalIconUrl(classname)
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
} else {
img.style.opacity = '0'
}
}
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: 'pixelated' }}
onError={handleError}
loading="lazy"
/>
</div>
)
}
+517
View File
@@ -0,0 +1,517 @@
'use client'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Download,
Filter,
Search,
SlidersHorizontal,
X,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useDebounce } from '@/hooks/use-debounce'
import { PER_PAGE_OPTIONS } from '@/lib/admin-helpers'
import type { DataTableColumn, PaginatedResult } from '@/types'
/**
* One-click filter preset. The chip applies/clears the listed query-string
* keys at once; the preset is "active" when every key matches the URL state.
*/
export interface DataTablePreset {
label: string
params: Record<string, string>
}
interface DataTableProps<T extends { id: number | string }> {
data: PaginatedResult<T>
columns: DataTableColumn<T>[]
searchPlaceholder?: string
bulkActions?: { label: string; value: string; variant?: 'destructive' | 'default' }[]
onBulkAction?: (action: string, ids: (number | string)[]) => Promise<void>
exportUrl?: string
presets?: DataTablePreset[]
}
export function DataTable<T extends { id: number | string }>({
data,
columns,
searchPlaceholder = 'Search...',
bulkActions,
onBulkAction,
exportUrl,
presets,
}: DataTableProps<T>) {
const router = useRouter()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set())
const [searchValue, setSearchValue] = useState(searchParams.get('search') || '')
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set())
const currentSort = searchParams.get('sort') || ''
const currentOrder = searchParams.get('order') || 'desc'
const debouncedSearch = useDebounce(searchValue, 300)
const isFirstRender = useRef(true)
// Collect active filters from URL
const filterColumns = columns.filter((col) => col.filterOptions && col.filterOptions.length > 0)
const activeFilters: Record<string, string> = {}
for (const col of filterColumns) {
const key = col.filterKey || `filter_${col.key}`
const val = searchParams.get(key)
if (val) activeFilters[key] = val
}
const activeFilterCount = Object.keys(activeFilters).length
function updateParams(updates: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString())
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
updateParams({ search: debouncedSearch, page: '1' })
}, [debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(value: string) {
setSearchValue(value)
}
function handleSort(key: string) {
const newOrder = currentSort === key && currentOrder === 'asc' ? 'desc' : 'asc'
updateParams({ sort: key, order: newOrder })
}
function handlePageChange(page: number) {
updateParams({ page: String(page) })
}
function handlePerPageChange(perPage: string) {
updateParams({ perPage, page: '1' })
}
function handleFilterChange(col: DataTableColumn<T>, value: string) {
const key = col.filterKey || `filter_${col.key}`
updateParams({ [key]: value === '__all__' ? '' : value, page: '1' })
}
function clearAllFilters() {
const updates: Record<string, string> = { page: '1' }
for (const col of filterColumns) {
const key = col.filterKey || `filter_${col.key}`
updates[key] = ''
}
updateParams(updates)
}
function toggleSelectAll() {
if (selectedIds.size === data.rows.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(data.rows.map((r) => r.id)))
}
}
function toggleSelect(id: number | string) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleBulkAction(action: string) {
if (selectedIds.size === 0 || !onBulkAction) return
await onBulkAction(action, Array.from(selectedIds))
setSelectedIds(new Set())
router.refresh()
}
function getSortIcon(key: string) {
if (currentSort !== key) return <ArrowUpDown className="h-3 w-3" />
return currentOrder === 'asc' ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
}
function isPresetActive(preset: DataTablePreset): boolean {
return Object.entries(preset.params).every(([k, v]) => (searchParams.get(k) ?? '') === v)
}
function applyPreset(preset: DataTablePreset) {
// Apply (or toggle off) the preset by overwriting only its keys + reset page.
const updates: Record<string, string> = { page: '1' }
if (isPresetActive(preset)) {
for (const k of Object.keys(preset.params)) updates[k] = ''
} else {
Object.assign(updates, preset.params)
}
updateParams(updates)
}
return (
<div className="space-y-4">
{/* Preset filter chips */}
{presets && presets.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{presets.map((preset) => {
const active = isPresetActive(preset)
return (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset)}
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors ${
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-accent'
}`}
>
{preset.label}
{active && <X className="h-3 w-3" />}
</button>
)
})}
</div>
)}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Column Filters */}
{filterColumns.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center"
>
{activeFilterCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Filters</h4>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearAllFilters}
>
Clear all
</Button>
)}
</div>
{filterColumns.map((col) => {
const key = col.filterKey || `filter_${col.key}`
const currentValue = searchParams.get(key) || '__all__'
return (
<div key={col.key} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{col.label}</span>
<Select
value={currentValue}
onValueChange={(v) => handleFilterChange(col, v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
{col.filterOptions!.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
</PopoverContent>
</Popover>
)}
{/* Active Filter Badges */}
{activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{filterColumns.map((col) => {
const key = col.filterKey || `filter_${col.key}`
const val = searchParams.get(key)
if (!val) return null
const opt = col.filterOptions!.find((o) => o.value === val)
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{col.label}: {opt?.label || val}
<button
type="button"
onClick={() => handleFilterChange(col, '__all__')}
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
</div>
)}
{/* Column Visibility */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Columns
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<div className="space-y-1">
<h4 className="text-sm font-medium mb-2">Toggle columns</h4>
{columns.map((col) => (
<div
key={col.key}
className="flex items-center gap-2 py-1 text-sm hover:text-foreground"
>
<Checkbox
checked={!hiddenColumns.has(col.key)}
onCheckedChange={(checked) => {
setHiddenColumns((prev) => {
const next = new Set(prev)
if (checked) next.delete(col.key)
else next.add(col.key)
return next
})
}}
/>
{col.label}
</div>
))}
</div>
</PopoverContent>
</Popover>
<Select value={String(data.perPage)} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / page
</SelectItem>
))}
</SelectContent>
</Select>
{exportUrl && (
<Button variant="outline" size="sm" asChild>
<a href={exportUrl}>
<Download className="mr-2 h-4 w-4" />
CSV
</a>
</Button>
)}
{bulkActions && selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{selectedIds.size} selected</span>
{bulkActions.map((action) => (
<Button
key={action.value}
variant={action.variant || 'default'}
size="sm"
onClick={() => handleBulkAction(action.value)}
>
{action.label}
</Button>
))}
</div>
)}
<div className="ml-auto text-sm text-muted-foreground">{data.total} results</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{bulkActions && (
<TableHead className="w-[40px]">
<Checkbox
checked={selectedIds.size === data.rows.length && data.rows.length > 0}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
)}
{columns
.filter((col) => !hiddenColumns.has(col.key))
.map((col) => (
<TableHead key={col.key}>
{col.sortable ? (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => handleSort(col.key)}
>
{col.label}
{getSortIcon(col.key)}
</button>
) : (
col.label
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={
columns.filter((c) => !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0)
}
className="h-24 text-center text-muted-foreground"
>
No results found.
</TableCell>
</TableRow>
) : (
data.rows.map((row) => (
<TableRow
key={row.id}
className={isPending ? 'opacity-50' : undefined}
data-selected={selectedIds.has(row.id) || undefined}
>
{bulkActions && (
<TableCell>
<Checkbox
checked={selectedIds.has(row.id)}
onCheckedChange={() => toggleSelect(row.id)}
/>
</TableCell>
)}
{columns
.filter((col) => !hiddenColumns.has(col.key))
.map((col) => (
<TableCell key={col.key}>
{col.render
? col.render((row as Record<string, unknown>)[col.key] as T[keyof T], row)
: String((row as Record<string, unknown>)[col.key] ?? '')}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data.lastPage > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {data.page} of {data.lastPage}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
disabled={data.page <= 1}
onClick={() => handlePageChange(data.page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from({ length: Math.min(data.lastPage, 7) }, (_, i) => {
let pageNum: number
if (data.lastPage <= 7) {
pageNum = i + 1
} else if (data.page <= 4) {
pageNum = i + 1
} else if (data.page >= data.lastPage - 3) {
pageNum = data.lastPage - 6 + i
} else {
pageNum = data.page - 3 + i
}
return (
<Button
key={pageNum}
variant={pageNum === data.page ? 'default' : 'outline'}
size="icon"
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
)
})}
<Button
variant="outline"
size="icon"
disabled={data.page >= data.lastPage}
onClick={() => handlePageChange(data.page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,412 @@
'use client'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
RotateCcw,
Save,
Search,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { saveEmulatorSettings } from '@/actions/emulator'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
interface Setting {
key: string
value: string
}
interface GroupSummary {
name: string
count: number
}
interface EmulatorSettingsFormProps {
settings: Setting[]
pagination: { total: number; page: number; perPage: number; lastPage: number }
search: string
allGroups?: GroupSummary[]
activeGroup?: string
}
const PER_PAGE_OPTIONS = [25, 50, 100, 200] as const
function getSettingGroup(key: string): string {
const parts = key.split('.')
return parts.length > 1 ? parts[0] : 'general'
}
const GROUP_BORDER_COLORS: Record<string, string> = {
hotel: 'border-l-blue-500',
navigator: 'border-l-green-500',
catalog: 'border-l-purple-500',
room: 'border-l-orange-500',
camera: 'border-l-pink-500',
commands: 'border-l-cyan-500',
wordfilter: 'border-l-red-500',
pet: 'border-l-yellow-500',
matchmaking: 'border-l-indigo-500',
message: 'border-l-emerald-500',
notification: 'border-l-fuchsia-500',
general: 'border-l-gray-400',
}
export function EmulatorSettingsForm({
settings,
pagination,
search,
allGroups,
activeGroup,
}: EmulatorSettingsFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
// `values` only contains overrides typed by the user. Display falls back
// to `setting.value`. `originalValues` accumulates the on-disk values for
// every setting we have ever rendered (across pages) so we can detect when
// an edit returns to its original state.
const [values, setValues] = useState<Record<string, string>>({})
const [originalValues, setOriginalValues] = useState<Record<string, string>>({})
const [changedKeys, setChangedKeys] = useState<Set<string>>(new Set())
const [isNavigating, startTransition] = useTransition()
const { run, isPending } = useServerAction()
const [searchValue, setSearchValue] = useState(search)
useEffect(() => {
setOriginalValues((prev) => {
let dirty = false
const next = { ...prev }
for (const s of settings) {
if (!(s.key in next)) {
next[s.key] = s.value
dirty = true
}
}
return dirty ? next : prev
})
}, [settings])
// Prefer the canonical list passed by the server (covers all groups, not
// just those visible on the current page). Fall back to deriving from the
// current page when the prop is omitted.
const groupSummary = useMemo<GroupSummary[]>(() => {
if (allGroups && allGroups.length > 0) return allGroups
const map = new Map<string, number>()
for (const s of settings) {
const g = getSettingGroup(s.key)
map.set(g, (map.get(g) ?? 0) + 1)
}
return Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name))
}, [allGroups, settings])
function toggleGroup(name: string) {
updateParams({
group: activeGroup === name ? '' : name,
page: '1',
})
}
function getDisplayValue(setting: Setting): string {
return values[setting.key] ?? setting.value
}
function updateValue(key: string, val: string) {
setValues((prev) => ({ ...prev, [key]: val }))
setChangedKeys((prev) => {
const next = new Set(prev)
if (originalValues[key] !== undefined && val === originalValues[key]) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
function revertChange(key: string) {
setValues((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setChangedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
function updateParams(updates: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString())
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
}
function handleSearch(e: React.FormEvent) {
e.preventDefault()
updateParams({ search: searchValue, page: '1' })
}
function handleSaveAll() {
if (changedKeys.size === 0) {
toast.info('No changes to save.')
return
}
const changedSettings: Record<string, string> = {}
for (const key of changedKeys) {
changedSettings[key] = values[key]
}
const count = changedKeys.size
run(() => saveEmulatorSettings({ settings: changedSettings }), {
successMessage: `${count} setting(s) saved and synced with emulator.`,
onSuccess: () => {
setChangedKeys(new Set())
setValues({})
// Refresh server data so the on-disk values reflect the save and the
// originalValues snapshot rebuilds on next render.
setOriginalValues({})
router.refresh()
},
})
}
function jumpToPage(raw: string) {
const n = parseInt(raw, 10)
if (!Number.isFinite(n)) return
const clamped = Math.max(1, Math.min(pagination.lastPage, n))
if (clamped !== pagination.page) updateParams({ page: String(clamped) })
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search settings..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Search
</Button>
</form>
<div className="ml-auto flex items-center gap-3">
<span className="text-sm text-muted-foreground">{pagination.total} settings</span>
{changedKeys.size > 0 && (
<Badge variant="secondary" className="gap-1">
{changedKeys.size} changed
</Badge>
)}
<Button onClick={handleSaveAll} disabled={isPending || changedKeys.size === 0} size="sm">
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
</div>
</div>
{/* Group summary chips — click to toggle group filter */}
{groupSummary.length > 1 && (
<div className="flex flex-wrap gap-1.5 items-center">
{activeGroup && (
<button
type="button"
onClick={() => toggleGroup(activeGroup)}
className="text-[11px] underline text-muted-foreground hover:text-foreground"
>
clear
</button>
)}
{groupSummary.map(({ name, count }) => {
const isActive = activeGroup === name
const color = (GROUP_BORDER_COLORS[name] || GROUP_BORDER_COLORS.general).replace(
'border-l-',
'bg-',
)
return (
<button
key={name}
type="button"
onClick={() => toggleGroup(name)}
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md border text-[10px] font-mono transition-colors ${
isActive
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isActive ? `Rimuovi filtro: ${name}` : `Filtra: ${name}`}
disabled={isNavigating}
>
<span className={`inline-block w-2 h-2 rounded-full ${color}`} />
{name}
<span className="text-muted-foreground">{count}</span>
</button>
)
})}
</div>
)}
{/* Settings list — flat rows with always-on inline editing */}
<div className="rounded-md border bg-card divide-y">
{settings.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No settings found.
</div>
) : (
settings.map((setting) => {
const group = getSettingGroup(setting.key)
const borderColor = GROUP_BORDER_COLORS[group] || GROUP_BORDER_COLORS.general
const isChanged = changedKeys.has(setting.key)
const display = getDisplayValue(setting)
return (
<div
key={setting.key}
className={`flex items-center gap-3 px-3 py-1.5 border-l-4 ${borderColor} ${
isChanged ? 'bg-yellow-50/60 dark:bg-yellow-950/20' : ''
}`}
>
<code
className="text-xs font-mono text-muted-foreground w-[280px] flex-shrink-0 truncate"
title={setting.key}
>
{setting.key}
</code>
<Input
value={display}
onChange={(e) => updateValue(setting.key, e.target.value)}
className={`flex-1 h-8 text-xs font-mono ${
isChanged ? 'border-yellow-500/60' : ''
}`}
/>
{isChanged && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Revert"
onClick={() => revertChange(setting.key)}
>
<RotateCcw className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
)
})
)}
</div>
{/* Pagination + per-page selector */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Per page</span>
<Select
value={String(pagination.perPage)}
onValueChange={(v) => updateParams({ perPage: v, page: '1' })}
>
<SelectTrigger size="sm" className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-2">
{pagination.total === 0
? '0 results'
: `${(pagination.page - 1) * pagination.perPage + 1}–${Math.min(
pagination.page * pagination.perPage,
pagination.total,
)} of ${pagination.total}`}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: '1' })}
title="First page"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: String(pagination.page - 1) })}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 mx-1 text-sm">
<Input
type="number"
min={1}
max={pagination.lastPage}
defaultValue={pagination.page}
key={pagination.page}
onBlur={(e) => jumpToPage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToPage((e.target as HTMLInputElement).value)
}}
className="h-8 w-14 text-center text-xs"
/>
<span className="text-muted-foreground">of {pagination.lastPage}</span>
</div>
<Button
variant="outline"
size="icon"
disabled={pagination.page >= pagination.lastPage || isNavigating}
onClick={() => updateParams({ page: String(pagination.page + 1) })}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={pagination.page >= pagination.lastPage || isNavigating}
onClick={() => updateParams({ page: String(pagination.lastPage) })}
title="Last page"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
'use client'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { toggleUserWatch } from '@/actions/watch'
import { Button } from '@/components/ui/button'
interface UserWatchToggleProps {
targetUserId: number
username: string
initialWatching: boolean
}
export function UserWatchToggle({ targetUserId, username, initialWatching }: UserWatchToggleProps) {
const [watching, setWatching] = useState(initialWatching)
const [busy, setBusy] = useState(false)
async function handleToggle() {
setBusy(true)
try {
const result = await toggleUserWatch({ targetUserId })
if (result.ok) {
const data = result.data as { watching: boolean }
setWatching(data.watching)
toast.success(data.watching ? `Now watching ${username}` : `Stopped watching ${username}`)
} else {
toast.error(result.error)
}
} finally {
setBusy(false)
}
}
return (
<Button
variant={watching ? 'default' : 'outline'}
size="sm"
disabled={busy}
onClick={handleToggle}
title={watching ? 'Stop watching this user' : 'Watch this user'}
>
{watching ? (
<>
<Eye className="mr-1.5 h-3.5 w-3.5" /> Watching
</>
) : (
<>
<EyeOff className="mr-1.5 h-3.5 w-3.5" /> Watch
</>
)}
</Button>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
link: 'text-primary underline-offset-4 [a&]:hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span'
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+63
View File
@@ -0,0 +1,63 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
+30
View File
@@ -0,0 +1,30 @@
'use client'
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import type * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
)
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
)
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
}
+111
View File
@@ -0,0 +1,111 @@
'use client'
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
}
+144
View File
@@ -0,0 +1,144 @@
'use client'
import { XIcon } from 'lucide-react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+229
View File
@@ -0,0 +1,229 @@
'use client'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
'use client'
import { Label as LabelPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }
+75
View File
@@ -0,0 +1,75 @@
'use client'
import { Popover as PopoverPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="popover-header"
className={cn('flex flex-col gap-1 text-sm', className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="popover-description"
className={cn('text-muted-foreground', className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+176
View File
@@ -0,0 +1,176 @@
'use client'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { Select as SelectPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+14
View File
@@ -0,0 +1,14 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { Switch as SwitchPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Switch({
className,
size = 'default',
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: 'sm' | 'default'
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+93
View File
@@ -0,0 +1,93 @@
'use client'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }
+82
View File
@@ -0,0 +1,82 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Tabs({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn('group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', className)}
{...props}
/>
)
}
const tabsListVariants = cva(
'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground',
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
className,
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
+19
View File
@@ -0,0 +1,19 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
)
}
export { Textarea }
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+2
View File
@@ -56,6 +56,8 @@ const schema = z.object({
OPENAI_API_KEY: z.string().optional(),
// Optional alerting (jobs worker / alert service).
DISCORD_WEBHOOK_URL: z.string().url().optional(),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_CHAT_ID: z.string().optional(),
ALERT_EMAIL: z.string().optional(),
// Optional PayPal top-up.
PAYPAL_CLIENT_ID: z.string().optional(),
+15
View File
@@ -0,0 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
+71
View File
@@ -0,0 +1,71 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { toast } from 'sonner'
import type { ActionResult } from '@/lib/safe-action-shared'
interface UseServerActionOptions {
/** Where to redirect after a successful action (optional) */
redirectOnSuccess?: string
}
interface RunOptions {
successMessage?: string
errorMessage?: string
/** Override redirect path for this specific call */
redirectTo?: string
/** Called on success with the result data. Overrides default router.refresh(). */
onSuccess?: (data: Record<string, unknown>) => void
}
/**
* Hook that wraps a server action with loading state, toast notifications,
* and optional redirect/refresh. Drop-in replacement for useAdminForm.
*
* @example
* const { run, isPending } = useServerAction()
*
* // Submit
* run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' })
*
* // Delete with redirect
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
*/
export function useServerAction(options: UseServerActionOptions = {}) {
const [isPending, startTransition] = useTransition()
const router = useRouter()
function run(action: () => Promise<ActionResult | void>, opts: RunOptions = {}) {
const {
successMessage = 'Saved successfully.',
errorMessage = 'An error occurred.',
redirectTo,
onSuccess,
} = opts
startTransition(async () => {
try {
const result = await action()
if (!result || result.ok) {
toast.success(successMessage)
if (onSuccess) {
onSuccess((result?.data ?? {}) as Record<string, unknown>)
} else {
const target = redirectTo || options.redirectOnSuccess
if (target) router.push(target)
else router.refresh()
}
} else {
toast.error(result.error || errorMessage)
}
} catch (err) {
console.error('[useServerAction] Unexpected error:', err)
toast.error(errorMessage)
}
})
}
return { run, isPending }
}
+55
View File
@@ -0,0 +1,55 @@
import type { ListParams } from '@/types'
export const PER_PAGE_OPTIONS = [10, 20, 50] as const
export const DEFAULT_PER_PAGE = 20
export function parseListParams(searchParams: URLSearchParams): ListParams {
const search = searchParams.get('search') || ''
const perPage = Math.min(
Math.max(parseInt(searchParams.get('perPage') || String(DEFAULT_PER_PAGE), 10), 1),
100,
)
const page = Math.max(parseInt(searchParams.get('page') || '1', 10), 1)
const sort = searchParams.get('sort') || undefined
const order = (searchParams.get('order') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc'
return { search, perPage, page, sort, order }
}
export function calcPagination(total: number, page: number, perPage: number) {
const lastPage = Math.max(Math.ceil(total / perPage), 1)
return {
total,
page: Math.min(page, lastPage),
perPage,
lastPage,
offset: (Math.min(page, lastPage) - 1) * perPage,
}
}
/** Generate CSV content from rows */
export function generateCsv(
rows: Record<string, unknown>[],
columns: { key: string; label: string }[],
): string {
const BOM = '\uFEFF'
const header = columns.map((c) => escapeCsv(c.label)).join(',')
const body = rows
.map((row) => columns.map((c) => escapeCsv(String(row[c.key] ?? ''))).join(','))
.join('\n')
return `${BOM + header}\n${body}`
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
export function formatTimestamp(ts: number): string {
if (!ts) return 'N/A'
return new Date(ts * 1000).toLocaleString()
}
+151
View File
@@ -0,0 +1,151 @@
import { redirect } from 'next/navigation'
import { calcPagination, parseListParams } from './admin-helpers'
import { canAccess, getAdminContext, type PermissionSet } from './permissions'
import { prisma } from './prisma'
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
type PrismaModel = any
interface AdminListConfig<TRow> {
/** Permission slug required to view this page */
permission: string
/** Prisma model name (e.g. 'user', 'ban', 'room') */
model: string
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
searchFields: string[]
/** Default sort when no sort param provided. Default: { id: 'desc' } */
defaultSort?: Record<string, 'asc' | 'desc'>
/** Default items per page. Default: 20 */
defaultPerPage?: number
/** Prisma include clause for relations */
include?: Record<string, unknown>
/** Prisma select clause (mutually exclusive with include) */
select?: Record<string, unknown>
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
baseWhere?: Record<string, unknown>
/**
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
* the raw string value from the URL and returns the WHERE fragment to merge.
* Returning null/undefined drops the filter.
*
* @example
* filterMap: {
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
* filter_online: (v) => ({ online: v }),
* }
*/
filterMap?: Record<string, (value: string) => Record<string, unknown> | null | undefined>
/** Transform raw Prisma row to the shape needed by the table component */
mapRow?: (row: PrismaModel) => TRow
}
interface AdminListResult<TRow> {
rows: TRow[]
total: number
page: number
perPage: number
lastPage: number
locale: string
permissions: PermissionSet
rank: number
}
/**
* Build a nested Prisma WHERE condition from a dot-notation field path.
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
* e.g. 'username' => { username: { contains: search } }
*/
function buildFieldCondition(fieldPath: string, search: string): Record<string, unknown> {
const parts = fieldPath.split('.')
if (parts.length === 1) {
return { [parts[0]]: { contains: search } }
}
// Build nested object from right to left
let result: Record<string, unknown> = { [parts[parts.length - 1]]: { contains: search } }
for (let i = parts.length - 2; i >= 0; i--) {
result = { [parts[i]]: result }
}
return result
}
/**
* Generic admin list data fetcher.
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
* Prisma query with pagination, and row mapping.
*/
export async function fetchAdminList<TRow = PrismaModel>(
config: AdminListConfig<TRow>,
paramsPromise: Promise<{ locale: string }>,
searchParamsPromise: Promise<Record<string, string>>,
): Promise<AdminListResult<TRow>> {
const { locale } = await paramsPromise
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, config.permission, session.user.rank)) {
redirect('/admin')
}
const rawParams = await searchParamsPromise
const sp = new URLSearchParams(rawParams)
const parsed = parseListParams(sp)
const perPage = config.defaultPerPage
? Number(rawParams.perPage) || config.defaultPerPage
: parsed.perPage
const { search, page, sort, order } = parsed
// Build WHERE clause
const searchWhere =
search && config.searchFields.length > 0
? { OR: config.searchFields.map((field) => buildFieldCondition(field, search)) }
: {}
// Apply URL filter_* params via filterMap
const filterWhere: Record<string, unknown> = {}
if (config.filterMap) {
for (const [paramKey, build] of Object.entries(config.filterMap)) {
const value = rawParams[paramKey]
if (!value) continue
const fragment = build(value)
if (fragment) Object.assign(filterWhere, fragment)
}
}
const where = {
...(config.baseWhere ?? {}),
...filterWhere,
...searchWhere,
}
// Build orderBy
const orderBy = sort ? { [sort]: order } : (config.defaultSort ?? { id: 'desc' as const })
// Access Prisma model dynamically
const delegate = (prisma as PrismaModel)[config.model]
const queryArgs: PrismaModel = {
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}
if (config.include) queryArgs.include = config.include
if (config.select) queryArgs.select = config.select
const [rawRows, total] = await Promise.all([
delegate.findMany(queryArgs),
delegate.count({ where }),
])
const pagination = calcPagination(total, page, perPage)
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows
return {
rows,
...pagination,
locale,
permissions,
rank: session.user.rank,
}
}
+48
View File
@@ -0,0 +1,48 @@
/** All known catalog page layouts for Habbo/Arcturus. */
export const CATALOG_LAYOUTS = [
'default_3x3',
'frontpage',
'spaces_new',
'recycler',
'trophies',
'pets',
'pets2',
'pets3',
'soundmachine',
'guilds',
'info_loyalty',
'info_duckets',
'loyalty_vip_buy',
'single_bundle',
'club_buy',
'marketplace',
'badge_display',
'room_bundle',
'sold_ltd_items',
] as const
export type CatalogLayout = (typeof CATALOG_LAYOUTS)[number]
/** Short human-readable description for each layout. Shown in the layout selector. */
export const LAYOUT_DESCRIPTIONS: Record<CatalogLayout, string> = {
default_3x3: 'Standard 3-column grid of items',
frontpage: 'Featured landing page with large teasers',
spaces_new: 'Wide 4-column grid used for themed spaces',
recycler: 'Ecotron recycler exchange page',
trophies: 'Trophy crafting form',
pets: 'Legacy pet purchase page',
pets2: 'Pet purchase page (v2)',
pets3: 'Pet purchase page (v3)',
soundmachine: 'Soundmachine track picker',
guilds: 'Guild/group creation page',
info_loyalty: 'Informational loyalty page (no items)',
info_duckets: 'Informational duckets page (no items)',
loyalty_vip_buy: 'HC/VIP loyalty purchase',
single_bundle: 'Single prominent bundle tile',
club_buy: 'HC subscription purchase page',
marketplace: 'Marketplace entry page',
badge_display: 'Badge showcase page',
room_bundle: 'Room bundle purchase page',
sold_ltd_items: 'Sold limited editions archive',
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Italian translations for common Habbo catalog page captions.
* Keys are lowercase versions of the English caption.
* Add new entries as needed.
*/
export const CATALOG_IT: Record<string, string> = {
// ── Main categories ───────────────────────────────────
frontpage: 'Pagina Principale',
'front page': 'Pagina Principale',
furniture: 'Mobili',
furni: 'Mobili',
rare: 'Rari',
rares: 'Rari',
'super rares': 'Super Rari',
'ultra rares': 'Ultra Rari',
limited: 'Limitati',
'limited edition': 'Edizione Limitata',
ltd: 'Limitati',
'new': 'Novità',
'new furni': 'Nuovi Mobili',
newest: 'Novità',
// ── Rooms & Spaces ────────────────────────────────────
rooms: 'Stanze',
'room bundles': 'Pacchetti Stanza',
'room bundle': 'Pacchetto Stanza',
spaces: 'Spazi',
walls: 'Pareti',
wall: 'Parete',
floors: 'Pavimenti',
floor: 'Pavimento',
landscapes: 'Paesaggi',
landscape: 'Paesaggio',
wallpaper: 'Carta da Parati',
wallpapers: 'Carte da Parati',
// ── Pets ──────────────────────────────────────────────
pets: 'Animali',
'pet accessories': 'Accessori Animali',
'pet food': 'Cibo Animali',
horses: 'Cavalli',
dogs: 'Cani',
cats: 'Gatti',
crocodiles: 'Coccodrilli',
terriers: 'Terrier',
bears: 'Orsi',
pigs: 'Maiali',
lions: 'Leoni',
rhinos: 'Rinoceronti',
spiders: 'Ragni',
turtles: 'Tartarughe',
chickens: 'Galline',
frogs: 'Rane',
dragons: 'Draghi',
monkeys: 'Scimmie',
gnomes: 'Gnomi',
monsters: 'Mostri',
'monster plants': 'Piante Mostro',
butterflies: 'Farfalle',
bunnies: 'Coniglietti',
pigeons: 'Piccioni',
// ── Economy ───────────────────────────────────────────
credits: 'Crediti',
diamonds: 'Diamanti',
duckets: 'Duckets',
pixels: 'Pixels',
marketplace: 'Mercatino',
recycler: 'Riciclatore',
ecotron: 'Ecotron',
// ── Habbo Club ────────────────────────────────────────
'habbo club': 'Club Habbo',
hc: 'Club Habbo',
vip: 'VIP',
'club furni': 'Mobili Club',
'club furniture': 'Mobili Club',
'club offers': 'Offerte Club',
membership: 'Abbonamento',
// ── Building ──────────────────────────────────────────
building: 'Costruzione',
'builders club': 'Club Costruttori',
wired: 'Wired',
'wired furni': 'Mobili Wired',
teleports: 'Teletrasporti',
rollers: 'Rulli',
gates: 'Cancelli',
switches: 'Interruttori',
// ── Decorations / Themes ──────────────────────────────
decorations: 'Decorazioni',
decoration: 'Decorazione',
plants: 'Piante',
lighting: 'Illuminazione',
lights: 'Luci',
candles: 'Candele',
kitchen: 'Cucina',
bathroom: 'Bagno',
bedroom: 'Camera da Letto',
'living room': 'Soggiorno',
garden: 'Giardino',
outdoor: 'Esterno',
office: 'Ufficio',
study: 'Studio',
music: 'Musica',
sound: 'Suoni',
'sound machine': 'Jukebox',
// ── Collections / Series ──────────────────────────────
gothic: 'Gotico',
executive: 'Executive',
mode: 'Moda',
iced: 'Ghiacciato',
pura: 'Pura',
lodge: 'Baita',
plastic: 'Plastica',
area: 'Area',
asian: 'Asiatico',
candy: 'Caramelle',
chrome: 'Cromo',
country: 'Country',
diner: 'Tavola Calda',
fairy: 'Fatato',
greek: 'Greco',
haunted: 'Stregato',
hollywood: 'Hollywood',
japanese: 'Giapponese',
jungle: 'Giungla',
love: 'Amore',
marble: 'Marmo',
medieval: 'Medievale',
neon: 'Neon',
prairie: 'Prateria',
romantic: 'Romantico',
'sci-fi': 'Fantascienza',
sport: 'Sport',
sports: 'Sport',
street: 'Strada',
tropical: 'Tropicale',
victorian: 'Vittoriano',
vintage: 'Vintage',
winter: 'Inverno',
summer: 'Estate',
spring: 'Primavera',
autumn: 'Autunno',
fall: 'Autunno',
christmas: 'Natale',
xmas: 'Natale',
easter: 'Pasqua',
halloween: 'Halloween',
valentine: 'San Valentino',
valentines: 'San Valentino',
'st patricks': 'San Patrizio',
// ── Games & Activities ────────────────────────────────
games: 'Giochi',
game: 'Gioco',
'battle banzai': 'Battaglia Banzai',
freeze: 'Freeze',
football: 'Calcio',
snowstorm: 'Tempesta di Neve',
'roller skating': 'Pattinaggio',
trophies: 'Trofei',
trophy: 'Trofeo',
prizes: 'Premi',
rewards: 'Ricompense',
// ── Badges & Clothing ────────────────────────────────
badges: 'Distintivi',
badge: 'Distintivo',
clothing: 'Abbigliamento',
clothes: 'Vestiti',
effects: 'Effetti',
dances: 'Balli',
// ── Bots & Features ──────────────────────────────────
bots: 'Bot',
bot: 'Bot',
groups: 'Gruppi',
guilds: 'Gilde',
guild: 'Gilda',
'group furni': 'Mobili Gruppo',
// ── Misc ──────────────────────────────────────────────
offers: 'Offerte',
specials: 'Speciali',
special: 'Speciale',
deals: 'Occasioni',
sale: 'Saldi',
bundles: 'Pacchetti',
bundle: 'Pacchetto',
promotions: 'Promozioni',
promo: 'Promozione',
info: 'Informazioni',
help: 'Aiuto',
loyalty: 'Fedeltà',
'loyalty rewards': 'Premi Fedeltà',
gift: 'Regalo',
gifts: 'Regali',
'gift shop': 'Negozio Regali',
'credit furni': 'Mobili Crediti',
exchange: 'Cambio',
'sold out': 'Esaurito',
'coming soon': 'In Arrivo',
all: 'Tutto',
search: 'Cerca',
featured: 'In Evidenza',
popular: 'Popolari',
'top picks': 'Le Migliori',
trending: 'Tendenze',
collections: 'Collezioni',
collection: 'Collezione',
'imported furni': 'Mobili Importati',
}
/**
* Get Italian translation for a catalog caption.
* Returns the translated name if found, otherwise null.
*/
export function translateCaption(caption: string): string | null {
const key = caption.toLowerCase().trim()
return CATALOG_IT[key] ?? null
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Module-level cache for the song picker options, shared between the
* catalog-items table (where the picker lives) and the Sounds admin page
* (which invalidates after an upload/edit/delete).
*
* Living in `src/lib/` (outside any `[dynamic]` route folder) avoids
* cross-route client imports that confused Turbopack's HMR boundaries
* and triggered `Router action dispatched before initialization` on
* the Sounds page in dev.
*/
export interface SoundtrackOption {
id: number
code: string
name: string
author: string
length: number
}
let cachedOptions: SoundtrackOption[] | null = null
let inflight: Promise<SoundtrackOption[]> | null = null
export async function loadSongPickerOptions(): Promise<SoundtrackOption[]> {
if (cachedOptions) return cachedOptions
if (inflight) return inflight
inflight = (async () => {
const res = await fetch('/api/admin/sounds', { cache: 'no-store' })
if (!res.ok) throw new Error('Failed to load soundtracks')
const json = (await res.json()) as { ok: boolean; items?: SoundtrackOption[] }
cachedOptions = json.items ?? []
inflight = null
return cachedOptions
})()
return inflight
}
export function getCachedSongPickerOptions(): SoundtrackOption[] | null {
return cachedOptions
}
/** Clear the cache so new uploads show up on next open. */
export function invalidateSongPickerCache(): void {
cachedOptions = null
}
+98
View File
@@ -0,0 +1,98 @@
// Whitelist of Nitro client localization files exposed in
// /admin/translations/client. The registry doubles as a path-traversal guard
// for the saveClientTranslations server action: only `id`s defined here can be
// written to.
export type ClientTranslationFormat = 'json5' | 'json'
export interface ClientTranslationFile {
id: string
/** Path relative to the project root. */
relPath: string
format: ClientTranslationFormat
language: 'it' | 'en' | 'shared'
readOnly: boolean
/**
* True when the on-disk file commonly contains comments that the json5
* serializer cannot preserve. Used to surface a warning in the UI.
*/
hasComments: boolean
/** Free-form note shown in the UI (e.g. why a file is read-only). */
note?: string
}
export const CLIENT_TRANSLATION_FILES: ClientTranslationFile[] = [
{
id: 'ui-texts-it',
relPath: 'public/nitro-assets/config/UITexts.json5',
format: 'json5',
language: 'it',
readOnly: false,
hasComments: true,
note: 'Override del client (sovrascrive ExternalTexts).',
},
{
id: 'ui-texts-en',
relPath: 'public/nitro-assets/config/UITexts_en.json5',
format: 'json5',
language: 'en',
readOnly: true,
hasComments: true,
note: 'Riferimento EN — non viene caricato dal client.',
},
{
id: 'external-texts',
relPath: 'public/nitro-assets/config/ExternalTexts.json5',
format: 'json5',
language: 'shared',
readOnly: false,
hasComments: false,
},
{
id: 'external-texts-badges',
relPath: 'public/nitro-assets/config/ExternalTexts_Badges.json5',
format: 'json5',
language: 'shared',
readOnly: false,
hasComments: false,
note: 'File grande (~30k voci): usa la ricerca.',
},
{
id: 'catalog-texts',
relPath: 'public/nitro-assets/config/CatalogTexts.json5',
format: 'json5',
language: 'shared',
readOnly: false,
hasComments: false,
},
{
id: 'gamedata-external-texts',
relPath: 'public/nitro-assets/gamedata/ExternalTexts.json',
format: 'json',
language: 'shared',
readOnly: true,
hasComments: false,
note: 'Copia "live" servita al client. Generata dal build, modifiche manuali sovrascritte.',
},
{
id: 'badge-texts-en',
relPath: 'public/nitro3/localization/badge-texts-en.json',
format: 'json',
language: 'en',
readOnly: false,
hasComments: false,
},
{
id: 'badge-texts-it',
relPath: 'public/nitro3/localization/badge-texts-it.json',
format: 'json',
language: 'it',
readOnly: false,
hasComments: false,
},
]
export function getClientTranslationFile(id: string): ClientTranslationFile | undefined {
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id)
}
+43
View File
@@ -0,0 +1,43 @@
const CLASSNAME_CHAR_RE = /^[a-z0-9_*\-.]+$/
export function getBaseClassname(classname: string): string {
const star = classname.indexOf('*')
return star >= 0 ? classname.substring(0, star) : classname
}
export function getSafeClassnameStem(classname: string): string {
return classname.replace(/\*/g, '_')
}
export function getIconFileName(classname: string): string {
return `${getSafeClassnameStem(classname)}_icon.png`
}
export function getSwfFileName(classname: string): string {
return `${getBaseClassname(classname)}.swf`
}
export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}.nitro`
}
export function normalizeClassname(raw: string): string {
return raw.trim().toLowerCase()
}
export function isValidClassname(classname: string): boolean {
if (!classname) return false
return CLASSNAME_CHAR_RE.test(classname)
}
export const LOCAL_ICON_URL_PREFIX = '/swf/dcr/hof_furni/icons/'
export function getLocalIconUrl(classname: string): string {
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`
}
export function getHabboCdnIconUrl(classname: string, revision: number): string {
const base = getBaseClassname(classname)
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Centralized Habbo avatar imager utility.
* Points to the self-hosted imager API (`/api/imaging/avatar`) which handles
* disk + memory caching and (Phase 2) local rendering from .nitro bundles.
* Override with NEXT_PUBLIC_IMAGER_URL when needed (e.g. CDN in front).
*/
const IMAGER_URL = process.env.NEXT_PUBLIC_IMAGER_URL || '/api/imaging/avatar'
export type { AvatarOptions } from '@/types/admin'
import type { AvatarOptions } from '@/types/admin'
/**
* Build an avatar image URL served by the self-hosted imager.
*
* @example
* getAvatarUrl('hr-893-45.hd-180-1.ch-210-66.lg-270-82')
* getAvatarUrl(look, { size: 'l', headOnly: true })
* getAvatarUrl(look, { size: 'm', gesture: 'sml', direction: 2 })
*/
export function getAvatarUrl(figure: string, options: AvatarOptions = {}): string {
const {
size = 'm',
direction = 2,
headDirection = 3,
headOnly = false,
gesture,
action,
} = options
const params = new URLSearchParams({
figure,
direction: String(direction),
head_direction: String(headDirection),
size,
})
if (headOnly) params.set('headonly', '1')
if (gesture) params.set('gesture', gesture)
if (action) params.set('action', action)
return `${IMAGER_URL}?${params.toString()}`
}
+79
View File
@@ -0,0 +1,79 @@
// Surgical line-level update for JSON5 files that preserves comments and
// formatting. Used by saveClientTranslations so that the Nitro localization
// files keep their headers and section markers when an admin edits a value.
//
// Assumptions (true for every Nitro `.json5` file in the repo):
// * one entry per line, formatted as `<key>: <value>,`
// * keys are simple strings (single- or double-quoted, or bare)
// * values are strings (single- or double-quoted)
// If a changed key cannot be located via this contract, we report it back so
// the caller can decide whether to fall back to a full re-serialization.
export interface PatchResult {
/** The new file content, with surgical edits applied. */
content: string
/** Keys that we could not patch surgically (missing or unusual format). */
unpatchedKeys: string[]
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function escapeForQuote(value: string, quote: '"' | "'"): string {
// Escape backslashes first, then the chosen quote. Newlines/tabs are
// preserved as escape sequences so we don't break the JSON5 parser.
let out = value.replace(/\\/g, '\\\\')
out = out.replace(new RegExp(quote, 'g'), `\\${quote}`)
out = out.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
return out
}
/**
* Apply per-key value updates to a JSON5 source string while preserving
* comments and formatting. Only keys present in `original` (and with a
* different value in `updated`) are touched.
*/
export function patchJson5(
raw: string,
original: Record<string, string>,
updated: Record<string, string>,
): PatchResult {
let result = raw
const unpatchedKeys: string[] = []
for (const [key, newVal] of Object.entries(updated)) {
if (!(key in original)) {
// Key did not exist on disk — append/merge logic is the caller's job.
unpatchedKeys.push(key)
continue
}
if (original[key] === newVal) continue
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`
const re = new RegExp(keyPattern)
const idx = result.search(re)
if (idx < 0) {
unpatchedKeys.push(key)
continue
}
const match = re.exec(result)
if (!match) {
unpatchedKeys.push(key)
continue
}
const valueQuote = match[2] as '"' | "'"
const oldValueLen = match[3].length
const fullLen = match[0].length
// prefix keeps everything up to and including the opening value quote
const prefix = match[0].slice(0, fullLen - oldValueLen - 1)
const escaped = escapeForQuote(newVal, valueQuote)
const replacement = prefix + escaped + valueQuote
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen)
}
return { content: result, unpatchedKeys }
}
+3 -2
View File
@@ -7,7 +7,7 @@ export interface MissingLocalImport {
}
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
async function collectSourceFiles(directory: string): Promise<string[]> {
@@ -16,6 +16,7 @@ async function collectSourceFiles(directory: string): Promise<string[]> {
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return collectSourceFiles(path);
if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) return [];
return sourceExtensions.has(extname(entry.name)) ? [path] : [];
}),
);
@@ -32,7 +33,7 @@ async function exists(path: string): Promise<boolean> {
}
async function resolvesToLocalFile(base: string): Promise<boolean> {
if (sourceExtensions.has(extname(base)) && (await exists(base))) return true;
if (extname(base) && (await exists(base))) return true;
for (const suffix of resolutionSuffixes) {
if (await exists(`${base}${suffix}`)) return true;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Learns where items get moved and suggests target pages for future moves.
* Stores patterns in localStorage:
* - By interaction type (e.g. "clothing" → page #42)
* - By item name prefix (e.g. "rare_" → page #15)
* - Recent moves (last 10)
*
* All suggestions are non-forced — just ranked higher in the combobox.
*/
const STORAGE_KEY = 'catalog_move_suggestions'
interface MoveRecord {
targetPageId: number
count: number
}
interface SuggestionStore {
/** interaction type → target pages with count */
byType: Record<string, MoveRecord[]>
/** item name prefix (first segment before _) → target pages with count */
byPrefix: Record<string, MoveRecord[]>
/** last N recent targets */
recent: number[]
}
function load(): SuggestionStore {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return { byType: {}, byPrefix: {}, recent: [] }
}
function save(store: SuggestionStore) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
} catch {}
}
function extractPrefix(itemName: string): string {
if (!itemName) return ''
const idx = itemName.indexOf('_')
return idx > 0 ? itemName.substring(0, idx).toLowerCase() : itemName.toLowerCase()
}
function incrementRecord(records: MoveRecord[], targetPageId: number): MoveRecord[] {
const existing = records.find((r) => r.targetPageId === targetPageId)
if (existing) {
return records
.map((r) => (r.targetPageId === targetPageId ? { ...r, count: r.count + 1 } : r))
.sort((a, b) => b.count - a.count)
}
return [...records, { targetPageId, count: 1 }].sort((a, b) => b.count - a.count).slice(0, 10)
}
/**
* Record a move for learning. Call this after successfully moving an item.
*/
export function recordMove(opts: {
interactionType?: string
itemName?: string
targetPageId: number
}) {
const store = load()
const { interactionType, itemName, targetPageId } = opts
if (interactionType) {
const key = interactionType.toLowerCase()
store.byType[key] = incrementRecord(store.byType[key] || [], targetPageId)
}
const prefix = extractPrefix(itemName ?? '')
if (prefix && prefix.length > 1) {
store.byPrefix[prefix] = incrementRecord(store.byPrefix[prefix] || [], targetPageId)
}
// Recent (deduplicate, keep last 10)
store.recent = [targetPageId, ...store.recent.filter((id) => id !== targetPageId)].slice(0, 10)
save(store)
}
/**
* Get suggested page IDs for moving an item, ranked by relevance.
* Returns unique page IDs ordered by best match.
*/
export function getSuggestions(opts: { interactionType?: string; itemName?: string }): number[] {
const store = load()
const { interactionType, itemName } = opts
const scores = new Map<number, number>()
// By interaction type (highest weight)
if (interactionType) {
const records = store.byType[interactionType.toLowerCase()] || []
for (const r of records) {
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 3)
}
}
// By name prefix
const prefix = extractPrefix(itemName ?? '')
if (prefix && prefix.length > 1) {
const records = store.byPrefix[prefix] || []
for (const r of records) {
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 2)
}
}
// Recent (lowest weight)
for (let i = 0; i < store.recent.length; i++) {
const id = store.recent[i]
scores.set(id, (scores.get(id) ?? 0) + (10 - i))
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([id]) => id)
}
+69
View File
@@ -0,0 +1,69 @@
// ── Permission Slug Constants ───────────────────────────────────────
// Extracted to its own file so it can be imported by client components
// (modules.ts → sidebar.tsx) without pulling in prisma/server-only deps.
export const PERMS = {
ADMIN_DASHBOARD: 'admin.dashboard',
USERS_VIEW: 'admin.users.view',
USERS_EDIT: 'admin.users.edit',
USERS_BAN: 'admin.users.ban',
USERS_RESET_PASSWORD: 'admin.users.reset_password',
ROOMS_VIEW: 'admin.room.view',
ROOMS_EDIT: 'admin.room.edit',
ROOMS_DELETE: 'admin.room.delete',
BANS_VIEW: 'admin.bans.view',
NEWS_VIEW: 'admin.news.view',
NEWS_EDIT: 'admin.news.edit',
LOGS_VIEW: 'admin.logs.view',
SETTINGS_VIEW: 'admin.settings.view',
SETTINGS_EDIT: 'admin.settings.edit',
PERMISSIONS_MANAGE: 'admin.permissions.manage',
SHOP_VIEW: 'admin.shop.view',
SHOP_EDIT: 'admin.shop.edit',
WORDFILTER_VIEW: 'admin.wordfilter.view',
WORDFILTER_EDIT: 'admin.wordfilter.edit',
CATALOG_VIEW: 'admin.catalog.view',
CATALOG_EDIT: 'admin.catalog.edit',
RCON_EXECUTE: 'admin.rcon.execute',
EXPORT: 'admin.export',
PREFIXES_VIEW: 'admin.prefixes.view',
PREFIXES_EDIT: 'admin.prefixes.edit',
TICKETS_VIEW: 'admin.tickets.view',
TICKETS_EDIT: 'admin.tickets.edit',
MODERATION_VIEW: 'admin.moderation.view',
MODERATION_EDIT: 'admin.moderation.edit',
// ── Events Module ──
EVENTS_VIEW: 'admin.events.view',
EVENTS_EDIT: 'admin.events.edit',
// ── Analytics Module ──
ANALYTICS_VIEW: 'admin.analytics.view',
ANALYTICS_EXPORT: 'admin.analytics.export',
// ── DevOps Module ──
DEVOPS_VIEW: 'admin.devops.view',
DEVOPS_EDIT: 'admin.devops.edit',
// ── Polls Module ──
POLLS_VIEW: 'admin.polls.view',
POLLS_EDIT: 'admin.polls.edit',
// ── Notifications Module ──
NOTIFICATIONS_VIEW: 'admin.notifications.view',
NOTIFICATIONS_EDIT: 'admin.notifications.edit',
// ── CMS Pages Module ──
PAGES_VIEW: 'admin.pages.view',
PAGES_EDIT: 'admin.pages.edit',
// ── Banners Module ──
BANNERS_VIEW: 'admin.banners.view',
BANNERS_EDIT: 'admin.banners.edit',
// ── Assets Module ──
ASSETS_IMPORT: 'admin.assets.import',
// ── Mod Panel Permissions ──
MOD_DASHBOARD: 'mod.dashboard',
MOD_CFH_VIEW: 'mod.cfh.view',
MOD_CFH_EDIT: 'mod.cfh.edit',
MOD_ACTIONS: 'mod.actions',
MOD_TEAM_VIEW: 'mod.team.view',
MOD_TICKETS_VIEW: 'mod.tickets.view',
MOD_TICKETS_EDIT: 'mod.tickets.edit',
MOD_USERS_VIEW: 'mod.users.view',
MOD_BANS_VIEW: 'mod.bans.view',
} as const
+193
View File
@@ -0,0 +1,193 @@
import { unstable_cache } from 'next/cache'
import { redirect } from 'next/navigation'
import { cache } from 'react'
import { auth } from './auth'
import { sessionUserId } from './auth/session-user'
import { prisma } from './prisma'
// Re-export PERMS from the standalone file (safe for client components)
export { PERMS } from './permission-slugs'
import { PERMS } from './permission-slugs'
// ── Permission Set ──────────────────────────────────────────────────
export type { PermissionSet } from '@/types/admin'
import type { PermissionSet } from '@/types/admin'
function createEmptySet(): PermissionSet {
return {
has: () => false,
hasAny: () => false,
hasAll: () => false,
isSuperAdmin: false,
}
}
/**
* Fetch all permission slugs for a user in a single SQL query.
* Cached via unstable_cache with 60s TTL — invalidated via revalidateTag('permissions').
*/
const getCachedPermissionSlugs = unstable_cache(
async (userId: number, rank: number): Promise<string[]> => {
const rows = await prisma.$queryRaw<{ slug: string }[]>`
SELECT DISTINCT p.slug
FROM acl_model_permissions mp
JOIN acl_permissions p ON p.id = mp.permission_id
WHERE mp.model_type = 'Role'
AND mp.model_id IN (
SELECT amr.role_id
FROM acl_model_roles amr
WHERE amr.model_type = 'User' AND amr.model_id = ${userId}
UNION
SELECT ar.id
FROM acl_roles ar
WHERE ar.slug = ${`rank_${rank}`}
)
`
return rows.map((r) => r.slug)
},
['user-permissions'],
{ revalidate: 60, tags: ['permissions'] },
)
/**
* Load permission slugs for a user. Rank is taken from the JWT session
* to avoid an extra DB query. Super admin (rank >= 7) bypasses all checks.
* Wrapped with React cache() to de-duplicate within the same request.
*/
export const loadUserPermissions = cache(async function loadUserPermissions(
userId: number,
rank: number,
): Promise<PermissionSet> {
try {
// Super admin bypasses all permission checks — zero DB queries
if (rank >= 7) {
return {
has: () => true,
hasAny: () => true,
hasAll: () => true,
isSuperAdmin: true,
}
}
const slugArray = await getCachedPermissionSlugs(userId, rank)
if (slugArray.length === 0) return createEmptySet()
const slugs = new Set(slugArray)
return {
has: (perm: string) => slugs.has(perm),
hasAny: (...perms: string[]) => perms.some((p) => slugs.has(p)),
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
isSuperAdmin: false,
}
} catch {
// Fail-closed: return empty set on any error
return createEmptySet()
}
})
// ── Context Helpers ─────────────────────────────────────────────────
/**
* For server components: get session + load permissions.
* Redirects to login if not authenticated.
*/
export async function getAdminContext() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const userId = sessionUserId(session.user.id)
if (!userId) redirect('/login')
const permissions = await loadUserPermissions(userId, session.user.rank)
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
}
/**
* For API routes: get session + load permissions.
* Returns null if not authenticated (caller handles 401).
*/
export async function getApiAdminContext() {
const session = await auth()
if (!session?.user) return null
const userId = sessionUserId(session.user.id)
if (!userId) return null
const permissions = await loadUserPermissions(userId, session.user.rank)
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
}
/**
* Check if user has a specific permission.
* Super admins (rank >= 7) bypass all checks via PermissionSet.isSuperAdmin.
* Rank >= 6 gets fallback access to admin dashboard and admin .view permissions.
* Rank >= 3 gets fallback access to mod dashboard and mod .view permissions.
*/
export function canAccess(permissions: PermissionSet, slug: string, rank: number): boolean {
if (permissions.has(slug)) return true
// Rank 6+ fallback: admin dashboard and admin view-only permissions
if (
rank >= 6 &&
(slug === PERMS.ADMIN_DASHBOARD || (slug.startsWith('admin.') && slug.endsWith('.view')))
)
return true
// Rank 3+ fallback: mod dashboard and mod view-only permissions + mod actions
if (
rank >= 3 &&
(slug === PERMS.MOD_DASHBOARD ||
(slug.startsWith('mod.') && slug.endsWith('.view')) ||
slug === PERMS.MOD_ACTIONS)
)
return true
return false
}
/**
* For mod panel server components: get session + load permissions.
* Redirects to login if not authenticated, to / if rank < 3.
*/
export async function getModContext() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
if (session.user.rank < 3) {
redirect('/')
}
const userId = sessionUserId(session.user.id)
if (!userId) redirect('/login')
const permissions = await loadUserPermissions(userId, session.user.rank)
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
}
// ── Legacy single-check functions (kept for backward compatibility) ──
/** Check if a user has a CMS permission. Rank >= 7 bypasses all checks. */
export async function checkPermission(
userId: number,
rank: number,
permission: string,
): Promise<boolean> {
const perms = await loadUserPermissions(userId, rank)
return perms.has(permission)
}
/** Check multiple permissions (user needs ALL of them) */
export async function checkAllPermissions(
userId: number,
rank: number,
permissions: string[],
): Promise<boolean> {
const perms = await loadUserPermissions(userId, rank)
return perms.hasAll(...permissions)
}
/** Check if user has admin access */
export async function hasAdminAccess(userId: number, rank: number): Promise<boolean> {
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD)
}
+51
View File
@@ -0,0 +1,51 @@
import { ZodError } from 'zod'
// ── Action Result Type ───────────────────────────────────────────────
export type ActionResult<T = Record<string, unknown>> =
| { ok: true; data?: T }
| { ok: false; error: string; fieldErrors?: Record<string, string[]> }
// ── Helper to build results ──────────────────────────────────────────
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
return { ok: true, data: data ?? ({} as T) }
}
export function actionError(message: string): ActionResult<never> {
return { ok: false, error: message }
}
// ── Throwable error ──────────────────────────────────────────────────
export class ActionError extends Error {
constructor(message: string) {
super(message)
this.name = 'ActionError'
}
}
// ── Error handler ────────────────────────────────────────────────────
export function handleActionError(error: unknown): ActionResult<never> {
if (error instanceof ZodError) {
return {
ok: false,
error: 'Validation failed',
fieldErrors: error.flatten().fieldErrors as Record<string, string[]>,
}
}
if (error instanceof Error && error.name === 'ActionError') {
return { ok: false, error: error.message }
}
// Prisma P2025
if (
error instanceof Error &&
error.constructor.name === 'PrismaClientKnownRequestError' &&
(error as Error & { code?: string }).code === 'P2025'
) {
return { ok: false, error: 'Not found' }
}
console.error('[Action error]', error)
return { ok: false, error: 'Internal server error' }
}
+122
View File
@@ -0,0 +1,122 @@
import type { z } from 'zod'
import { auth } from '@/lib/auth'
import { canAccess, getApiAdminContext } from '@/lib/permissions'
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
export type { ActionResult }
// ── Admin action wrapper ─────────────────────────────────────────────
interface AdminActionOptions<TSchema extends z.ZodType | undefined = undefined> {
permission?: string
schema?: TSchema
}
type AdminActionContext<TSchema extends z.ZodType | undefined> = {
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
/**
* Create a server action with admin auth + optional permission + optional Zod validation.
*
* @example
* export const updateNews = adminAction(
* { permission: PERMS.NEWS_EDIT, schema: updateNewsSchema },
* async (ctx) => {
* await prisma.websiteArticle.update({ ... })
* return actionOk()
* }
* )
*/
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
options: AdminActionOptions<TSchema>,
handler: (ctx: AdminActionContext<TSchema>) => Promise<ActionResult>,
) {
return async (
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
try {
const apiCtx = await getApiAdminContext()
if (!apiCtx) return actionError('Unauthorized')
if (options.permission) {
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
return actionError('Unauthorized')
}
}
let data: unknown
if (options.schema) {
const parsed = options.schema.safeParse(input)
if (!parsed.success) {
return {
ok: false,
error: 'Validation failed',
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
}
}
data = parsed.data
}
const ctx = {
session: apiCtx.session,
...(options.schema ? { data } : {}),
} as AdminActionContext<TSchema>
return await handler(ctx)
} catch (error) {
return handleActionError(error)
}
}
}
// ── Auth action wrapper (no permissions) ─────────────────────────────
interface AuthActionOptions<TSchema extends z.ZodType | undefined = undefined> {
schema?: TSchema
}
type AuthActionContext<TSchema extends z.ZodType | undefined> = {
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
/**
* Create a server action with auth only (no permission check).
*/
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
options: AuthActionOptions<TSchema>,
handler: (ctx: AuthActionContext<TSchema>) => Promise<ActionResult>,
) {
return async (
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
try {
const session = await auth()
if (!session?.user) return actionError('Unauthorized')
let data: unknown
if (options.schema) {
const parsed = options.schema.safeParse(input)
if (!parsed.success) {
return {
ok: false,
error: 'Validation failed',
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
}
}
data = parsed.data
}
const ctx = {
session,
...(options.schema ? { data } : {}),
} as AuthActionContext<TSchema>
return await handler(ctx)
} catch (error) {
return handleActionError(error)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import { prisma } from '../prisma'
interface AuditEntry {
userId: number
action: string
target: string
targetId?: number
before?: Record<string, unknown>
after?: Record<string, unknown>
}
const SENSITIVE_KEY_RE =
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i
const REDACTED = '[Redacted]'
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
if (depth > 6 || value == null) return value
if (Array.isArray(value)) return value.map((v) => sanitizeAuditPayload(v, depth + 1))
if (typeof value !== 'object') return value
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY_RE.test(key) ? REDACTED : sanitizeAuditPayload(val, depth + 1)
}
return out
}
function computeDiff(
before?: Record<string, unknown>,
after?: Record<string, unknown>,
): Record<string, { from: unknown; to: unknown }> | null {
if (!before || !after) return null
const diff: Record<string, { from: unknown; to: unknown }> = {}
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
for (const key of allKeys) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
diff[key] = { from: before[key], to: after[key] }
}
}
return Object.keys(diff).length > 0 ? diff : null
}
export async function logAudit(entry: AuditEntry): Promise<void> {
const sanitizedBefore = entry.before
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
: undefined
const sanitizedAfter = entry.after
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
: undefined
const diff = computeDiff(sanitizedBefore, sanitizedAfter)
await prisma.adminAuditLog.create({
data: {
userId: entry.userId,
action: entry.action,
target: entry.target,
targetId: entry.targetId,
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
diff: diff ? JSON.stringify(diff) : null,
createdAt: new Date().toISOString(),
},
})
}
interface GetLogsOptions {
search?: string
page?: number
perPage?: number
}
export async function getAuditLogs(options: GetLogsOptions = {}) {
const { search, page = 1, perPage = 20 } = options
const skip = (page - 1) * perPage
const where = search
? {
OR: [{ action: { contains: search } }, { target: { contains: search } }],
}
: {}
const [rows, total] = await Promise.all([
prisma.adminAuditLog.findMany({
where,
orderBy: { id: 'desc' },
skip,
take: perPage,
}),
prisma.adminAuditLog.count({ where }),
])
const userIds = [...new Set(rows.map((r) => r.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 enrichedRows = rows.map((r) => ({
...r,
username: userMap.get(r.userId) ?? `User #${r.userId}`,
}))
return {
rows: enrichedRows,
total,
page,
perPage,
lastPage: Math.ceil(total / perPage),
}
}
+297
View File
@@ -0,0 +1,297 @@
import { promises as fs } from 'node:fs'
import { Prisma } from '@/generated/prisma/client'
import { prisma } from '@/lib/prisma'
import { getFurnitureDataPath } from '@/lib/services/furni-data'
// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O
let furniDataCache: {
roomitemtypes?: {
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>
}
wallitemtypes?: {
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>
}
} | null = null
let furniDataCacheTime = 0
const FURNI_CACHE_TTL = 30_000 // 30 seconds
async function getFurnitureData() {
if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL) return furniDataCache
const furniDataPath = await getFurnitureDataPath()
try {
const raw = await fs.readFile(furniDataPath, 'utf-8')
furniDataCache = JSON.parse(raw)
furniDataCacheTime = Date.now()
return furniDataCache
} catch {
return null
}
}
/** Invalidate the FurnitureData cache after writes */
export function invalidateFurniDataCache() {
furniDataCache = null
furniDataCacheTime = 0
}
interface RawItem {
id: number
itemIds: string
pageId: number
offerId: number
songId: number
orderNumber: number
catalogName: string
costCredits: number
costPoints: number
pointsType: number
amount: number
limitedSells: number
limitedStack: number
extradata: string
haveOffer: string
clubOnly: string
}
interface BaseItem {
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 CatalogItemEnriched {
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: BaseItem | null
}
export interface CatalogItemsData {
items: CatalogItemEnriched[]
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[]
catalogNameMap: Record<number, string>
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap: Record<number, { classname: string; revision: number }>
interactionTypes: string[]
allPages: { id: number; caption: string }[]
}
export async function loadCatalogItemsData(pageId: number): Promise<CatalogItemsData> {
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
const pageIdStr = String(pageId)
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
SELECT * FROM catalog_items WHERE page_id = ${pageIdStr} ORDER BY id ASC
`
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
id: Number(r.id),
itemIds: String(r.item_ids ?? ''),
pageId: Number(r.page_id) || 0,
offerId: Number(r.offer_id) || -1,
songId: Number(r.song_id) || 0,
orderNumber: Number(r.order_number) || 99,
catalogName: String(r.catalog_name ?? ''),
costCredits: Number(r.cost_credits) || 0,
costPoints: Number(r.cost_points) || 0,
pointsType: Number(r.points_type) || 0,
amount: Number(r.amount) || 1,
limitedSells: Number(r.limited_sells) || 0,
limitedStack: Number(r.limited_stack) || 0,
extradata: String(r.extradata ?? ''),
haveOffer: String(r.have_offer ?? '1'),
clubOnly: String(r.club_only ?? '0'),
}))
const [allPages, interactionTypesRaw] = await Promise.all([
prisma.catalogPages.findMany({
orderBy: { caption: 'asc' },
select: { id: true, caption: true },
}),
prisma.$queryRaw<Array<{ interaction_type: string }>>`
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
`,
])
const interactionTypes = interactionTypesRaw.map((r) => String(r.interaction_type))
// Resolve item names from items_base for enrichment + translate
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean)
const baseItemIds = [
...new Set(
itemIdStrings.flatMap((s) =>
s
.split(';')
.map(Number)
.filter((n) => n > 0),
),
),
]
// Raw query to work around ENUM('0','1') columns returning numeric values.
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
const baseItems: BaseItem[] =
baseItemIds.length > 0
? (
await prisma.$queryRaw<Array<Record<string, unknown>>>`
SELECT id, sprite_id, public_name, item_name, type, width, length,
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
allow_inventory_stack, interaction_type, interaction_modes_count,
vending_ids, customparams, effect_id_male, effect_id_female,
clothing_on_walk
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
`
).map((r) => ({
id: Number(r.id),
spriteId: Number(r.sprite_id),
publicName: String(r.public_name ?? ''),
itemName: String(r.item_name ?? ''),
type: String(r.type ?? 's'),
width: Number(r.width) || 1,
length: Number(r.length) || 1,
stackHeight: Number(r.stack_height) || 0,
allowStack: Number(r.allow_stack ?? 0),
allowSit: Number(r.allow_sit ?? 0),
allowLay: Number(r.allow_lay ?? 0),
allowWalk: Number(r.allow_walk ?? 0),
allowGift: Number(r.allow_gift ?? 1),
allowTrade: Number(r.allow_trade ?? 1),
allowRecycle: Number(r.allow_recycle ?? 0),
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
interactionType: String(r.interaction_type ?? 'default'),
interactionModesCount: Number(r.interaction_modes_count) || 2,
vendingIds: String(r.vending_ids ?? '0'),
customparams: String(r.customparams ?? ''),
effectIdMale: Number(r.effect_id_male) || 0,
effectIdFemale: Number(r.effect_id_female) || 0,
clothingOnWalk: String(r.clothing_on_walk ?? ''),
}))
: []
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]))
// Build mapping: baseItemId -> catalogName
const catalogNameMap: Record<number, string> = {}
for (const item of items) {
const firstBaseId = item.itemIds
.split(';')
.map(Number)
.find((n) => n > 0)
if (firstBaseId && !catalogNameMap[firstBaseId]) {
catalogNameMap[firstBaseId] = item.catalogName
}
}
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
const firstBaseId = item.itemIds
.split(';')
.map(Number)
.find((n) => n > 0)
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined
return {
id: item.id,
catalogName: item.catalogName,
itemIds: item.itemIds,
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
limitedSells: item.limitedSells,
limitedStack: item.limitedStack,
orderNumber: item.orderNumber,
offerId: item.offerId,
songId: item.songId,
haveOffer: item.haveOffer,
clubOnly: item.clubOnly,
extradata: item.extradata,
baseName: base?.publicName || base?.itemName || '',
baseItemName: base?.itemName || '',
spriteId: base?.spriteId ?? 0,
baseItem: base ? { ...base } : null,
}
})
// Build spriteId -> items_base.id mapping for FurnitureData.json lookup
const spriteToBaseId = new Map<number, number>()
for (const b of baseItems) {
spriteToBaseId.set(b.spriteId, b.id)
}
// Read FurnitureData.json (cached in memory)
const foundBaseIds = new Set<number>()
const furniDescriptionMap: Record<number, string> = {}
const furniRevisionMap: Record<number, { classname: string; revision: number }> = {}
const furniData = await getFurnitureData()
if (furniData) {
for (const section of ['roomitemtypes', 'wallitemtypes'] as const) {
if (furniData[section]?.furnitype) {
for (const item of furniData[section].furnitype) {
const baseId = spriteToBaseId.get(item.id)
if (baseId !== undefined) {
foundBaseIds.add(baseId)
furniDescriptionMap[baseId] = item.description ?? ''
furniRevisionMap[baseId] = {
classname: item.classname ?? '',
revision: item.revision ?? 0,
}
}
}
}
}
}
return {
items: catalogItems,
baseItems: baseItems.map((b) => ({
id: b.id,
publicName: b.publicName,
itemName: b.itemName,
spriteId: b.spriteId,
type: b.type,
})),
catalogNameMap,
furniDataIdList: [...foundBaseIds],
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
}
}
+260
View File
@@ -0,0 +1,260 @@
import { prisma } from '@/lib/prisma'
export type { NestedTreeNode, TreeNode } from '@/types/catalog'
import type { TreeNode } from '@/types/catalog'
/**
* Get the full catalog tree recursively using iterative Prisma queries.
* Returns a flat list with depth info for client-side tree building.
* Depth is computed from parentId hierarchy (not stored in DB).
*/
export async function getTreeFlat(): Promise<TreeNode[]> {
const [allPages, itemCounts] = await Promise.all([
prisma.catalogPages.findMany({
orderBy: { orderNum: 'asc' },
}),
prisma.catalogItems.groupBy({
by: ['pageId'],
_count: true,
}),
])
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]))
// Count children per page
const childCountMap = new Map<number, number>()
for (const p of allPages) {
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1)
}
// Compute depth from parent hierarchy
const pageMap = new Map(allPages.map((p) => [p.id, p]))
const depthCache = new Map<number, number>()
function computeDepth(pageId: number): number {
if (depthCache.has(pageId)) return depthCache.get(pageId)!
const page = pageMap.get(pageId)
if (!page || page.parentId <= 0) {
depthCache.set(pageId, 0)
return 0
}
const d = computeDepth(page.parentId) + 1
depthCache.set(pageId, d)
return d
}
return allPages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
depth: computeDepth(p.id),
orderNum: p.orderNum,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: childCountMap.get(p.id) ?? 0,
itemCount: itemCountMap.get(p.id) ?? 0,
}))
}
/**
* Build a nested tree structure from flat nodes.
*/
import type { NestedTreeNode } from '@/types/catalog'
export function buildNestedTree(flat: TreeNode[], rootParentId = -1): NestedTreeNode[] {
const childrenMap = new Map<number, NestedTreeNode[]>()
const nodeMap = new Map<number, NestedTreeNode>()
for (const node of flat) {
const nested: NestedTreeNode = { ...node, children: [] }
nodeMap.set(node.id, nested)
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, [])
childrenMap.get(node.parentId)!.push(nested)
}
// Attach children
for (const node of nodeMap.values()) {
node.children = childrenMap.get(node.id) ?? []
}
return childrenMap.get(rootParentId) ?? []
}
/**
* Create a new catalog page.
*/
export async function createPage(data: {
parentId: number
caption: string
captionSave?: string
pageLayout?: string
iconImage?: number
iconColor?: number
minRank?: number
orderNum?: number
visible?: boolean
enabled?: boolean
}): Promise<{ id: number }> {
// Auto-generate captionSave if not provided
const captionSave =
data.captionSave ||
data.caption
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.substring(0, 25)
// Get next orderNum if not provided
let orderNum = data.orderNum
if (orderNum === undefined) {
const lastSibling = await prisma.catalogPages.findFirst({
where: { parentId: data.parentId },
orderBy: { orderNum: 'desc' },
select: { orderNum: true },
})
orderNum = (lastSibling?.orderNum ?? 0) + 1
}
const created = await prisma.catalogPages.create({
data: {
parentId: data.parentId,
caption: data.caption,
captionSave,
pageLayout: data.pageLayout ?? 'default_3x3',
iconImage: data.iconImage ?? 1,
iconColor: data.iconColor ?? 1,
minRank: data.minRank ?? 1,
orderNum,
visible: (data.visible ?? true) ? '1' : '0',
enabled: (data.enabled ?? true) ? '1' : '0',
pageHeadline: '',
pageTeaser: '',
pageSpecial: '',
pageText1: '',
pageText2: '',
pageTextDetails: '',
pageTextTeaser: '',
includes: '',
},
select: { id: true },
})
return created
}
/**
* Move a page to a new parent.
* Validates against circular hierarchy (A → B → C → A).
*/
export async function movePage(pageId: number, newParentId: number): Promise<void> {
// Walk up from newParentId to root — if we hit pageId, it's circular
if (newParentId > 0) {
let currentId = newParentId
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error('Cannot move page: would create a circular hierarchy')
}
const parent = await prisma.catalogPages.findUnique({
where: { id: currentId },
select: { parentId: true },
})
if (!parent || parent.parentId <= 0) break
currentId = parent.parentId
}
}
await prisma.catalogPages.update({
where: { id: pageId },
data: { parentId: newParentId },
})
}
/**
* Delete a page with cascade or reparent mode.
* - cascade: deletes all children and their items
* - reparent: moves children to the deleted page's parent
*/
export async function deletePage(
pageId: number,
mode: 'cascade' | 'reparent' = 'reparent',
): Promise<{ deletedPages: number; movedChildren: number }> {
const page = await prisma.catalogPages.findUnique({
where: { id: pageId },
select: { parentId: true },
})
if (!page) return { deletedPages: 0, movedChildren: 0 }
if (mode === 'reparent') {
// Move children to page's parent
const result = await prisma.catalogPages.updateMany({
where: { parentId: pageId },
data: { parentId: page.parentId },
})
// Delete items in this page
await prisma.catalogItems.deleteMany({ where: { pageId } })
// Delete the page
await prisma.catalogPages.delete({ where: { id: pageId } })
return { deletedPages: 1, movedChildren: result.count }
}
// Cascade: delete all descendants
const deleted = await cascadeDelete(pageId)
return { deletedPages: deleted, movedChildren: 0 }
}
async function cascadeDelete(pageId: number): Promise<number> {
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
const toDelete: number[] = [pageId]
const queue: number[] = [pageId]
while (queue.length > 0) {
const children = await prisma.catalogPages.findMany({
where: { parentId: { in: queue } },
select: { id: true },
})
queue.length = 0
for (const child of children) {
toDelete.push(child.id)
queue.push(child.id)
}
}
// Delete all items and pages in bulk (children first, then parents)
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } })
// Delete in reverse order (deepest first) to avoid FK issues
for (let i = toDelete.length - 1; i >= 0; i--) {
await prisma.catalogPages.delete({ where: { id: toDelete[i] } })
}
return toDelete.length
}
/**
* Get ancestors (breadcrumb) from root to the given page.
*/
export async function getAncestors(
pageId: number,
): Promise<Array<{ id: number; caption: string }>> {
const ancestors: Array<{ id: number; caption: string }> = []
let currentId = pageId
// Safety limit to prevent infinite loops
for (let i = 0; i < 20; i++) {
const page = await prisma.catalogPages.findUnique({
where: { id: currentId },
select: { id: true, caption: true, parentId: true },
})
if (!page) break
ancestors.unshift({ id: page.id, caption: page.caption })
if (page.parentId <= 0) break
currentId = page.parentId
}
return ancestors
}
+117
View File
@@ -0,0 +1,117 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { siteSettings } from '@/lib/services/site-settings'
export const DEFAULT_NITRO_FILES_ROOT = path.join(
/*turbopackIgnore: true*/ path.dirname(process.cwd()),
'DEV',
'Nitro-Files',
)
export const DEFAULT_FURNI_ICON_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/dcr/hof_furni/icons',
)
export const DEFAULT_FURNI_SWF_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/dcr/hof_furni/swf',
)
export const DEFAULT_FURNI_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/furniture',
)
export interface FurniAssetDirs {
swfDir: string
iconDir: string
nitroDir: string
}
export interface FurniAssetWriteTargets extends FurniAssetDirs {
mirrorDirs: FurniAssetDirs[]
}
function normalizeDir(value: string | undefined, fallback: string): string {
const raw = value?.trim() || fallback
let normalized = path.normalize(raw)
const root = path.parse(normalized).root
while (normalized.length > root.length && /[\\/]$/.test(normalized)) {
normalized = normalized.slice(0, -1)
}
return normalized
}
function uniqueDirs(dirs: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const dir of dirs) {
const normalized = normalizeDir(dir, dir)
const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized
if (seen.has(key)) continue
seen.add(key)
out.push(normalized)
}
return out
}
export async function getNitroFilesRoot(): Promise<string> {
const configured = (await siteSettings.get('nitro_files_root', '')) ?? ''
if (configured.trim()) return normalizeDir(configured, configured)
return existsSync(/*turbopackIgnore: true*/ DEFAULT_NITRO_FILES_ROOT)
? normalizeDir(DEFAULT_NITRO_FILES_ROOT, DEFAULT_NITRO_FILES_ROOT)
: ''
}
function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
return {
swfDir: path.join(/*turbopackIgnore: true*/ root, 'swf/dcr/hof_furni'),
iconDir: path.join(/*turbopackIgnore: true*/ root, 'swf/dcr/hof_furni/icons'),
nitroDir: path.join(/*turbopackIgnore: true*/ root, 'nitro-assets/bundled/furniture'),
}
}
export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
const [swfDir, iconDir, nitroDir] = await Promise.all([
siteSettings.get('furni_swf_dir', DEFAULT_FURNI_SWF_DIR).then((v) => v ?? DEFAULT_FURNI_SWF_DIR),
siteSettings.get('furni_icon_dir', DEFAULT_FURNI_ICON_DIR).then((v) => v ?? DEFAULT_FURNI_ICON_DIR),
siteSettings.get('furni_nitro_dir', DEFAULT_FURNI_NITRO_DIR).then((v) => v ?? DEFAULT_FURNI_NITRO_DIR),
])
return {
swfDir: normalizeDir(swfDir, DEFAULT_FURNI_SWF_DIR),
iconDir: normalizeDir(iconDir, DEFAULT_FURNI_ICON_DIR),
nitroDir: normalizeDir(nitroDir, DEFAULT_FURNI_NITRO_DIR),
}
}
export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets> {
const primary = await getFurniAssetDirs()
const nitroFilesRoot = await getNitroFilesRoot()
const mirrorCandidates = nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : []
const primaryKey = {
swfDir: process.platform === 'win32' ? primary.swfDir.toLowerCase() : primary.swfDir,
iconDir: process.platform === 'win32' ? primary.iconDir.toLowerCase() : primary.iconDir,
nitroDir: process.platform === 'win32' ? primary.nitroDir.toLowerCase() : primary.nitroDir,
}
const mirrorDirs = mirrorCandidates.filter((dirs) => {
const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir)
const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir)
const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir)
return (
(process.platform === 'win32' ? swfDir.toLowerCase() : swfDir) !== primaryKey.swfDir ||
(process.platform === 'win32' ? iconDir.toLowerCase() : iconDir) !== primaryKey.iconDir ||
(process.platform === 'win32' ? nitroDir.toLowerCase() : nitroDir) !== primaryKey.nitroDir
)
})
return {
...primary,
mirrorDirs: mirrorDirs.map((dirs) => ({
swfDir: uniqueDirs([dirs.swfDir])[0],
iconDir: uniqueDirs([dirs.iconDir])[0],
nitroDir: uniqueDirs([dirs.nitroDir])[0],
})),
}
}
+263
View File
@@ -0,0 +1,263 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { getNitroFilesRoot } from '@/lib/services/furni-asset-dirs'
import type { HabboItFurniEntry } from '@/lib/services/habbo-furnidata-cache'
import { siteSettings } from '@/lib/services/site-settings'
import type { FurniMetadata } from '@/lib/services/swf-to-nitro'
const LOCK_STALE_MS = 60_000
/**
* Resolve the FurnitureData file path from the admin setting
* `furni_data_url` (e.g. `/nitro-assets/config/FurnitureData.json5`) mapped
* under the public/ directory. Falls back to the json5 path if the setting
* is unset.
*/
export async function getFurnitureDataPath(): Promise<string> {
const url = (await siteSettings.get('furni_data_url', '/nitro-assets/config/FurnitureData.json5')) ?? '/nitro-assets/config/FurnitureData.json5'
if (path.isAbsolute(url) && !url.startsWith('/')) return url
const relative = url.replace(/^\/+/, '')
// The `relative` segment is admin-configurable so its concrete value is
// unknown at build time; without the hint Turbopack traces the entire
// public/ tree (~400k files) into the route's NFT.
return path.join(/*turbopackIgnore: true*/ process.cwd(), 'public', relative)
}
async function getFurnitureDataWritePaths(): Promise<string[]> {
const primary = await getFurnitureDataPath()
const configuredMirror = (await siteSettings.get('furni_data_mirror_path', '')) ?? ''
const nitroFilesRoot = await getNitroFilesRoot()
const mirror = configuredMirror.trim()
? configuredMirror
: nitroFilesRoot
? path.join(
/*turbopackIgnore: true*/ nitroFilesRoot,
'nitro-assets/gamedata/FurnitureData.json',
)
: ''
const paths = [primary, mirror].filter(Boolean).map((p) => path.normalize(p))
const seen = new Set<string>()
return paths.filter((p) => {
const key = process.platform === 'win32' ? p.toLowerCase() : p
if (seen.has(key)) return false
seen.add(key)
return true
})
}
async function getFurnitureDataReadPath(): Promise<string> {
const paths = await getFurnitureDataWritePaths()
const existing = paths.find((p) => existsSync(/*turbopackIgnore: true*/ p))
return existing ?? paths[0]
}
// ── FurnitureData.json lock + helpers ────────────────────────────────
let furniDataLock: Promise<void> = Promise.resolve()
async function acquireDiskLock(lockPath: string): Promise<void> {
const deadline = Date.now() + 30_000
while (true) {
try {
const fh = await fs.open(lockPath, 'wx')
await fh.writeFile(String(process.pid))
await fh.close()
return
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code !== 'EEXIST') throw err
try {
const stat = await fs.stat(lockPath)
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fs.unlink(lockPath).catch(() => {})
continue
}
} catch {
/* lock vanished between checks */
}
if (Date.now() > deadline) throw new Error('FurnitureData lock timeout')
await new Promise((r) => setTimeout(r, 100))
}
}
}
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
let release: () => void
const acquired = new Promise<void>((r) => {
release = r
})
const prev = furniDataLock
furniDataLock = acquired
await prev
const lockBase = await getFurnitureDataReadPath()
await fs.mkdir(path.dirname(lockBase), { recursive: true })
const lockPath = `${lockBase}.lock`
await acquireDiskLock(lockPath)
try {
return await fn()
} finally {
await fs.unlink(lockPath).catch(() => {})
release!()
}
}
export async function readFurniData(): Promise<Record<string, unknown>> {
const target = await getFurnitureDataReadPath()
const raw = await fs.readFile(/*turbopackIgnore: true*/ target, 'utf-8')
return JSON.parse(raw)
}
export async function writeFurniData(data: Record<string, unknown>): Promise<void> {
const targets = await getFurnitureDataWritePaths()
const payload = JSON.stringify(data)
for (const target of targets) {
await fs.mkdir(path.dirname(target), { recursive: true })
const tmp = `${target}.tmp`
await fs.writeFile(/*turbopackIgnore: true*/ tmp, payload, 'utf-8')
await fs.rename(/*turbopackIgnore: true*/ tmp, target)
}
}
export function buildFurniEntry(params: {
id: number
classname: string
revision: number
category: string
name: string
description: string
spriteId: number
dims: { x: number; y: number; z: number }
metadata: FurniMetadata | null
habboIt?: HabboItFurniEntry | null
}): Record<string, unknown> {
const {
id,
classname,
revision,
category,
name,
description,
spriteId,
dims,
metadata,
habboIt,
} = params
// Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction
const partcolors = habboIt?.partcolors?.color?.length
? habboIt.partcolors
: (metadata?.partcolors ?? { color: [] })
return {
id,
classname,
revision: revision || habboIt?.revision || 0,
category: category || habboIt?.category || 'unknown',
defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0,
xdim: dims.x,
ydim: dims.y,
partcolors,
name,
description: description || '',
adurl: '',
offerid: spriteId,
buyout: !!habboIt?.bc,
rentofferid: -1,
rentbuyout: false,
bc: habboIt?.bc ?? false,
excludeddynamic: false,
customparams: habboIt?.customparams ?? '',
specialtype: metadata?.specialtype ?? habboIt?.specialtype ?? 0,
canstandon: metadata?.canstandon ?? habboIt?.canstandon ?? false,
cansiton: metadata?.cansiton ?? habboIt?.cansiton ?? false,
canlayon: metadata?.canlayon ?? habboIt?.canlayon ?? false,
furniline: habboIt?.furniline ?? '',
environment: habboIt?.environment ?? '',
rare: habboIt?.rare ?? false,
}
}
function upsertEntryInSection(
section: { furnitype: Array<Record<string, unknown>> },
entry: Record<string, unknown>,
) {
const classname = entry.classname as string | undefined
if (classname) {
const idx = section.furnitype.findIndex((e) => e.classname === classname)
if (idx >= 0) {
section.furnitype[idx] = entry
return
}
}
section.furnitype.push(entry)
}
export async function appendFurniEntry(
entry: Record<string, unknown>,
itemType: string,
): Promise<void> {
await withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>
if (itemType === 'i') {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] }
upsertEntryInSection(furniData.wallitemtypes, entry)
} else {
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }
upsertEntryInSection(furniData.roomitemtypes, entry)
}
await writeFurniData(furniData as Record<string, unknown>)
})
}
export async function removeFurniEntry(classname: string): Promise<boolean> {
return withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>
let removed = false
for (const section of ['roomitemtypes', 'wallitemtypes'] as const) {
if (furniData[section]?.furnitype) {
const before = furniData[section].furnitype.length
furniData[section].furnitype = furniData[section].furnitype.filter(
(e) => e.classname !== classname,
)
if (furniData[section].furnitype.length < before) removed = true
}
}
if (removed) {
await writeFurniData(furniData as Record<string, unknown>)
}
return removed
})
}
export async function appendFurniEntriesBatch(
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
): Promise<void> {
await withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<string, { furnitype: unknown[] }>
for (const { entry, itemType } of entries) {
if (itemType === 'i') {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] }
upsertEntryInSection(
furniData.wallitemtypes as { furnitype: Array<Record<string, unknown>> },
entry,
)
} else {
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }
upsertEntryInSection(
furniData.roomitemtypes as { furnitype: Array<Record<string, unknown>> },
entry,
)
}
}
await writeFurniData(furniData as Record<string, unknown>)
})
}
+163
View File
@@ -0,0 +1,163 @@
/**
* habbo.it furnidata cache — downloads once, keeps in memory.
* Provides instant lookup by classname for enriching imports.
*/
export type { HabboItFurniEntry } from '@/types/furni'
import type { HabboItFurniEntry } from '@/types/furni'
const FURNIDATA_URL = 'https://www.habbo.it/gamedata/furnidata_json/1'
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes
let cache: Map<string, HabboItFurniEntry> | null = null
let cacheTimestamp = 0
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null
/**
* Fetches and parses the full habbo.it furnidata JSON.
* Results are cached in memory for 30 minutes.
*/
export async function getHabboItFurnidata(): Promise<Map<string, HabboItFurniEntry>> {
const now = Date.now()
// Return cache if still valid
if (cache && now - cacheTimestamp < CACHE_TTL) {
return cache
}
// Deduplicate concurrent requests
if (loading) return loading
loading = (async () => {
try {
console.debug('[habbo-it] Fetching furnidata from habbo.it...')
const res = await fetch(FURNIDATA_URL, {
signal: AbortSignal.timeout(20000),
headers: { Accept: 'application/json' },
})
if (!res.ok) {
console.warn(`[habbo-it] Fetch failed: ${res.status}`)
return cache || new Map()
}
const data = await res.json()
const map = new Map<string, HabboItFurniEntry>()
// Process room items (floor furniture)
const roomTypes = data?.roomitemtypes?.furnitype
if (Array.isArray(roomTypes)) {
for (const item of roomTypes) {
if (!item.classname) continue
map.set(item.classname, {
id: item.id ?? 0,
classname: item.classname,
revision: item.revision ?? 0,
category: item.category ?? '',
defaultdir: item.defaultdir ?? 0,
xdim: item.xdim ?? 1,
ydim: item.ydim ?? 1,
partcolors: item.partcolors ?? { color: [] },
name: item.name ?? '',
description: item.description ?? '',
specialtype: item.specialtype ?? 0,
canstandon: item.canstandon ?? false,
cansiton: item.cansiton ?? false,
canlayon: item.canlayon ?? false,
furniline: item.furniline ?? '',
environment: item.environment ?? '',
rare: item.rare ?? false,
tradeable: item.tradeable,
bc: item.bc,
customparams: item.customparams,
itemType: 's',
})
}
}
// Process wall items
const wallTypes = data?.wallitemtypes?.furnitype
if (Array.isArray(wallTypes)) {
for (const item of wallTypes) {
if (!item.classname) continue
map.set(item.classname, {
id: item.id ?? 0,
classname: item.classname,
revision: item.revision ?? 0,
category: item.category ?? '',
defaultdir: item.defaultdir ?? 0,
xdim: item.xdim ?? 1,
ydim: item.ydim ?? 1,
partcolors: item.partcolors ?? { color: [] },
name: item.name ?? '',
description: item.description ?? '',
specialtype: item.specialtype ?? 0,
canstandon: item.canstandon ?? false,
cansiton: item.cansiton ?? false,
canlayon: item.canlayon ?? false,
furniline: item.furniline ?? '',
environment: item.environment ?? '',
rare: item.rare ?? false,
tradeable: item.tradeable,
bc: item.bc,
customparams: item.customparams,
itemType: 'i',
})
}
}
console.debug(
`[habbo-it] Cached ${map.size} items (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
)
cache = map
cacheTimestamp = now
return map
} catch (err) {
console.warn('[habbo-it] Fetch error:', (err as Error).message)
return cache || new Map()
} finally {
loading = null
}
})()
return loading
}
/**
* Lookup a single item by classname from habbo.it furnidata.
* Tries exact match first, then base classname (without color variant).
*/
export async function lookupHabboIt(classname: string): Promise<HabboItFurniEntry | null> {
const map = await getHabboItFurnidata()
// 1. Exact match
const exact = map.get(classname)
if (exact) return exact
// 2. If classname has color variant (e.g. "chair*4"), try base
if (classname.includes('*')) {
const base = classname.split('*')[0]
// Try base with *0 first (most common default)
const variant0 = map.get(`${base}*0`)
if (variant0) return variant0
// Try just the base
const baseMatch = map.get(base)
if (baseMatch) return baseMatch
}
// 3. If no variant in classname, try *0
if (!classname.includes('*')) {
const withVariant = map.get(`${classname}*0`)
if (withVariant) return withVariant
}
return null
}
/** Clear the cache (useful after manual edits) */
export function clearHabboItCache(): void {
cache = null
cacheTimestamp = 0
}
+311
View File
@@ -0,0 +1,311 @@
import { Prisma } from '@/generated/prisma/client'
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/
export const RANK_GENERAL_FIELDS = new Set([
'rank_name',
'hidden_rank',
'badge',
'job_description',
'staff_color',
'staff_background',
'level',
'room_effect',
'log_commands',
'prefix',
'prefix_color',
'auto_credits_amount',
'auto_pixels_amount',
'auto_gotw_amount',
'auto_points_amount',
])
export interface EmulatorRankSummary {
id: number
rank_name: string
badge: string
level: number
prefix: string
prefix_color: string
hidden_rank: string
}
export interface EmulatorRankForEdit extends EmulatorRankSummary {
log_commands: string
room_effect: number
auto_credits_amount: number
auto_pixels_amount: number
auto_gotw_amount: number
auto_points_amount: number
permissions: Record<string, string>
permissionMaxValues: Record<string, number>
}
type RawDb = {
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>
$executeRaw: (query: Prisma.Sql) => Promise<number>
$transaction: <T>(queries: Promise<T>[]) => Promise<T[]>
}
export function getRankPermissionColumn(rankId: number) {
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
throw new Error('Invalid rank id')
}
return `rank_${rankId}`
}
export function toPermissionValue(value: string | number) {
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
if (!Number.isFinite(parsed) || parsed < 0) return 0
return Math.min(Math.trunc(parsed), 255)
}
export function splitRankUpdateFields(fields: Record<string, string | number>) {
const rankFields: Record<string, string | number> = {}
const permissionFields: Record<string, number> = {}
for (const [key, value] of Object.entries(fields)) {
if (key === 'id' || !VALID_KEY.test(key)) continue
if (RANK_GENERAL_FIELDS.has(key)) {
rankFields[key] = value
} else {
permissionFields[key] = toPermissionValue(value)
}
}
return { rankFields, permissionFields }
}
export async function fetchEmulatorRankSummaries(db: RawDb): Promise<EmulatorRankSummary[]> {
const rows = await db.$queryRaw<Record<string, unknown>[]>(
Prisma.sql`
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
FROM permission_ranks
ORDER BY id ASC
`,
)
return rows.map((row) => ({
id: Number(row.id),
rank_name: String(row.rank_name ?? ''),
badge: String(row.badge ?? ''),
level: Number(row.level ?? 1),
prefix: String(row.prefix ?? ''),
prefix_color: String(row.prefix_color ?? ''),
hidden_rank: String(row.hidden_rank ?? '0'),
}))
}
export async function fetchEmulatorRankForEdit(
db: RawDb,
rankId: number,
): Promise<EmulatorRankForEdit | null> {
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
Prisma.sql`
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
auto_gotw_amount, auto_points_amount
FROM permission_ranks
WHERE id = ${rankId}
`,
)
if (rankRows.length === 0) return null
const rankColumn = getRankPermissionColumn(rankId)
const permissionRows = await db.$queryRaw<
{ permission_key: string; max_value: number; value: number | string | null }[]
>(
Prisma.sql`
SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value
FROM permission_definitions
ORDER BY permission_key ASC
`,
)
const permissions: Record<string, string> = {}
const permissionMaxValues: Record<string, number> = {}
for (const row of permissionRows) {
permissions[row.permission_key] = String(row.value ?? 0)
permissionMaxValues[row.permission_key] = Number(row.max_value ?? 1)
}
const row = rankRows[0]
return {
id: Number(row.id),
rank_name: String(row.rank_name ?? ''),
badge: String(row.badge ?? ''),
level: Number(row.level ?? 1),
prefix: String(row.prefix ?? ''),
prefix_color: String(row.prefix_color ?? ''),
hidden_rank: String(row.hidden_rank ?? '0'),
log_commands: String(row.log_commands ?? '0'),
room_effect: Number(row.room_effect ?? 0),
auto_credits_amount: Number(row.auto_credits_amount ?? 0),
auto_pixels_amount: Number(row.auto_pixels_amount ?? 0),
auto_gotw_amount: Number(row.auto_gotw_amount ?? 0),
auto_points_amount: Number(row.auto_points_amount ?? 0),
permissions,
permissionMaxValues,
}
}
export async function createEmulatorRank(
db: RawDb,
data: { rank_name: string; level: number },
): Promise<number> {
const rows = await db.$queryRaw<{ next_id: number }[]>(
Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
)
const id = Number(rows[0]?.next_id ?? 1)
await ensurePermissionRankColumn(db, id)
await resetPermissionRankColumn(db, id)
await db.$transaction([
db.$executeRaw(
Prisma.sql`
INSERT INTO permission_ranks (id, rank_name, level)
VALUES (${id}, ${data.rank_name}, ${data.level})
`,
),
db.$executeRaw(
Prisma.sql`
INSERT INTO permissions (id, rank_name, level)
VALUES (${id}, ${data.rank_name}, ${data.level})
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
`,
),
])
return id
}
export async function deleteEmulatorRank(db: RawDb, rankId: number) {
await resetPermissionRankColumn(db, rankId)
await db.$transaction([
db.$executeRaw(Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`),
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`),
])
}
export async function updateEmulatorRank(
db: RawDb,
rankId: number,
fields: Record<string, string | number>,
) {
const { rankFields, permissionFields } = splitRankUpdateFields(fields)
if (Object.keys(rankFields).length > 0) {
await updateTableFields(db, 'permission_ranks', rankId, rankFields)
await updateLegacyPermissionFields(db, rankId, rankFields)
}
if (Object.keys(permissionFields).length > 0) {
await updatePermissionDefinitionValues(db, rankId, permissionFields)
await updateLegacyPermissionFields(db, rankId, permissionFields)
}
}
async function updateTableFields(
db: RawDb,
table: string,
rankId: number,
fields: Record<string, string | number>,
) {
const assignments = Object.entries(fields).map(
([key, value]) => Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
)
return db.$executeRaw(
Prisma.sql`
UPDATE ${Prisma.raw(quoteIdentifier(table))}
SET ${Prisma.join(assignments, ', ')}
WHERE id = ${rankId}
`,
)
}
async function updatePermissionDefinitionValues(
db: RawDb,
rankId: number,
permissionFields: Record<string, number>,
) {
const rankColumn = getRankPermissionColumn(rankId)
const entries = Object.entries(permissionFields)
const cases = entries.map(
([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
)
const keys = entries.map(([key]) => key)
return db.$executeRaw(
Prisma.sql`
UPDATE permission_definitions
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
CASE permission_key
${Prisma.join(cases, ' ')}
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
END
WHERE permission_key IN (${Prisma.join(keys)})
`,
)
}
async function updateLegacyPermissionFields(
db: RawDb,
rankId: number,
fields: Record<string, string | number>,
) {
const columns = await getTableColumns(db, 'permissions')
const existingFields = Object.fromEntries(
Object.entries(fields).filter(([key]) => columns.has(key)),
)
if (Object.keys(existingFields).length === 0) return 0
return updateTableFields(db, 'permissions', rankId, existingFields)
}
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId)
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
Prisma.sql`
SELECT COUNT(*) AS c
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'permission_definitions'
AND column_name = ${rankColumn}
`,
)
if (Number(exists[0]?.c ?? 0) > 0) return
await db.$executeRaw(
Prisma.sql`
ALTER TABLE permission_definitions
ADD COLUMN ${Prisma.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
`,
)
}
async function resetPermissionRankColumn(db: RawDb, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId)
await db.$executeRaw(
Prisma.sql`
UPDATE permission_definitions
SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0
`,
)
}
async function getTableColumns(db: RawDb, table: string) {
const rows = await db.$queryRaw<{ column_name: string }[]>(
Prisma.sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ${table}
`,
)
return new Set(rows.map((row) => row.column_name))
}
function quoteIdentifier(identifier: string) {
if (!VALID_KEY.test(identifier)) throw new Error('Invalid identifier')
return `\`${identifier}\``
}
+13 -1
View File
@@ -92,7 +92,7 @@ export class RconClient {
sendGift(userId: number, itemId: number, message = "Here is a gift.") {
return this.send("sendgift", { user_id: userId, itemid: itemId, message });
}
disconnectUser(userId: number, username: string) {
disconnectUser(userId: number, username = "") {
return this.send("disconnect", { user_id: userId, username });
}
alertUser(userId: number, message: string) {
@@ -110,6 +110,18 @@ export class RconClient {
updateCatalog() {
return this.send("updatecatalog");
}
updateConfig() {
return this.send("updateconfig");
}
removeBadge(userId: number, badge: string) {
return this.send("removebadge", { user_id: userId, badge });
}
muteUser(userId: number, duration: number) {
return this.send("muteuser", { user_id: userId, duration });
}
unmuteUser(userId: number) {
return this.send("unmuteuser", { user_id: userId });
}
}
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
+128
View File
@@ -0,0 +1,128 @@
/**
* Soundtrack (Song Disks) service helpers.
*
* Storage layout:
* MP3 files live at public/swf/dcr/hof_furni/mp3/{code}.mp3
* Public URL: /swf/dcr/hof_furni/mp3/{code}.mp3
*
* This is the same directory used by Arcturus for the trax sample set
* (`sound_machine_sample_<N>.mp3`). Keeping uploads alongside existing
* samples means they resolve via the same URL base and play without
* extra middleware config.
*
* The public URL prefix is configurable via the CMS setting
* `soundtrack_base_url` — see site-settings.ts defaults.
*/
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { parseBuffer } from 'music-metadata'
// ── Constants ──────────────────────────────────────────────────────
/** On-disk directory where MP3s live. Fixed (not configurable from UI to
* avoid any path traversal risk). */
export const SOUNDTRACK_DIR = path.join(process.cwd(), 'public', 'swf', 'dcr', 'hof_furni', 'mp3')
/** Default public URL prefix (with trailing slash). Can be overridden via
* the `soundtrack_base_url` CMS setting. */
export const DEFAULT_SOUNDTRACK_BASE_URL = '/swf/dcr/hof_furni/mp3/'
/** 10 MB hard limit per upload. */
export const MAX_SOUNDTRACK_SIZE = 10 * 1024 * 1024
// ── File paths ─────────────────────────────────────────────────────
/** Reject codes that would escape the sounds directory or use unsafe chars. */
export function isSafeSoundtrackCode(code: string): boolean {
return /^[a-zA-Z0-9_-]{1,32}$/.test(code)
}
export function getSoundtrackPath(code: string): string {
if (!isSafeSoundtrackCode(code)) {
throw new Error(`Unsafe soundtrack code: ${code}`)
}
return path.join(SOUNDTRACK_DIR, `${code}.mp3`)
}
/** Ensure the sounds directory exists (mkdir -p). */
export async function ensureSoundtrackDir(): Promise<void> {
await fs.mkdir(SOUNDTRACK_DIR, { recursive: true })
}
// ── Validation ─────────────────────────────────────────────────────
/**
* Verify the buffer starts with an MP3 header.
* Accepts:
* - ID3v2 tag header ("ID3")
* - Raw MPEG sync byte 0xFF followed by 0xFA / 0xFB / 0xFE / 0xF3 / 0xF2
* (covers MPEG-1/2 Layer III common variants)
*
* This is a best-effort check — not a full parse — but it rejects arbitrary
* files masquerading as .mp3 (e.g. renamed .exe).
*/
export function validateMp3Bytes(buf: Buffer): boolean {
if (buf.length < 4) return false
// ID3v2 tag header
if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true
// MPEG sync: 0xFF followed by 0xFA/0xFB/0xFE/0xF3/0xF2
if (buf[0] === 0xff) {
const b = buf[1]
if (b === 0xfa || b === 0xfb || b === 0xfe || b === 0xf3 || b === 0xf2) return true
}
return false
}
/**
* Extract MP3 duration in seconds (integer, rounded).
* Returns 0 if metadata cannot be parsed.
*/
export async function extractMp3Duration(buf: Buffer): Promise<number> {
try {
const meta = await parseBuffer(buf, { mimeType: 'audio/mpeg' }, { duration: true })
const seconds = meta.format.duration
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return 0
return Math.round(seconds)
} catch {
return 0
}
}
// ── File I/O ───────────────────────────────────────────────────────
/**
* Write the MP3 payload to disk under the given code.
* Creates the parent directory if missing.
*/
export async function writeSoundtrackFile(code: string, buf: Buffer): Promise<void> {
await ensureSoundtrackDir()
await fs.writeFile(getSoundtrackPath(code), buf)
}
/**
* Remove the MP3 file for the given code. No-op if the file is already gone.
*/
export async function deleteSoundtrackFile(code: string): Promise<void> {
try {
await fs.unlink(getSoundtrackPath(code))
} catch (err) {
const errCode = (err as NodeJS.ErrnoException).code
if (errCode === 'ENOENT') return
throw err
}
}
export type { TraxChannel, TraxEvent } from '@/lib/trax-format'
// Re-export the Trax format parser for server-side use. The actual
// implementation lives in `@/lib/trax-format` so it can be imported by
// client components without pulling in Node-only deps from this file.
export {
detectSoundtrackKind,
parseTrackSamples,
parseTraxChannels,
TRAX_TICK_SECONDS,
} from '@/lib/trax-format'
+6
View File
@@ -0,0 +1,6 @@
/**
* Re-export barrel for backward compatibility.
* All implementation has been moved to the ./swf/ module directory.
*/
export * from './swf'
+304
View File
@@ -0,0 +1,304 @@
import { inflateSync } from 'node:zlib'
import * as jpeg from 'jpeg-js'
export interface SpriteImage {
id: number
name: string
width: number
height: number
pixels: Buffer // RGBA
}
// ── DefineBitsLossless2 Parser (with alpha) ──────────────────────
export function parseBitsLossless2(data: Buffer): {
id: number
width: number
height: number
pixels: Buffer
} {
let offset = 0
const id = data.readUInt16LE(offset)
offset += 2
const format = data.readUInt8(offset)
offset += 1
const width = data.readUInt16LE(offset)
offset += 2
const height = data.readUInt16LE(offset)
offset += 2
let colorTableSize = 0
if (format === 3) {
colorTableSize = data.readUInt8(offset) + 1
offset += 1
}
const compressed = data.subarray(offset)
let decompressed: Buffer
try {
decompressed = inflateSync(compressed)
} catch {
return { id, width, height, pixels: Buffer.alloc(width * height * 4) }
}
const pixels = Buffer.alloc(width * height * 4)
if (format === 5) {
let srcOff = 0
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (srcOff + 3 >= decompressed.length) break
const a = decompressed[srcOff]
let r = decompressed[srcOff + 1]
let g = decompressed[srcOff + 2]
let b = decompressed[srcOff + 3]
srcOff += 4
// Un-premultiply alpha
if (a > 0 && a < 255) {
r = Math.min(255, Math.round((r * 255) / a))
g = Math.min(255, Math.round((g * 255) / a))
b = Math.min(255, Math.round((b * 255) / a))
}
const dstOff = (y * width + x) * 4
pixels[dstOff] = r
pixels[dstOff + 1] = g
pixels[dstOff + 2] = b
pixels[dstOff + 3] = a
}
}
} else if (format === 3) {
const palette: number[][] = []
let palOff = 0
for (let i = 0; i < colorTableSize; i++) {
const a = decompressed[palOff]
let r = decompressed[palOff + 1]
let g = decompressed[palOff + 2]
let b = decompressed[palOff + 3]
// Un-premultiply alpha for palette entries (same as format 5)
if (a > 0 && a < 255) {
r = Math.min(255, Math.round((r * 255) / a))
g = Math.min(255, Math.round((g * 255) / a))
b = Math.min(255, Math.round((b * 255) / a))
}
palette.push([r, g, b, a])
palOff += 4
}
const rowPadded = Math.ceil(width / 4) * 4
let srcOff = colorTableSize * 4
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = decompressed[srcOff + x]
const color = palette[idx] || [0, 0, 0, 0]
const dstOff = (y * width + x) * 4
pixels[dstOff] = color[0]
pixels[dstOff + 1] = color[1]
pixels[dstOff + 2] = color[2]
pixels[dstOff + 3] = color[3]
}
srcOff += rowPadded
}
}
return { id, width, height, pixels }
}
// ── DefineBitsLossless Parser (without alpha) ──────────────────────
export function parseBitsLossless(data: Buffer): {
id: number
width: number
height: number
pixels: Buffer
} {
let offset = 0
const id = data.readUInt16LE(offset)
offset += 2
const format = data.readUInt8(offset)
offset += 1
const width = data.readUInt16LE(offset)
offset += 2
const height = data.readUInt16LE(offset)
offset += 2
let colorTableSize = 0
if (format === 3) {
colorTableSize = data.readUInt8(offset) + 1
offset += 1
}
const compressed = data.subarray(offset)
let decompressed: Buffer
try {
decompressed = inflateSync(compressed)
} catch {
return { id, width, height, pixels: Buffer.alloc(width * height * 4) }
}
const pixels = Buffer.alloc(width * height * 4)
if (format === 5) {
let srcOff = 0
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (srcOff + 3 >= decompressed.length) break
srcOff++ // skip padding byte
const r = decompressed[srcOff++]
const g = decompressed[srcOff++]
const b = decompressed[srcOff++]
const dstOff = (y * width + x) * 4
pixels[dstOff] = r
pixels[dstOff + 1] = g
pixels[dstOff + 2] = b
pixels[dstOff + 3] = 255
}
}
} else if (format === 3) {
const palette: number[][] = []
let palOff = 0
for (let i = 0; i < colorTableSize; i++) {
const r = decompressed[palOff]
const g = decompressed[palOff + 1]
const b = decompressed[palOff + 2]
palette.push([r, g, b, 255])
palOff += 3
}
const rowPadded = Math.ceil(width / 4) * 4
let srcOff = colorTableSize * 3
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = decompressed[srcOff + x]
const color = palette[idx] || [0, 0, 0, 255]
const dstOff = (y * width + x) * 4
pixels[dstOff] = color[0]
pixels[dstOff + 1] = color[1]
pixels[dstOff + 2] = color[2]
pixels[dstOff + 3] = color[3]
}
srcOff += rowPadded
}
}
return { id, width, height, pixels }
}
// ── DefineBitsJpeg2 Parser (JPEG without alpha) ───────────────────
export function parseBitsJpeg2(
data: Buffer,
): { id: number; width: number; height: number; pixels: Buffer } | null {
if (data.length < 6) return null
const id = data.readUInt16LE(0)
let jpegData = data.subarray(2)
// Strip erroneous JPEG header (FF D9 FF D1) if present
if (
jpegData.length >= 4 &&
jpegData[0] === 0xff &&
jpegData[1] === 0xd9 &&
jpegData[2] === 0xff &&
jpegData[3] === 0xd1
) {
jpegData = jpegData.subarray(4)
}
const dims = parseJpegDimensions(jpegData)
if (!dims) return null
const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height)
return { id, width: dims.width, height: dims.height, pixels }
}
// ── DefineBitsJpeg3 Parser (JPEG with alpha channel) ──────────────
export function parseBitsJpeg3(
data: Buffer,
): { id: number; width: number; height: number; pixels: Buffer } | null {
if (data.length < 6) return null
const id = data.readUInt16LE(0)
const alphaDataOffset = data.readUInt32LE(2)
let jpegData = data.subarray(6, 6 + alphaDataOffset)
// Strip erroneous JPEG header (FF D9 FF D1) if present
if (
jpegData.length >= 4 &&
jpegData[0] === 0xff &&
jpegData[1] === 0xd9 &&
jpegData[2] === 0xff &&
jpegData[3] === 0xd1
) {
jpegData = jpegData.subarray(4)
}
const dims = parseJpegDimensions(jpegData)
if (!dims) return null
const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height)
// Apply zlib-compressed alpha channel
const compressedAlpha = data.subarray(6 + alphaDataOffset)
if (compressedAlpha.length > 0) {
try {
const alpha = inflateSync(compressedAlpha)
const pixelCount = dims.width * dims.height
for (let i = 0; i < pixelCount && i < alpha.length; i++) {
pixels[i * 4 + 3] = alpha[i]
}
} catch {
// Alpha decompression failed, keep fully opaque
}
}
return { id, width: dims.width, height: dims.height, pixels }
}
// ── JPEG Dimension Parser (reads SOF0/SOF2 markers) ───────────────
export function parseJpegDimensions(jpeg: Buffer): { width: number; height: number } | null {
let offset = 0
while (offset < jpeg.length - 1) {
if (jpeg[offset] !== 0xff) {
offset++
continue
}
const marker = jpeg[offset + 1]
offset += 2
// SOF0 (Baseline), SOF1 (Extended), SOF2 (Progressive)
if (marker >= 0xc0 && marker <= 0xc2) {
if (offset + 7 > jpeg.length) return null
// Skip length (2 bytes) + precision (1 byte)
const height = jpeg.readUInt16BE(offset + 3)
const width = jpeg.readUInt16BE(offset + 5)
return { width, height }
}
// Skip non-SOF markers with length field
if (marker === 0xd8 || marker === 0xd9) continue // SOI / EOI
if (marker >= 0xd0 && marker <= 0xd7) continue // RST markers
if (offset + 2 > jpeg.length) break
const segLen = jpeg.readUInt16BE(offset)
offset += segLen
}
return null
}
// ── JPEG to RGBA decoder using jpeg-js ──────────────────────────────
export function decodeJpegToRGBA(jpegData: Buffer, width: number, height: number): Buffer {
try {
const decoded = jpeg.decode(jpegData, { useTArray: true, formatAsRGBA: true })
// jpeg-js returns RGBA data directly
return Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength)
} catch {
// Fallback: transparent pixels if JPEG decode fails
return Buffer.alloc(width * height * 4)
}
}
+488
View File
@@ -0,0 +1,488 @@
/**
* SWF to Nitro (.nitro) converter for Habbo furniture assets.
*
* Parses a SWF file, extracts sprites and metadata XML,
* then bundles everything into the .nitro format expected
* by the Nitro HTML5 client.
*/
import { gunzipSync, inflateSync } from 'node:zlib'
import type { BatchConversionItem, BatchConversionResult, ConversionResult } from '@/types/furni'
import {
parseBitsJpeg2,
parseBitsJpeg3,
parseBitsLossless,
parseBitsLossless2,
type SpriteImage,
} from './image-decoder'
import { buildSpritesheet, createNitroBundle, encodePng } from './nitro-builder'
import {
decompressSwf,
parseBinaryData,
parseSwfTags,
parseSymbolClass,
type SymbolClassResult,
TAG_DEFINE_BINARY_DATA,
TAG_DEFINE_BITS_JPEG2,
TAG_DEFINE_BITS_JPEG3,
TAG_DEFINE_BITS_LOSSLESS,
TAG_DEFINE_BITS_LOSSLESS2,
TAG_SYMBOL_CLASS,
} from './swf-parser'
import {
computeDefaultDir,
extractPartColors,
findChild,
findChildren,
parseXml,
processAssetsXml,
processIndex,
processLogic,
processManifest,
processVisualization,
} from './xml-processor'
export type {
BatchConversionItem,
BatchConversionResult,
ConversionResult,
FurniMetadata,
} from '@/types/furni'
// Re-export public API from submodules
export { createNitroBundle, parseNitroBundle } from './nitro-builder'
export { computeDefaultDir, extractPartColors } from './xml-processor'
// ── Main Conversion ────────────────────────────────────────────────
export function convertSwfToNitro(swfBuffer: Buffer, classname: string): ConversionResult {
const warnings: string[] = []
// 1. Decompress SWF
let decompressed: Buffer
try {
decompressed = decompressSwf(swfBuffer)
} catch (err) {
throw new Error(`SWF decompression failed for ${classname}: ${(err as Error).message}`)
}
// 2. Parse tags
const tags = parseSwfTags(decompressed)
if (tags.length === 0) {
throw new Error(`No tags found in SWF for ${classname}`)
}
// 3. Parse SymbolClass to map IDs to names
let symbolResult: SymbolClassResult = { classNames: new Map(), symbols: new Map() }
for (const tag of tags) {
if (tag.type === TAG_SYMBOL_CLASS) {
symbolResult = parseSymbolClass(tag.data)
break
}
}
const { classNames: symbolMap, symbols: allSymbols } = symbolResult
// 4. Extract binary data (XML configs) and images
const binaryData = new Map<string, Buffer>()
const rawImages = new Map<number, { width: number; height: number; pixels: Buffer }>()
for (const tag of tags) {
switch (tag.type) {
case TAG_DEFINE_BINARY_DATA: {
const { id, content } = parseBinaryData(tag.data)
const name = symbolMap.get(id) || `binary_${id}`
binaryData.set(name, content)
break
}
case TAG_DEFINE_BITS_LOSSLESS2: {
const img = parseBitsLossless2(tag.data)
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
break
}
case TAG_DEFINE_BITS_LOSSLESS: {
const img = parseBitsLossless(tag.data)
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
break
}
case TAG_DEFINE_BITS_JPEG2: {
const img = parseBitsJpeg2(tag.data)
if (img && !rawImages.has(img.id)) {
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
}
break
}
case TAG_DEFINE_BITS_JPEG3: {
const img = parseBitsJpeg3(tag.data)
if (img && !rawImages.has(img.id)) {
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
}
break
}
}
}
// 5. Strip the SymbolClass package prefix
const prefix = `${classname}_`
function stripPrefix(name: string): string {
if (name.startsWith(prefix)) return name.substring(prefix.length)
if (name.includes('.')) return name.split('.').pop() || name
return name
}
// 6. Parse XML data from binary tags
let assetsXml = ''
let indexXml = ''
let logicXml = ''
let visualizationXml = ''
let manifestXml = ''
for (const [rawName, content] of binaryData) {
let xmlStr = content.toString('utf-8').trim()
xmlStr = xmlStr.replace(/encoding="[Ii][Ss][Oo]-8859-1"/g, 'encoding="UTF-8"')
const name = stripPrefix(rawName).toLowerCase()
if (name.includes('manifest') || name.endsWith('_manifest')) {
manifestXml = xmlStr
} else if (name.includes('assets') || name.endsWith('_assets')) {
assetsXml = xmlStr
} else if (name === 'index' || name.endsWith('_index')) {
indexXml = xmlStr
} else if (name.includes('logic') || name.endsWith('_logic')) {
logicXml = xmlStr
} else if (name.includes('visualization') || name.endsWith('_visualization')) {
visualizationXml = xmlStr
}
}
if (!assetsXml) warnings.push('No assets XML found in SWF')
if (!logicXml) warnings.push('No logic XML found in SWF')
if (!visualizationXml) warnings.push('No visualization XML found in SWF')
// Build set of needed sprites from assets.xml
const neededSprites = new Set<string>()
if (assetsXml) {
const assetsRoot = parseXml(assetsXml)
if (assetsRoot) {
const assetsNode =
assetsRoot.tag === 'assets' ? assetsRoot : findChild(assetsRoot, 'assets') || assetsRoot
for (const asset of findChildren(assetsNode, 'asset')) {
const assetName = asset.attrs.name || ''
if (!assetName || assetName.startsWith('sh_') || assetName.includes('_32_')) continue
if (!asset.attrs.source) {
neededSprites.add(assetName)
} else {
neededSprites.add(asset.attrs.source)
}
}
}
}
// Map images to named sprites
const namedImages: SpriteImage[] = []
for (const [id, imgData] of rawImages) {
const rawName = symbolMap.get(id)
if (!rawName) continue
const name = stripPrefix(rawName)
if (name.startsWith('sh_') || name.includes('_32_')) continue
if (neededSprites.size > 0 && !neededSprites.has(name)) continue
namedImages.push({
id,
name,
width: imgData.width,
height: imgData.height,
pixels: imgData.pixels,
})
}
namedImages.sort((a, b) => a.name.localeCompare(b.name))
// 7. Build spritesheet using bin-packing
const { png, frames, width: sheetWidth, height: sheetHeight } = buildSpritesheet(namedImages)
// 8. Process XML into Nitro JSON structures
const { palettes } = processAssetsXml(assetsXml)
const manifest = processManifest(manifestXml)
const indexData = processIndex(indexXml)
const logic = processLogic(logicXml)
const visualizations = processVisualization(visualizationXml)
// Build IMAGE_SOURCES map
const imageSources = new Map<string, string>()
for (const [symbolName, charId] of allSymbols) {
if (!rawImages.has(charId)) continue
const canonicalName = symbolMap.get(charId)
if (!canonicalName) continue
if (symbolName !== canonicalName) {
imageSources.set(stripPrefix(symbolName), stripPrefix(canonicalName))
}
}
// Build assets map
const assets: Record<
string,
{
x: number
y: number
source?: string
flipH?: boolean
flipV?: boolean
usesPalette?: boolean
}
> = {}
for (const ma of manifest.assets) {
assets[ma.name] = { x: ma.x, y: ma.y }
}
if (assetsXml) {
const root = parseXml(assetsXml)
if (root) {
const assetsNode = root.tag === 'assets' ? root : findChild(root, 'assets') || root
for (const asset of findChildren(assetsNode, 'asset')) {
const assetName = asset.attrs.name || ''
if (!assetName) continue
if (assetName.startsWith('sh_') || assetName.includes('_32_')) continue
const x = parseInt(asset.attrs.x || '0', 10)
const y = parseInt(asset.attrs.y || '0', 10)
let source = asset.attrs.source || ''
if (source && imageSources.has(source)) {
source = imageSources.get(source)!
}
if (imageSources.has(assetName)) {
source = imageSources.get(assetName)!
}
const entry: (typeof assets)[string] = source
? ({ source, x, y } as (typeof assets)[string])
: { x, y }
if (asset.attrs.flipH === '1') {
entry.flipH = true
}
if (asset.attrs.flipV === '1') {
entry.flipV = true
}
if (asset.attrs.usesPalette === '1') {
entry.usesPalette = true
}
assets[assetName] = entry
}
}
}
if (Object.keys(assets).length === 0) {
for (const img of namedImages) {
assets[img.name] = { x: 0, y: 0 }
}
}
const validSpriteNames = new Set(namedImages.map((img) => img.name))
for (const key of Object.keys(assets)) {
if (!validSpriteNames.has(key) && !assets[key].source) {
delete assets[key]
}
}
const sortedAssets: typeof assets = {}
for (const key of Object.keys(assets).sort()) {
sortedAssets[key] = assets[key]
}
// Build spritesheet frames
const spritesheetFrames: Record<string, unknown> = {}
for (const img of namedImages) {
const frameInfo = frames.get(img.name)
if (!frameInfo) continue
const frameKey = `${classname}_${img.name}`
spritesheetFrames[frameKey] = {
frame: { x: frameInfo.x, y: frameInfo.y, w: frameInfo.w, h: frameInfo.h },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: frameInfo.w, h: frameInfo.h },
sourceSize: { w: frameInfo.w, h: frameInfo.h },
pivot: { x: 0.5, y: 0.5 },
}
}
// Determine visualization/logic types
let visualizationType = indexData?.visualizationType || 'furniture_static'
let logicType = indexData?.logicType || 'furniture_basic'
if (!indexData) {
if (visualizations.some((v) => v.animations && Object.keys(v.animations).length > 0)) {
visualizationType = 'furniture_animated'
logicType = 'furniture_multistate'
}
}
// 9. Build final JSON
const logicObj: Record<string, unknown> = {
model: {
dimensions: logic.dimensions,
directions: logic.directions,
},
}
if (logic.maskType) logicObj.maskType = logic.maskType
if (logic.action) logicObj.action = logic.action
if (logic.credits) logicObj.credits = logic.credits
if (logic.soundSample) logicObj.soundSample = logic.soundSample
if (logic.customVars) logicObj.customVars = logic.customVars
if (logic.particleSystems) logicObj.particleSystems = logic.particleSystems
if (logic.planetSystems) logicObj.planetSystems = logic.planetSystems
const nitroJson: Record<string, unknown> = {
name: classname,
visualizationType,
logicType,
spritesheet: {
meta: {
image: `${classname}.png`,
format: 'RGBA8888',
size: { w: sheetWidth, h: sheetHeight },
scale: 1,
},
frames: spritesheetFrames,
},
assets: sortedAssets,
}
if (Object.keys(palettes).length > 0) {
nitroJson.palettes = palettes
}
nitroJson.visualizations = visualizations
nitroJson.logic = logicObj
// 10. Create .nitro bundle
const bundle = createNitroBundle(nitroJson, png, classname)
return {
bundle,
classname,
dimensions: logic.dimensions,
directions: logic.directions,
spriteCount: namedImages.length,
warnings,
metadata: {
visualizationType,
logicType,
canstandon: logic.canstandon,
cansiton: logic.cansiton,
canlayon: logic.canlayon,
specialtype: indexData?.specialtype ?? 0,
defaultdir: computeDefaultDir(logic.directions),
partcolors: extractPartColors(visualizations),
},
}
}
// ── Icon Extraction from SWF ────────────────────────────────────────
export function extractIconFromSwf(swfBuffer: Buffer, classname: string): Buffer | null {
try {
const decompressed = decompressSwf(swfBuffer)
const tags = parseSwfTags(decompressed)
let iconSymbolMap = new Map<number, string>()
for (const tag of tags) {
if (tag.type === TAG_SYMBOL_CLASS) {
const result = parseSymbolClass(tag.data)
iconSymbolMap = result.classNames
break
}
}
const prefix = `${classname}_`
const candidates: { name: string; width: number; height: number; pixels: Buffer }[] = []
for (const tag of tags) {
let img: { id: number; width: number; height: number; pixels: Buffer } | null = null
if (tag.type === TAG_DEFINE_BITS_LOSSLESS2) img = parseBitsLossless2(tag.data)
else if (tag.type === TAG_DEFINE_BITS_LOSSLESS) img = parseBitsLossless(tag.data)
else if (tag.type === TAG_DEFINE_BITS_JPEG2) img = parseBitsJpeg2(tag.data)
else if (tag.type === TAG_DEFINE_BITS_JPEG3) img = parseBitsJpeg3(tag.data)
if (!img) continue
const rawName = iconSymbolMap.get(img.id) || ''
const name = rawName.startsWith(prefix) ? rawName.substring(prefix.length) : rawName
if (!name || name.startsWith('sh_')) continue
if (name.includes('icon') || /^1_[a-z]_\d+_\d+$/.test(name)) {
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels })
}
}
if (candidates.length === 0) {
for (const tag of tags) {
let img: { id: number; width: number; height: number; pixels: Buffer } | null = null
if (tag.type === TAG_DEFINE_BITS_LOSSLESS2) img = parseBitsLossless2(tag.data)
else if (tag.type === TAG_DEFINE_BITS_LOSSLESS) img = parseBitsLossless(tag.data)
if (!img || img.width < 2 || img.height < 2) continue
const rawName = iconSymbolMap.get(img.id) || ''
const name = rawName.startsWith(prefix) ? rawName.substring(prefix.length) : rawName
if (name.startsWith('sh_')) continue
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels })
}
}
if (candidates.length === 0) return null
candidates.sort((a, b) => a.width * a.height - b.width * b.height)
const icon = candidates[0]
return encodePng(icon.width, icon.height, icon.pixels)
} catch {
return null
}
}
// ── Backward-compatible wrapper ────────────────────────────────────
export function convertSwfToNitroBuffer(swfBuffer: Buffer, classname: string): Buffer {
return convertSwfToNitro(swfBuffer, classname).bundle
}
// ── Batch Conversion ───────────────────────────────────────────────
export function convertSwfToNitroBatch(items: BatchConversionItem[]): BatchConversionResult[] {
return items.map((item) => {
try {
const result = convertSwfToNitro(item.swfBuffer, item.classname)
return {
classname: item.classname,
success: true,
bundle: result.bundle,
dimensions: result.dimensions,
directions: result.directions,
spriteCount: result.spriteCount,
warnings: result.warnings,
}
} catch (err) {
return {
classname: item.classname,
success: false,
warnings: [],
error: (err as Error).message,
}
}
})
}
// ── Utility: Try to decompress nitro-style data (zlib or gzip) ─────
export function nitroDecompress(data: Buffer): Buffer {
try {
return inflateSync(data)
} catch {
try {
return gunzipSync(data)
} catch {
return data
}
}
}
+261
View File
@@ -0,0 +1,261 @@
import { deflateSync, inflateSync } from 'node:zlib'
import type { SpriteImage } from './image-decoder'
// ── Minimal PNG Encoder ────────────────────────────────────────────
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[n] = c
}
return table
})()
function crc32(data: Buffer): number {
let crc = 0xffffffff
for (let i = 0; i < data.length; i++) {
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
function pngChunk(type: string, data: Buffer): Buffer {
const buf = Buffer.alloc(4 + 4 + data.length + 4)
buf.writeUInt32BE(data.length, 0)
buf.write(type, 4, 4, 'ascii')
data.copy(buf, 8)
const crc = crc32(buf.subarray(4, 8 + data.length))
buf.writeUInt32BE(crc >>> 0, 8 + data.length)
return buf
}
export function encodePng(width: number, height: number, rgba: Buffer): Buffer {
const rawData = Buffer.alloc(height * (1 + width * 4))
for (let y = 0; y < height; y++) {
const rowStart = y * (1 + width * 4)
rawData[rowStart] = 0 // filter: None
rgba.copy(rawData, rowStart + 1, y * width * 4, (y + 1) * width * 4)
}
const compressed = deflateSync(rawData)
const chunks: Buffer[] = []
// Signature
chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
// IHDR
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type: RGBA
ihdr[10] = 0 // compression
ihdr[11] = 0 // filter
ihdr[12] = 0 // interlace
chunks.push(pngChunk('IHDR', ihdr))
// IDAT
chunks.push(pngChunk('IDAT', compressed))
// IEND
chunks.push(pngChunk('IEND', Buffer.alloc(0)))
return Buffer.concat(chunks)
}
// ── Bin-Packing Spritesheet Builder ────────────────────────────────
interface PackedRect {
x: number
y: number
w: number
h: number
name: string
}
function nextPow2(v: number): number {
v--
v |= v >> 1
v |= v >> 2
v |= v >> 4
v |= v >> 8
v |= v >> 16
return v + 1
}
/**
* Simple shelf-based bin packing algorithm.
* Sorts sprites by height descending, then packs left-to-right in rows.
* Produces much tighter spritesheets than simple vertical stacking.
*/
function binPack(images: SpriteImage[]): { rects: PackedRect[]; width: number; height: number } {
if (images.length === 0) {
return { rects: [], width: 1, height: 1 }
}
// Sort by height descending for better shelf packing
const sorted = [...images].sort((a, b) => b.height - a.height || b.width - a.width)
// Estimate initial atlas width based on total area
const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0)
const maxSingleWidth = Math.max(...sorted.map((i) => i.width))
let atlasWidth = Math.max(maxSingleWidth, Math.ceil(Math.sqrt(totalArea)))
// Round up to power of 2 for GPU friendliness (max 2048)
atlasWidth = Math.min(2048, nextPow2(atlasWidth))
const rects: PackedRect[] = []
let shelfY = 0
let shelfHeight = 0
let shelfX = 0
for (const img of sorted) {
// If sprite doesn't fit on current shelf, start new shelf
if (shelfX + img.width > atlasWidth) {
shelfY += shelfHeight
shelfHeight = 0
shelfX = 0
}
rects.push({ x: shelfX, y: shelfY, w: img.width, h: img.height, name: img.name })
shelfX += img.width
shelfHeight = Math.max(shelfHeight, img.height)
}
const finalHeight = shelfY + shelfHeight
return { rects, width: atlasWidth, height: finalHeight || 1 }
}
// ── Spritesheet Builder ────────────────────────────────────────────
interface FrameInfo {
x: number
y: number
w: number
h: number
}
export function buildSpritesheet(images: SpriteImage[]): {
png: Buffer
frames: Map<string, FrameInfo>
width: number
height: number
} {
if (images.length === 0) {
const emptyPng = encodePng(1, 1, Buffer.alloc(4))
return { png: emptyPng, frames: new Map(), width: 1, height: 1 }
}
const { rects, width: sheetWidth, height: sheetHeight } = binPack(images)
// Build lookup by name for pixel data
const imageByName = new Map(images.map((img) => [img.name, img]))
const combined = Buffer.alloc(sheetWidth * sheetHeight * 4)
const frames = new Map<string, FrameInfo>()
for (const rect of rects) {
const img = imageByName.get(rect.name)
if (!img) continue
// Copy pixels row by row
for (let y = 0; y < img.height; y++) {
const srcStart = y * img.width * 4
const dstStart = ((rect.y + y) * sheetWidth + rect.x) * 4
img.pixels.copy(combined, dstStart, srcStart, srcStart + img.width * 4)
}
frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h })
}
const png = encodePng(sheetWidth, sheetHeight, combined)
return { png, frames, width: sheetWidth, height: sheetHeight }
}
// ── .nitro Bundle Creator ──────────────────────────────────────────
export function createNitroBundle(jsonData: object, pngData: Buffer, name: string): Buffer {
const jsonStr = JSON.stringify(jsonData)
const jsonCompressed = deflateSync(Buffer.from(jsonStr, 'utf-8'))
const pngCompressed = deflateSync(pngData)
const jsonFileName = `${name}.json`
const pngFileName = `${name}.png`
const totalSize =
2 +
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
(2 + pngFileName.length + 4 + pngCompressed.length)
const bundle = Buffer.alloc(totalSize)
let offset = 0
bundle.writeInt16BE(2, offset)
offset += 2
bundle.writeInt16BE(jsonFileName.length, offset)
offset += 2
bundle.write(jsonFileName, offset, jsonFileName.length, 'utf-8')
offset += jsonFileName.length
bundle.writeInt32BE(jsonCompressed.length, offset)
offset += 4
jsonCompressed.copy(bundle, offset)
offset += jsonCompressed.length
bundle.writeInt16BE(pngFileName.length, offset)
offset += 2
bundle.write(pngFileName, offset, pngFileName.length, 'utf-8')
offset += pngFileName.length
bundle.writeInt32BE(pngCompressed.length, offset)
offset += 4
pngCompressed.copy(bundle, offset)
return bundle
}
// ── .nitro Bundle Parser ──────────────────────────────────────────
export function parseNitroBundle(buffer: Buffer): {
json: Record<string, unknown>
jsonFileName: string
png: Buffer
pngFileName: string
} {
let offset = 0
const fileCount = buffer.readInt16BE(offset)
offset += 2
if (fileCount < 2) {
throw new Error(`Expected at least 2 files in .nitro bundle, got ${fileCount}`)
}
// File 1: JSON
const jsonFileNameLen = buffer.readInt16BE(offset)
offset += 2
const jsonFileName = buffer.toString('utf-8', offset, offset + jsonFileNameLen)
offset += jsonFileNameLen
const jsonCompressedLen = buffer.readInt32BE(offset)
offset += 4
const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen)
offset += jsonCompressedLen
const jsonStr = inflateSync(jsonCompressed).toString('utf-8')
const json = JSON.parse(jsonStr)
// File 2: PNG
const pngFileNameLen = buffer.readInt16BE(offset)
offset += 2
const pngFileName = buffer.toString('utf-8', offset, offset + pngFileNameLen)
offset += pngFileNameLen
const pngCompressedLen = buffer.readInt32BE(offset)
offset += 4
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen)
offset += pngCompressedLen
const png = inflateSync(pngCompressed)
return { json, jsonFileName, png, pngFileName }
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Low-level SWF file parsing: decompression, tag reading, symbol class.
*/
import { inflateSync } from 'node:zlib'
import LZMA from 'lzma'
// ── SWF Tag Types ──────────────────────────────────────────────────
export const TAG_END = 0
export const TAG_DEFINE_BITS_LOSSLESS = 20
export const TAG_DEFINE_BITS_JPEG2 = 21
export const TAG_DEFINE_BITS_JPEG3 = 35
export const TAG_DEFINE_BITS_LOSSLESS2 = 36
export const TAG_SYMBOL_CLASS = 76
export const TAG_DEFINE_BINARY_DATA = 87
// ── Interfaces ─────────────────────────────────────────────────────
export interface SwfTag {
type: number
data: Buffer
}
export interface SymbolClassResult {
/** charID → first symbol name (canonical name) */
classNames: Map<number, string>
/** symbol name → charID (all symbols including aliases) */
symbols: Map<string, number>
}
// ── SWF Header Parsing ─────────────────────────────────────────────
export function decompressSwf(buffer: Buffer): Buffer {
const sig = buffer.toString('ascii', 0, 3)
if (sig === 'FWS') {
return buffer
}
if (sig === 'CWS') {
const header = Buffer.alloc(8)
buffer.copy(header, 0, 0, 8)
header.write('FWS', 0, 3, 'ascii')
const compressed = buffer.subarray(8)
const decompressed = inflateSync(compressed)
return Buffer.concat([header, decompressed])
}
if (sig === 'ZWS') {
const fileLength = buffer.readUInt32LE(4)
const compressedSize = buffer.readUInt32LE(8)
const lzmaData = buffer.subarray(12, 12 + compressedSize)
const props = lzmaData.subarray(0, 5)
const compressedPayload = lzmaData.subarray(5)
const uncompressedSize = fileLength - 8
const lzmaHeader = Buffer.alloc(13)
props.copy(lzmaHeader, 0)
lzmaHeader.writeUInt32LE(uncompressedSize, 5)
lzmaHeader.writeUInt32LE(0, 9)
const lzmaStream = Buffer.concat([lzmaHeader, compressedPayload])
const decompressed = Buffer.from(LZMA.decompress(lzmaStream) as number[])
const header = Buffer.alloc(8)
buffer.copy(header, 0, 0, 8)
header.write('FWS', 0, 3, 'ascii')
return Buffer.concat([header, decompressed])
}
throw new Error(`Unknown SWF signature: ${sig}`)
}
function readRect(buf: Buffer, offset: number): number {
const nbits = (buf[offset] >> 3) & 0x1f
const totalBits = 5 + nbits * 4
const totalBytes = Math.ceil(totalBits / 8)
return offset + totalBytes
}
// ── SWF Tag Reader ─────────────────────────────────────────────────
export function parseSwfTags(swfBuffer: Buffer): SwfTag[] {
const tags: SwfTag[] = []
let offset = 8
offset = readRect(swfBuffer, offset)
offset += 4
while (offset < swfBuffer.length) {
if (offset + 2 > swfBuffer.length) break
const tagCodeAndLength = swfBuffer.readUInt16LE(offset)
offset += 2
const tagType = (tagCodeAndLength >> 6) & 0x3ff
let tagLength = tagCodeAndLength & 0x3f
if (tagLength === 0x3f) {
if (offset + 4 > swfBuffer.length) break
tagLength = swfBuffer.readUInt32LE(offset)
offset += 4
}
if (tagType === TAG_END) break
if (offset + tagLength > swfBuffer.length) break
const tagData = swfBuffer.subarray(offset, offset + tagLength)
tags.push({ type: tagType, data: Buffer.from(tagData) })
offset += tagLength
}
return tags
}
// ── SymbolClass Parser ─────────────────────────────────────────────
export function parseSymbolClass(data: Buffer): SymbolClassResult {
const classNames = new Map<number, string>()
const symbols = new Map<string, number>()
let offset = 0
const numSymbols = data.readUInt16LE(offset)
offset += 2
for (let i = 0; i < numSymbols; i++) {
if (offset + 2 > data.length) break
const charId = data.readUInt16LE(offset)
offset += 2
const strStart = offset
while (offset < data.length && data[offset] !== 0) offset++
const name = data.toString('utf-8', strStart, offset)
offset++
symbols.set(name, charId)
if (!classNames.has(charId)) {
classNames.set(charId, name)
}
}
return { classNames, symbols }
}
// ── DefineBinaryData Parser ────────────────────────────────────────
export function parseBinaryData(data: Buffer): { id: number; content: Buffer } {
const id = data.readUInt16LE(0)
const content = data.subarray(6)
return { id, content }
}
+582
View File
@@ -0,0 +1,582 @@
/**
* XML parser and XML data processor functions for SWF-to-Nitro conversion.
*
* Pure string/data processing — no external imports needed.
*/
// ── Interfaces ─────────────────────────────────────────────────────
export interface AssetOffset {
x: number
y: number
}
export interface NitroVisualization {
layerCount: number
angle: number
size: number
layers?: Record<string, Record<string, unknown>>
directions?: Record<string, Record<string, unknown>>
colors?: Record<string, unknown>
animations?: Record<string, unknown>
postures?: { defaultPosture?: string; postures?: { id: string; animationId: number }[] }
gestures?: { id: string; animationId: number }[]
}
// ── Minimal XML Parser ─────────────────────────────────────────────
export interface XmlNode {
tag: string
attrs: Record<string, string>
children: XmlNode[]
text: string
}
export function parseXml(xml: string): XmlNode | null {
const stack: XmlNode[] = []
const nodes: XmlNode[] = []
// Remove XML declaration and comments
const clean = xml
.replace(/<\?[^?]*\?>/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.trim()
const tagRe = /<\/?([a-zA-Z_][\w.-]*)((?:\s+[a-zA-Z_][\w.-]*\s*=\s*"[^"]*")*)\s*\/?>/g
let lastIndex = 0
for (const match of clean.matchAll(tagRe)) {
const fullMatch = match[0]
const tagName = match[1]
const attrsStr = match[2] || ''
const matchIndex = match.index ?? 0
const text = clean.substring(lastIndex, matchIndex).trim()
if (text && stack.length > 0) {
stack[stack.length - 1].text += text
}
lastIndex = matchIndex + fullMatch.length
if (fullMatch.startsWith('</')) {
if (stack.length > 0) {
const node = stack.pop()!
if (stack.length > 0) {
stack[stack.length - 1].children.push(node)
} else {
nodes.push(node)
}
}
} else {
const attrs: Record<string, string> = {}
const attrRe = /([a-zA-Z_][\w.-]*)\s*=\s*"([^"]*)"/g
for (const am of attrsStr.matchAll(attrRe)) {
attrs[am[1]] = am[2]
}
const node: XmlNode = { tag: tagName, attrs, children: [], text: '' }
if (fullMatch.endsWith('/>')) {
if (stack.length > 0) {
stack[stack.length - 1].children.push(node)
} else {
nodes.push(node)
}
} else {
stack.push(node)
}
}
}
while (stack.length > 1) {
const node = stack.pop()!
stack[stack.length - 1].children.push(node)
}
if (stack.length === 1) nodes.push(stack[0])
return nodes[0] || null
}
export function findChild(node: XmlNode, tag: string): XmlNode | undefined {
return node.children.find((c) => c.tag === tag)
}
export function findChildren(node: XmlNode, tag: string): XmlNode[] {
return node.children.filter((c) => c.tag === tag)
}
// ── XML Data Processors ────────────────────────────────────────────
export interface ManifestAsset {
name: string
x: number
y: number
}
export function processManifest(xml: string): { assets: ManifestAsset[] } {
const assets: ManifestAsset[] = []
const root = parseXml(xml)
if (!root) return { assets }
const libNode = root.tag === 'manifest' ? findChild(root, 'library') : root
if (!libNode) return { assets }
const assetsNode = findChild(libNode, 'assets')
if (!assetsNode) return { assets }
for (const asset of findChildren(assetsNode, 'asset')) {
const name = asset.attrs.name || ''
if (!name) continue
// Skip shadow sprites
if (name.startsWith('sh_')) continue
let x = 0,
y = 0
const paramNode = findChild(asset, 'param')
if (paramNode && paramNode.attrs.key === 'offset') {
const parts = (paramNode.attrs.value || '').split(',')
x = parseInt(parts[0] || '0', 10)
y = parseInt(parts[1] || '0', 10)
}
assets.push({ name, x, y })
}
return { assets }
}
export interface PaletteEntry {
id: number
source?: string
master?: boolean
tags?: string[]
breed?: number
colorTag?: number
color1?: string
color2?: string
}
export function processAssetsXml(xml: string): {
offsets: Map<string, AssetOffset>
palettes: Record<string, PaletteEntry>
} {
const offsets = new Map<string, AssetOffset>()
const palettes: Record<string, PaletteEntry> = {}
const root = parseXml(xml)
if (!root) return { offsets, palettes }
const assetsNode = root.tag === 'assets' ? root : findChild(root, 'assets') || root
for (const asset of findChildren(assetsNode, 'asset')) {
const name = asset.attrs.name || ''
if (!name) continue
const x = parseInt(asset.attrs.x || '0', 10)
const y = parseInt(asset.attrs.y || '0', 10)
offsets.set(name, { x, y })
}
// Parse palettes
for (const pal of findChildren(assetsNode, 'palette')) {
const id = parseInt(pal.attrs.id || '0', 10)
const entry: PaletteEntry = { id }
if (pal.attrs.source) entry.source = pal.attrs.source
if (pal.attrs.master === '1' || pal.attrs.master === 'true') entry.master = true
if (pal.attrs.tags) entry.tags = pal.attrs.tags.split(',')
if (pal.attrs.breed) entry.breed = parseInt(pal.attrs.breed, 10)
if (pal.attrs.colorTag) entry.colorTag = parseInt(pal.attrs.colorTag, 10)
if (pal.attrs.color1) entry.color1 = pal.attrs.color1
if (pal.attrs.color2) entry.color2 = pal.attrs.color2
palettes[String(id)] = entry
}
return { offsets, palettes }
}
export function processIndex(
xml: string,
): { visualizationType: string; logicType: string; specialtype: number } | null {
const root = parseXml(xml)
if (!root) return null
const obj = root.tag === 'object' ? root : findChild(root, 'object')
if (!obj) return null
return {
visualizationType: obj.attrs.visualization || '',
logicType: obj.attrs.logic || '',
specialtype: parseInt(obj.attrs.specialtype || '0', 10),
}
}
export interface LogicResult {
dimensions: { x: number; y: number; z: number; centerZ?: number }
directions: number[]
maskType: string
canstandon: boolean
cansiton: boolean
canlayon: boolean
action?: { link?: string; startState?: number }
credits?: string
soundSample?: { id: number; noPitch?: boolean }
customVars?: { variables: string[] }
particleSystems?: Record<string, unknown>[]
planetSystems?: Record<string, unknown>[]
}
export function processLogic(xml: string): LogicResult {
const defaults: LogicResult = {
dimensions: { x: 1, y: 1, z: 0 },
directions: [0],
maskType: '',
canstandon: false,
cansiton: false,
canlayon: false,
}
const root = parseXml(xml)
if (!root) return defaults
const model = findChild(root, 'model') || root
const dims = findChild(model, 'dimensions')
const dirsNode = findChild(model, 'directions')
const dimensions: LogicResult['dimensions'] = {
x: parseInt(dims?.attrs.x || '1', 10),
y: parseInt(dims?.attrs.y || '1', 10),
z: parseFloat(dims?.attrs.z || '0'),
}
if (dims?.attrs.centerZ) {
dimensions.centerZ = parseFloat(dims.attrs.centerZ)
}
const directions: number[] = []
if (dirsNode) {
for (const d of findChildren(dirsNode, 'direction')) {
directions.push(parseInt(d.attrs.id || '0', 10))
}
}
// Default directions [0, 90] when missing (matches Retrosprite behavior)
if (directions.length === 0) directions.push(0, 90)
// Parse mask type
const maskNode = findChild(root, 'mask')
const maskType = maskNode?.attrs.type || ''
// Infer canstandon/cansiton/canlayon from action nodes
const actions = findChildren(root, 'action')
const cansiton = actions.some((a) => a.attrs.kind === 'sit')
const canlayon = actions.some((a) => a.attrs.kind === 'lay')
const canstandon = !cansiton && !canlayon && dimensions.z > 0
const result: LogicResult = { dimensions, directions, maskType, canstandon, cansiton, canlayon }
// Parse action link/startState
const actionNode = findChild(root, 'action')
if (actionNode && (actionNode.attrs.link || actionNode.attrs.startState)) {
const action: LogicResult['action'] = {}
if (actionNode.attrs.link) action.link = actionNode.attrs.link
if (actionNode.attrs.startState) action.startState = parseInt(actionNode.attrs.startState, 10)
result.action = action
}
// Parse credits
const creditsNode = findChild(root, 'credits')
if (creditsNode?.attrs.value) {
result.credits = creditsNode.attrs.value
}
// Parse soundSample
const soundNode = findChild(root, 'sound')
if (soundNode) {
const sampleNode = findChild(soundNode, 'sample')
if (sampleNode) {
const sample: LogicResult['soundSample'] = { id: parseInt(sampleNode.attrs.id || '0', 10) }
if (sampleNode.attrs.noPitch === '1') sample.noPitch = true
result.soundSample = sample
}
}
// Parse customVars
const customVarsNode = findChild(root, 'customVars')
if (customVarsNode) {
const variables: string[] = []
for (const v of findChildren(customVarsNode, 'variable')) {
if (v.attrs.name) variables.push(v.attrs.name)
}
if (variables.length > 0) result.customVars = { variables }
}
// Parse particleSystems
const particleNode = findChild(root, 'particleSystems')
if (particleNode) {
const systems: Record<string, unknown>[] = []
for (const ps of findChildren(particleNode, 'particleSystem')) {
const system: Record<string, unknown> = {}
if (ps.attrs.size) system.size = parseInt(ps.attrs.size, 10)
if (ps.attrs.canvasId) system.canvasId = parseInt(ps.attrs.canvasId, 10)
if (ps.attrs.offsetY) system.offsetY = parseInt(ps.attrs.offsetY, 10)
if (ps.attrs.blend) system.blend = parseInt(ps.attrs.blend, 10)
if (ps.attrs.bgColor) system.bgColor = ps.attrs.bgColor
const emitters: Record<string, unknown>[] = []
for (const em of findChildren(ps, 'emitter')) {
const emitter: Record<string, unknown> = {}
if (em.attrs.id) emitter.id = parseInt(em.attrs.id, 10)
if (em.attrs.name) emitter.name = em.attrs.name
if (em.attrs.spriteId) emitter.spriteId = parseInt(em.attrs.spriteId, 10)
if (em.attrs.maxNumParticles)
emitter.maxNumParticles = parseInt(em.attrs.maxNumParticles, 10)
if (em.attrs.particlesPerFrame)
emitter.particlesPerFrame = parseInt(em.attrs.particlesPerFrame, 10)
if (em.attrs.burstPulse) emitter.burstPulse = parseInt(em.attrs.burstPulse, 10)
if (em.attrs.fuseTime) emitter.fuseTime = parseInt(em.attrs.fuseTime, 10)
const simNode = findChild(em, 'simulation')
if (simNode) {
const simulation: Record<string, unknown> = {}
if (simNode.attrs.force) simulation.force = parseFloat(simNode.attrs.force)
if (simNode.attrs.direction) simulation.direction = parseFloat(simNode.attrs.direction)
if (simNode.attrs.gravity) simulation.gravity = parseFloat(simNode.attrs.gravity)
if (simNode.attrs.airFriction)
simulation.airFriction = parseFloat(simNode.attrs.airFriction)
if (simNode.attrs.shape) simulation.shape = simNode.attrs.shape
if (simNode.attrs.energy) simulation.energy = parseFloat(simNode.attrs.energy)
emitter.simulation = simulation
}
const particles: Record<string, unknown>[] = []
for (const p of findChildren(em, 'particle')) {
const particle: Record<string, unknown> = {}
if (p.attrs.isEmitter === '1') particle.isEmitter = true
if (p.attrs.lifeTime) particle.lifeTime = parseInt(p.attrs.lifeTime, 10)
if (p.attrs.fade === '1') particle.fade = true
const pFrames: string[] = []
for (const pf of findChildren(p, 'frame')) {
if (pf.attrs.name) pFrames.push(pf.attrs.name)
}
if (pFrames.length > 0) particle.frames = pFrames
particles.push(particle)
}
if (particles.length > 0) emitter.particles = particles
emitters.push(emitter)
}
if (emitters.length > 0) system.emitters = emitters
systems.push(system)
}
if (systems.length > 0) result.particleSystems = systems
}
// Parse planetSystems
const planetNode = findChild(root, 'planetSystems')
if (planetNode) {
const systems: Record<string, unknown>[] = []
for (const ps of planetNode.children) {
const system: Record<string, unknown> = { ...ps.attrs }
// Convert numeric attrs
for (const key of Object.keys(system)) {
const val = system[key] as string
if (/^-?\d+(\.\d+)?$/.test(val)) system[key] = parseFloat(val)
}
systems.push(system)
}
if (systems.length > 0) result.planetSystems = systems
}
return result
}
export function processVisualization(xml: string): NitroVisualization[] {
const result: NitroVisualization[] = []
const root = parseXml(xml)
if (!root) return result
const graphics = findChild(root, 'graphics') || root
for (const viz of findChildren(graphics, 'visualization')) {
const size = parseInt(viz.attrs.size || '64', 10)
// Skip size 32 (small catalog icons) – only keep size 1 (special) and 64 (standard)
if (size === 32) continue
const layerCount = parseInt(viz.attrs.layerCount || '1', 10)
const angle = parseInt(viz.attrs.angle || '45', 10)
const entry: NitroVisualization = { layerCount, angle, size }
// Layers (before directions to match reference key order)
const layersNode = findChild(viz, 'layers')
if (layersNode) {
const layers: Record<string, Record<string, unknown>> = {}
for (const l of findChildren(layersNode, 'layer')) {
const id = l.attrs.id || '0'
const layerObj: Record<string, unknown> = {}
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10)
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10)
if (l.attrs.z) layerObj.z = parseInt(l.attrs.z, 10)
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10)
if (l.attrs.ink) layerObj.ink = l.attrs.ink
if (l.attrs.tag) layerObj.tag = l.attrs.tag
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === '1'
layers[id] = layerObj
}
if (Object.keys(layers).length > 0) entry.layers = layers
}
// Directions
const dirsNode = findChild(viz, 'directions')
if (dirsNode) {
const dirs: Record<string, Record<string, unknown>> = {}
for (const d of findChildren(dirsNode, 'direction')) {
const id = d.attrs.id || '0'
const layersInDir = findChildren(d, 'layer')
if (layersInDir.length > 0) {
const layerData: Record<string, Record<string, unknown>> = {}
for (const l of layersInDir) {
const lid = l.attrs.id || '0'
const layerObj: Record<string, unknown> = {}
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10)
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10)
if (l.attrs.z) layerObj.z = parseFloat(l.attrs.z)
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10)
if (l.attrs.ink) layerObj.ink = l.attrs.ink
if (l.attrs.tag) layerObj.tag = l.attrs.tag
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === '1'
layerData[lid] = layerObj
}
dirs[id] = { layers: layerData }
} else {
dirs[id] = {}
}
}
entry.directions = dirs
}
// Colors
const colorsNode = findChild(viz, 'colors')
if (colorsNode) {
const colors: Record<string, { layers: Record<string, { color: number }> }> = {}
for (const color of findChildren(colorsNode, 'color')) {
const cid = color.attrs.id || '0'
const colorLayers: Record<string, { color: number }> = {}
for (const cl of findChildren(color, 'colorLayer')) {
colorLayers[cl.attrs.id || '0'] = { color: parseInt(cl.attrs.color || '0', 16) }
}
colors[cid] = { layers: colorLayers }
}
if (Object.keys(colors).length > 0) entry.colors = colors
}
// Animations
const animsNode = findChild(viz, 'animations')
if (animsNode) {
const animations: Record<string, unknown> = {}
for (const anim of findChildren(animsNode, 'animation')) {
const aid = anim.attrs.id || '0'
const animLayers: Record<string, unknown> = {}
for (const al of findChildren(anim, 'animationLayer')) {
const alid = al.attrs.id || '0'
const frameSeqs: Record<string, unknown> = {}
for (const fs of findChildren(al, 'frameSequence')) {
const fsid = fs.attrs.id || Object.keys(frameSeqs).length.toString()
const frames: Record<string, Record<string, unknown>> = {}
let fi = 0
for (const f of findChildren(fs, 'frame')) {
const rawId = f.attrs.id || '0'
const frameObj: Record<string, unknown> = {
id: (rawId === 'NaN' ? 0 : parseInt(rawId, 10)) || 0,
}
if (f.attrs.x) frameObj.x = parseInt(f.attrs.x, 10)
if (f.attrs.y) frameObj.y = parseInt(f.attrs.y, 10)
if (f.attrs.randomX) frameObj.randomX = parseInt(f.attrs.randomX, 10)
if (f.attrs.randomY) frameObj.randomY = parseInt(f.attrs.randomY, 10)
// Per-direction offsets (keyed by sequential index, matching Retrosprite format)
const offsetNodes = findChildren(f, 'offset')
if (offsetNodes.length > 0) {
const offsets: Record<string, { direction: number; x: number; y: number }> = {}
for (let oi = 0; oi < offsetNodes.length; oi++) {
const o = offsetNodes[oi]
offsets[String(oi)] = {
direction: parseInt(o.attrs.direction || '0', 10),
x: parseInt(o.attrs.x || '0', 10),
y: parseInt(o.attrs.y || '0', 10),
}
}
frameObj.offsets = offsets
}
frames[String(fi)] = frameObj
fi++
}
const seqObj: Record<string, unknown> = { frames }
if (fs.attrs.loopCount) seqObj.loopCount = parseInt(fs.attrs.loopCount, 10)
if (fs.attrs.random) seqObj.random = parseInt(fs.attrs.random, 10)
frameSeqs[fsid] = seqObj
}
const layerObj: Record<string, unknown> = {
loopCount: parseInt(al.attrs.loopCount || '0', 10),
frameSequences: frameSeqs,
}
if (al.attrs.frameRepeat) layerObj.frameRepeat = parseInt(al.attrs.frameRepeat, 10)
if (al.attrs.random) layerObj.random = parseInt(al.attrs.random, 10)
animLayers[alid] = layerObj
}
const animObj: Record<string, unknown> = { layers: animLayers }
if (anim.attrs.transitionTo) animObj.transitionTo = parseInt(anim.attrs.transitionTo, 10)
if (anim.attrs.transitionFrom)
animObj.transitionFrom = parseInt(anim.attrs.transitionFrom, 10)
if (anim.attrs.immediateChangeFrom)
animObj.immediateChangeFrom = anim.attrs.immediateChangeFrom
if (anim.attrs.randomStart === '1') animObj.randomStart = true
animations[aid] = animObj
}
if (Object.keys(animations).length > 0) entry.animations = animations
}
// Postures
const posturesNode = findChild(viz, 'postures')
if (posturesNode) {
const posturesList: { id: string; animationId: number }[] = []
for (const p of findChildren(posturesNode, 'posture')) {
posturesList.push({
id: p.attrs.id || '',
animationId: parseInt(p.attrs.animationId || '0', 10),
})
}
const posturesObj: NitroVisualization['postures'] = {}
if (posturesNode.attrs.defaultPosture)
posturesObj.defaultPosture = posturesNode.attrs.defaultPosture
if (posturesList.length > 0) posturesObj.postures = posturesList
entry.postures = posturesObj
}
// Gestures
const gesturesNode = findChild(viz, 'gestures')
if (gesturesNode) {
const gesturesList: { id: string; animationId: number }[] = []
for (const g of findChildren(gesturesNode, 'gesture')) {
gesturesList.push({
id: g.attrs.id || '',
animationId: parseInt(g.attrs.animationId || '0', 10),
})
}
if (gesturesList.length > 0) entry.gestures = gesturesList
}
result.push(entry)
}
return result
}
// ── Metadata Helpers ────────────────────────────────────────────────
export function extractPartColors(visualizations: NitroVisualization[]): { color: string[] } {
const viz64 = visualizations.find((v) => v.size === 64)
if (!viz64?.colors) return { color: [] }
return { color: Object.keys(viz64.colors) }
}
export function computeDefaultDir(directions: number[]): number {
if (directions.includes(2)) return 2
if (directions.includes(0)) return 0
return directions[0] || 0
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more