fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+4528
-4740
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;
|
||||
@@ -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,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 }> = [];
|
||||
|
||||
@@ -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
@@ -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: {} };
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default async function RoomEditPage({
|
||||
</Badge>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Owner: {room.owner.username} · {room.users}/{room.usersMax} users
|
||||
Owner: {room.owner?.username ?? room.ownerName} · {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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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} · 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
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
{searchQuery}"
|
||||
</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 · 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 "{debounced}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}\``
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Re-export barrel for backward compatibility.
|
||||
* All implementation has been moved to the ./swf/ module directory.
|
||||
*/
|
||||
export * from './swf'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user