style: clean up code with prettier and eslint
This commit is contained in:
1 parent
60278b9e71
commit
fb8e77bb68
378 files changed
+22407
-20689
No files matched your search
@@ -11,51 +11,51 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
echo "--- EPIC WEB CONTROL: Starting Auto-Cleanup & Deploy ---"
|
echo "--- EPIC WEB CONTROL: Starting Auto-Cleanup & Deploy ---"
|
||||||
|
|
||||||
# 1. Clean up unused build images safely
|
# 1. Clean up unused build images safely
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|
||||||
# 2. Navigate to your website directory
|
# 2. Navigate to your website directory
|
||||||
cd /var/www/atom-nexst/
|
cd /var/www/atom-nexst/
|
||||||
|
|
||||||
# CRITICAL: Prevent Git permission blocks caused by the www-data ownership change
|
# CRITICAL: Prevent Git permission blocks caused by the www-data ownership change
|
||||||
git config --global --add safe.directory /var/www/atom-nexst
|
git config --global --add safe.directory /var/www/atom-nexst
|
||||||
|
|
||||||
# Point Git directly to the local Gitea folder path
|
# Point Git directly to the local Gitea folder path
|
||||||
git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||||
|
|
||||||
# 3. Fetch and update code
|
# 3. Fetch and update code
|
||||||
git fetch origin --prune
|
git fetch origin --prune
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
|
|
||||||
# Preserve .next/cache so Next.js can reuse its incremental build cache.
|
# Preserve .next/cache so Next.js can reuse its incremental build cache.
|
||||||
# next build safely refreshes stale server/static output itself.
|
# next build safely refreshes stale server/static output itself.
|
||||||
rm -rf .output dist
|
rm -rf .output dist
|
||||||
|
|
||||||
# 4. Configure trusted dependencies globally and install cleanly
|
# 4. Configure trusted dependencies globally and install cleanly
|
||||||
export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp"
|
export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# 5. Apply versioned CMS migrations and generate the Prisma client safely
|
# 5. Apply versioned CMS migrations and generate the Prisma client safely
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
|
||||||
# 6. Next.js Build
|
# 6. Next.js Build
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# 7. Fix ownership: Build first, THEN set permissions for the web server
|
# 7. Fix ownership: Build first, THEN set permissions for the web server
|
||||||
sudo chown -R www-data:www-data /var/www/atom-nexst/
|
sudo chown -R www-data:www-data /var/www/atom-nexst/
|
||||||
|
|
||||||
# 8. Hard restart of the Systemd service to clear memory cache
|
# 8. Hard restart of the Systemd service to clear memory cache
|
||||||
echo "Hard resetting systemd service..."
|
echo "Hard resetting systemd service..."
|
||||||
sudo systemctl stop atom-nexst.service
|
sudo systemctl stop atom-nexst.service
|
||||||
sudo systemctl start atom-nexst.service
|
sudo systemctl start atom-nexst.service
|
||||||
|
|
||||||
# Extra health check: Ensure the service is actually running
|
# Extra health check: Ensure the service is actually running
|
||||||
sleep 2
|
sleep 2
|
||||||
if ! systemctl is-active --quiet atom-nexst.service; then
|
if ! systemctl is-active --quiet atom-nexst.service; then
|
||||||
echo "ERROR: atom-nexst.service failed to start!" >&2
|
echo "ERROR: atom-nexst.service failed to start!" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "--- Deployment successfully completed ---"
|
echo "--- Deployment successfully completed ---"
|
||||||
Generated
+5872
-3394
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,9 @@ import { mysqlConnectionUrl } from "./db-url";
|
|||||||
|
|
||||||
describe("mysqlConnectionUrl", () => {
|
describe("mysqlConnectionUrl", () => {
|
||||||
it("removes Prisma-only pool options before passing the URL to MySQL2", () => {
|
it("removes Prisma-only pool options before passing the URL to MySQL2", () => {
|
||||||
const result = mysqlConnectionUrl("mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4");
|
const result = mysqlConnectionUrl(
|
||||||
|
"mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4",
|
||||||
|
);
|
||||||
expect(result).not.toContain("connection_limit");
|
expect(result).not.toContain("connection_limit");
|
||||||
expect(result).not.toContain("pool_timeout");
|
expect(result).not.toContain("pool_timeout");
|
||||||
expect(result).toContain("charset=utf8mb4");
|
expect(result).toContain("charset=utf8mb4");
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export function splitSqlStatements(sql: string): string[] {
|
export function splitSqlStatements(sql: string): string[] {
|
||||||
const withoutComments = sql
|
const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*--.*$/gm, "");
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
||||||
.replace(/^\s*--.*$/gm, "");
|
|
||||||
|
|
||||||
return withoutComments
|
return withoutComments
|
||||||
.split(";")
|
.split(";")
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export async function updateBadge({ code, name, desc }: { code: string; name: st
|
|||||||
await prisma.websiteBadges.upsert({
|
await prisma.websiteBadges.upsert({
|
||||||
where: { badgeKey: code },
|
where: { badgeKey: code },
|
||||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
||||||
create: { badgeKey: code, badgeName: name, badgeDescription: desc, createdAt: new Date(), updatedAt: new Date() },
|
create: {
|
||||||
|
badgeKey: code,
|
||||||
|
badgeName: name,
|
||||||
|
badgeDescription: desc,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/import/badges");
|
revalidatePath("/admin/import/badges");
|
||||||
}
|
}
|
||||||
+34
-34
@@ -1,70 +1,70 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
|
||||||
const bannerSchema = z.object({
|
const bannerSchema = z.object({
|
||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
subtitle: z.string().max(500).optional().default(''),
|
subtitle: z.string().max(500).optional().default(""),
|
||||||
image: z.string().max(500),
|
image: z.string().max(500),
|
||||||
link: z.string().max(500).optional().default(''),
|
link: z.string().max(500).optional().default(""),
|
||||||
color: z.string().max(20).optional().default(''),
|
color: z.string().max(20).optional().default(""),
|
||||||
isActive: z.coerce.number().int().min(0).max(1).default(1),
|
isActive: z.coerce.number().int().min(0).max(1).default(1),
|
||||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||||
startDate: z.string().max(50).nullable().optional(),
|
startDate: z.string().max(50).nullable().optional(),
|
||||||
endDate: z.string().max(50).nullable().optional(),
|
endDate: z.string().max(50).nullable().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createBanner = adminAction(
|
export const createBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const banner = await prisma.websiteBanner.create({ data: ctx.data })
|
const banner = await prisma.websiteBanner.create({ data: ctx.data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'banner_create',
|
action: "banner_create",
|
||||||
target: 'WebsiteBanner',
|
target: "WebsiteBanner",
|
||||||
targetId: banner.id,
|
targetId: banner.id,
|
||||||
after: { title: banner.title },
|
after: { title: banner.title },
|
||||||
})
|
});
|
||||||
return actionOk({ id: banner.id })
|
return actionOk({ id: banner.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() })
|
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const updateBanner = adminAction(
|
export const updateBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data
|
const { id, ...data } = ctx.data;
|
||||||
const existing = await prisma.websiteBanner.findUnique({ where: { id } })
|
const existing = await prisma.websiteBanner.findUnique({ where: { id } });
|
||||||
if (!existing) throw new ActionError('Banner not found')
|
if (!existing) throw new ActionError("Banner not found");
|
||||||
await prisma.websiteBanner.update({ where: { id }, data })
|
await prisma.websiteBanner.update({ where: { id }, data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'banner_update',
|
action: "banner_update",
|
||||||
target: 'WebsiteBanner',
|
target: "WebsiteBanner",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
})
|
});
|
||||||
return actionOk({ id })
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() })
|
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const deleteBanner = adminAction(
|
export const deleteBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } })
|
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'banner_delete',
|
action: "banner_delete",
|
||||||
target: 'WebsiteBanner',
|
target: "WebsiteBanner",
|
||||||
targetId: ctx.data.id,
|
targetId: ctx.data.id,
|
||||||
})
|
});
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
@@ -7,7 +7,11 @@ import { rcon } from "@/lib/services/rcon";
|
|||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
export async function bulkUnban({
|
||||||
|
userIds,
|
||||||
|
}: {
|
||||||
|
userIds: number[];
|
||||||
|
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
|
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -19,7 +23,15 @@ export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<Act
|
|||||||
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
|
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }): Promise<ActionResult<{ banned: number }>> {
|
export async function bulkBan({
|
||||||
|
userIds,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
userIds: number[];
|
||||||
|
reason: string;
|
||||||
|
duration: number;
|
||||||
|
}): Promise<ActionResult<{ banned: number }>> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
let banned = 0;
|
let banned = 0;
|
||||||
@@ -53,7 +65,17 @@ export async function bulkBan({ userIds, reason, duration }: { userIds: number[]
|
|||||||
return { ok: true as const, data: { banned } };
|
return { ok: true as const, data: { banned } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
|
export async function bulkGiveCurrency({
|
||||||
|
userIds,
|
||||||
|
amount,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
userIds: number[];
|
||||||
|
amount: number;
|
||||||
|
type: "credits" | "pixels" | "points";
|
||||||
|
}): Promise<
|
||||||
|
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
|
||||||
|
> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
let given = 0;
|
let given = 0;
|
||||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||||
@@ -93,7 +115,15 @@ export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: num
|
|||||||
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
|
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
|
export async function bulkGiveBadge({
|
||||||
|
userIds,
|
||||||
|
badgeCode,
|
||||||
|
}: {
|
||||||
|
userIds: number[];
|
||||||
|
badgeCode: string;
|
||||||
|
}): Promise<
|
||||||
|
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
|
||||||
|
> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
let given = 0;
|
let given = 0;
|
||||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||||
|
|||||||
@@ -33,7 +33,16 @@ export async function deleteBcItem({ id }: { id: number }) {
|
|||||||
revalidatePath("/admin/catalog/builder-club");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBcItem({ id, ...data }: { id: number; itemIds?: string; catalogName?: string; orderNumber?: number; extradata?: string }) {
|
export async function updateBcItem({
|
||||||
|
id,
|
||||||
|
...data
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
itemIds?: string;
|
||||||
|
catalogName?: string;
|
||||||
|
orderNumber?: number;
|
||||||
|
extradata?: string;
|
||||||
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
|
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -46,7 +55,16 @@ export async function updateBcItem({ id, ...data }: { id: number; itemIds?: stri
|
|||||||
revalidatePath("/admin/catalog/builder-club");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBcItem({ pageId, ...data }: { pageId: number; itemIds: string; catalogName: string; orderNumber: number; extradata: string }) {
|
export async function createBcItem({
|
||||||
|
pageId,
|
||||||
|
...data
|
||||||
|
}: {
|
||||||
|
pageId: number;
|
||||||
|
itemIds: string;
|
||||||
|
catalogName: string;
|
||||||
|
orderNumber: number;
|
||||||
|
extradata: string;
|
||||||
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const created = await prisma.catalogItemsBc.create({
|
const created = await prisma.catalogItemsBc.create({
|
||||||
data: { pageId, ...data },
|
data: { pageId, ...data },
|
||||||
@@ -63,7 +81,10 @@ export async function createBcItem({ pageId, ...data }: { pageId: number; itemId
|
|||||||
|
|
||||||
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
|
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const page = await prisma.catalogPagesBc.findUnique({ where: { id }, select: { enabled: true, visible: true } });
|
const page = await prisma.catalogPagesBc.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { enabled: true, visible: true },
|
||||||
|
});
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
await prisma.catalogPagesBc.update({
|
await prisma.catalogPagesBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; t
|
|||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
|
export async function reorderCatalogItems({
|
||||||
|
orders,
|
||||||
|
}: {
|
||||||
|
orders: Array<{ id: number; orderNumber: number }>;
|
||||||
|
}) {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
for (const { id, orderNumber } of orders) {
|
for (const { id, orderNumber } of orders) {
|
||||||
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
||||||
@@ -72,7 +76,15 @@ export async function reorderCatalogItems({ orders }: { orders: Array<{ id: numb
|
|||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: number; catalogFields: Record<string, unknown>; baseItem?: { id: number; fields: Record<string, unknown> } }) {
|
export async function updateCatalogItem({
|
||||||
|
id,
|
||||||
|
catalogFields,
|
||||||
|
baseItem,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
catalogFields: Record<string, unknown>;
|
||||||
|
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||||
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
|
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
|
||||||
if (baseItem) {
|
if (baseItem) {
|
||||||
@@ -90,15 +102,22 @@ export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: n
|
|||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
|
export async function translateCatalogItems({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: Array<{ id: number; publicName: string; description: string }>;
|
||||||
|
}) {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
let namesUpdated = 0;
|
let namesUpdated = 0;
|
||||||
let descriptionsUpdated = 0;
|
let descriptionsUpdated = 0;
|
||||||
let furniDataUpdated = 0;
|
let furniDataUpdated = 0;
|
||||||
let furniDataInserted = 0;
|
const furniDataInserted = 0;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const existing = await prisma.catalogItems.findUnique({ where: { id: item.id }, select: { catalogName: true } });
|
const existing = await prisma.catalogItems.findUnique({
|
||||||
|
where: { id: item.id },
|
||||||
|
select: { catalogName: true },
|
||||||
|
});
|
||||||
if (!existing) continue;
|
if (!existing) continue;
|
||||||
|
|
||||||
if (item.publicName && item.publicName !== existing.catalogName) {
|
if (item.publicName && item.publicName !== existing.catalogName) {
|
||||||
@@ -127,5 +146,14 @@ export async function translateCatalogItems({ items }: { items: Array<{ id: numb
|
|||||||
|
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: { namesUpdated, descriptionsUpdated, furniDataUpdated, furniDataInserted: 0, updated: items.length } };
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
data: {
|
||||||
|
namesUpdated,
|
||||||
|
descriptionsUpdated,
|
||||||
|
furniDataUpdated,
|
||||||
|
furniDataInserted: 0,
|
||||||
|
updated: items.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+27
-9
@@ -7,7 +7,10 @@ import { rcon } from "@/lib/services/rcon";
|
|||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
export async function updateCatalogPage({
|
||||||
|
id,
|
||||||
|
...fields
|
||||||
|
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
await prisma.catalogPages.update({ where: { id }, data: fields as any });
|
await prisma.catalogPages.update({ where: { id }, data: fields as any });
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
@@ -37,9 +40,18 @@ export async function deleteCatalogPage({ id }: { id: number }) {
|
|||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleCatalogPage({ id, action }: { id: number; action: "toggleEnabled" | "toggleVisible" }) {
|
export async function toggleCatalogPage({
|
||||||
|
id,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
action: "toggleEnabled" | "toggleVisible";
|
||||||
|
}) {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true } });
|
const page = await prisma.catalogPages.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { enabled: true, visible: true },
|
||||||
|
});
|
||||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||||
@@ -51,7 +63,17 @@ export async function toggleCatalogPage({ id, action }: { id: number; action: "t
|
|||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout?: string; iconImage?: number; iconColor?: number; enabled?: "0" | "1"; visible?: "0" | "1"; minRank?: number; orderNum?: number }): Promise<ActionResult<{ id: number }>> {
|
export async function createCatalogPage(input: {
|
||||||
|
caption: string;
|
||||||
|
parentId: number;
|
||||||
|
pageLayout?: string;
|
||||||
|
iconImage?: number;
|
||||||
|
iconColor?: number;
|
||||||
|
enabled?: "0" | "1";
|
||||||
|
visible?: "0" | "1";
|
||||||
|
minRank?: number;
|
||||||
|
orderNum?: number;
|
||||||
|
}): Promise<ActionResult<{ id: number }>> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const created = await prisma.catalogPages.create({
|
const created = await prisma.catalogPages.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -83,11 +105,7 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
|
|||||||
return { ok: true as const, data: { id: created.id } };
|
return { ok: true as const, data: { id: created.id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderTreePage(input: {
|
export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) {
|
||||||
pageId: number;
|
|
||||||
newParentId?: number;
|
|
||||||
newOrderNum: number;
|
|
||||||
}) {
|
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
await prisma.catalogPages.update({
|
await prisma.catalogPages.update({
|
||||||
where: { id: input.pageId },
|
where: { id: input.pageId },
|
||||||
|
|||||||
+17
-17
@@ -1,39 +1,39 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from '@/lib/safe-action-shared'
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { rcon } from '@/lib/services/rcon'
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
const saveEmulatorSettingsSchema = z.object({
|
const saveEmulatorSettingsSchema = z.object({
|
||||||
settings: z.record(z.string(), z.string()),
|
settings: z.record(z.string(), z.string()),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const saveEmulatorSettings = adminAction(
|
export const saveEmulatorSettings = adminAction(
|
||||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const entries = Object.entries(ctx.data.settings)
|
const entries = Object.entries(ctx.data.settings);
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
await prisma.emulatorSettings.upsert({
|
await prisma.emulatorSettings.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: { value: String(value) },
|
update: { value: String(value) },
|
||||||
create: { key, value: String(value) },
|
create: { key, value: String(value) },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await rcon.updateConfig()
|
await rcon.updateConfig();
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'emulator_settings_update',
|
action: "emulator_settings_update",
|
||||||
target: 'EmulatorSettings',
|
target: "EmulatorSettings",
|
||||||
after: ctx.data.settings,
|
after: ctx.data.settings,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+71
-71
@@ -1,18 +1,18 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
createEventSchema,
|
createEventSchema,
|
||||||
eventPrizeSchema,
|
eventPrizeSchema,
|
||||||
eventTypeSchema,
|
eventTypeSchema,
|
||||||
eventWinnerSchema,
|
eventWinnerSchema,
|
||||||
updateEventSchema,
|
updateEventSchema,
|
||||||
} from '@/lib/validators/event'
|
} from "@/lib/validators/event";
|
||||||
|
|
||||||
// ── Event Types ─────────────────────────────────────────────────────
|
// ── Event Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -21,63 +21,63 @@ export const createEventType = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const eventType = await prisma.websiteEventType.create({
|
const eventType = await prisma.websiteEventType.create({
|
||||||
data: ctx.data,
|
data: ctx.data,
|
||||||
})
|
});
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_type_create',
|
action: "event_type_create",
|
||||||
target: 'WebsiteEventType',
|
target: "WebsiteEventType",
|
||||||
targetId: eventType.id,
|
targetId: eventType.id,
|
||||||
after: { name: eventType.name },
|
after: { name: eventType.name },
|
||||||
})
|
});
|
||||||
return actionOk({ id: eventType.id })
|
return actionOk({ id: eventType.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateEventTypeInput = eventTypeSchema.partial().extend({
|
const updateEventTypeInput = eventTypeSchema.partial().extend({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateEventType = adminAction(
|
export const updateEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data
|
const { id, ...data } = ctx.data;
|
||||||
const existing = await prisma.websiteEventType.findUnique({ where: { id } })
|
const existing = await prisma.websiteEventType.findUnique({ where: { id } });
|
||||||
if (!existing) throw new ActionError('Event type not found')
|
if (!existing) throw new ActionError("Event type not found");
|
||||||
|
|
||||||
await prisma.websiteEventType.update({ where: { id }, data })
|
await prisma.websiteEventType.update({ where: { id }, data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_type_update',
|
action: "event_type_update",
|
||||||
target: 'WebsiteEventType',
|
target: "WebsiteEventType",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
before: { name: existing.name },
|
before: { name: existing.name },
|
||||||
after: data,
|
after: data,
|
||||||
})
|
});
|
||||||
return actionOk({ id })
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deleteEventTypeInput = z.object({
|
const deleteEventTypeInput = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteEventType = adminAction(
|
export const deleteEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } })
|
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } });
|
||||||
if (!existing) throw new ActionError('Event type not found')
|
if (!existing) throw new ActionError("Event type not found");
|
||||||
|
|
||||||
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } })
|
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_type_delete',
|
action: "event_type_delete",
|
||||||
target: 'WebsiteEventType',
|
target: "WebsiteEventType",
|
||||||
targetId: ctx.data.id,
|
targetId: ctx.data.id,
|
||||||
before: { name: existing.name },
|
before: { name: existing.name },
|
||||||
})
|
});
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Events ──────────────────────────────────────────────────────────
|
// ── Events ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -89,97 +89,97 @@ export const createEvent = adminAction(
|
|||||||
...ctx.data,
|
...ctx.data,
|
||||||
hostUserId: Number(ctx.session.user.id),
|
hostUserId: Number(ctx.session.user.id),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_create',
|
action: "event_create",
|
||||||
target: 'WebsiteEvent',
|
target: "WebsiteEvent",
|
||||||
targetId: event.id,
|
targetId: event.id,
|
||||||
after: { title: event.title },
|
after: { title: event.title },
|
||||||
})
|
});
|
||||||
return actionOk({ id: event.id })
|
return actionOk({ id: event.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateEventInput = updateEventSchema.extend({
|
const updateEventInput = updateEventSchema.extend({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateEvent = adminAction(
|
export const updateEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data
|
const { id, ...data } = ctx.data;
|
||||||
const existing = await prisma.websiteEvent.findUnique({ where: { id } })
|
const existing = await prisma.websiteEvent.findUnique({ where: { id } });
|
||||||
if (!existing) throw new ActionError('Event not found')
|
if (!existing) throw new ActionError("Event not found");
|
||||||
|
|
||||||
await prisma.websiteEvent.update({ where: { id }, data })
|
await prisma.websiteEvent.update({ where: { id }, data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_update',
|
action: "event_update",
|
||||||
target: 'WebsiteEvent',
|
target: "WebsiteEvent",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
before: { title: existing.title, status: existing.status },
|
before: { title: existing.title, status: existing.status },
|
||||||
after: data,
|
after: data,
|
||||||
})
|
});
|
||||||
return actionOk({ id })
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deleteEventInput = z.object({
|
const deleteEventInput = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteEvent = adminAction(
|
export const deleteEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } })
|
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } });
|
||||||
if (!existing) throw new ActionError('Event not found')
|
if (!existing) throw new ActionError("Event not found");
|
||||||
|
|
||||||
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } })
|
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_delete',
|
action: "event_delete",
|
||||||
target: 'WebsiteEvent',
|
target: "WebsiteEvent",
|
||||||
targetId: ctx.data.id,
|
targetId: ctx.data.id,
|
||||||
before: { title: existing.title },
|
before: { title: existing.title },
|
||||||
})
|
});
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Prizes ──────────────────────────────────────────────────────────
|
// ── Prizes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const addEventPrize = adminAction(
|
export const addEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const prize = await prisma.websiteEventPrize.create({ data: ctx.data })
|
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
|
||||||
return actionOk({ id: prize.id })
|
return actionOk({ id: prize.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() })
|
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const deleteEventPrize = adminAction(
|
export const deleteEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } })
|
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Winners ─────────────────────────────────────────────────────────
|
// ── Winners ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const addEventWinner = adminAction(
|
export const addEventWinner = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const winner = await prisma.websiteEventWinner.create({ data: ctx.data })
|
const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'event_winner_add',
|
action: "event_winner_add",
|
||||||
target: 'WebsiteEventWinner',
|
target: "WebsiteEventWinner",
|
||||||
targetId: winner.id,
|
targetId: winner.id,
|
||||||
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position },
|
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position },
|
||||||
})
|
});
|
||||||
return actionOk({ id: winner.id })
|
return actionOk({ id: winner.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
@@ -3,13 +3,27 @@
|
|||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function importBadgeFromRemote({ code, name, description }: { code: string; name: string; description: string }) {
|
export async function importBadgeFromRemote({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
try {
|
try {
|
||||||
await prisma.websiteBadges.upsert({
|
await prisma.websiteBadges.upsert({
|
||||||
where: { badgeKey: code },
|
where: { badgeKey: code },
|
||||||
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
|
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
|
||||||
create: { badgeKey: code, badgeName: name, badgeDescription: description, createdAt: new Date(), updatedAt: new Date() },
|
create: {
|
||||||
|
badgeKey: code,
|
||||||
|
badgeName: name,
|
||||||
|
badgeDescription: description,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+13
-12
@@ -1,19 +1,20 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from '@/lib/safe-action-shared'
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
import { cleanupConvertedSwfs, deleteImportedItem } from '@/lib/services/furni-import'
|
import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import";
|
||||||
|
|
||||||
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||||
const result = await cleanupConvertedSwfs()
|
const result = await cleanupConvertedSwfs();
|
||||||
return actionOk(result as unknown as Record<string, unknown>)
|
return actionOk(result as unknown as Record<string, unknown>);
|
||||||
})
|
});
|
||||||
|
|
||||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) })
|
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
||||||
|
|
||||||
export const deleteImportedFurni = adminAction(
|
export const deleteImportedFurni = adminAction(
|
||||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||||
async (ctx) => actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
|
async (ctx) =>
|
||||||
)
|
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
|
||||||
|
);
|
||||||
+75
-75
@@ -1,67 +1,67 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { rcon } from '@/lib/services/rcon'
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
||||||
|
|
||||||
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() })
|
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const assignCfhTicket = adminAction(
|
export const assignCfhTicket = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
|
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
await prisma.supportTickets.update({
|
await prisma.supportTickets.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: { modId: ctx.session.user.id, state: 1 },
|
data: { modId: ctx.session.user.id, state: 1 },
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'cfh_assign',
|
action: "cfh_assign",
|
||||||
target: 'support_tickets',
|
target: "support_tickets",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const cfhStateSchema = z.object({
|
const cfhStateSchema = z.object({
|
||||||
ticketId: z.coerce.number().int().positive(),
|
ticketId: z.coerce.number().int().positive(),
|
||||||
state: z.coerce.number().int().min(0).max(3),
|
state: z.coerce.number().int().min(0).max(3),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateCfhState = adminAction(
|
export const updateCfhState = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
|
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
await prisma.supportTickets.update({
|
await prisma.supportTickets.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: { state: ctx.data.state, modId: ctx.session.user.id },
|
data: { state: ctx.data.state, modId: ctx.session.user.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'cfh_state_change',
|
action: "cfh_state_change",
|
||||||
target: 'support_tickets',
|
target: "support_tickets",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
before: { state: ticket.state },
|
before: { state: ticket.state },
|
||||||
after: { state: ctx.data.state },
|
after: { state: ctx.data.state },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export const closeCfhTicket = adminAction(
|
export const closeCfhTicket = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||||
@@ -69,138 +69,138 @@ export const closeCfhTicket = adminAction(
|
|||||||
await prisma.supportTickets.update({
|
await prisma.supportTickets.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: { state: 2, modId: ctx.session.user.id },
|
data: { state: 2, modId: ctx.session.user.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'cfh_close',
|
action: "cfh_close",
|
||||||
target: 'support_tickets',
|
target: "support_tickets",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Quick Mod Actions ────────────────────────────────────────────────
|
// ── Quick Mod Actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() })
|
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickKick = adminAction(
|
export const quickKick = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.disconnectUser(ctx.data.userId)
|
await rcon.disconnectUser(ctx.data.userId);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'mod_kick',
|
action: "mod_kick",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const muteSchema = z.object({
|
const muteSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
|
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
|
||||||
})
|
});
|
||||||
|
|
||||||
export const quickMute = adminAction(
|
export const quickMute = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration)
|
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'mod_mute',
|
action: "mod_mute",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
after: { duration: ctx.data.duration },
|
after: { duration: ctx.data.duration },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export const quickUnmute = adminAction(
|
export const quickUnmute = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.unmuteUser(ctx.data.userId)
|
await rcon.unmuteUser(ctx.data.userId);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'mod_unmute',
|
action: "mod_unmute",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const alertSchema = z.object({
|
const alertSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
message: z.string().min(1).max(500),
|
message: z.string().min(1).max(500),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const quickAlert = adminAction(
|
export const quickAlert = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.alertUser(ctx.data.userId, ctx.data.message)
|
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'mod_alert',
|
action: "mod_alert",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
after: { message: ctx.data.message },
|
after: { message: ctx.data.message },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() })
|
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickRoomKick = adminAction(
|
export const quickRoomKick = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.kickAll(ctx.data.roomId)
|
await rcon.kickAll(ctx.data.roomId);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'mod_room_kick',
|
action: "mod_room_kick",
|
||||||
target: 'Room',
|
target: "Room",
|
||||||
targetId: ctx.data.roomId,
|
targetId: ctx.data.roomId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const broadcastSchema = z.object({
|
const broadcastSchema = z.object({
|
||||||
message: z.string().min(1).max(500),
|
message: z.string().min(1).max(500),
|
||||||
type: z.enum(['hotel', 'staff']),
|
type: z.enum(["hotel", "staff"]),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const broadcastAlert = adminAction(
|
export const broadcastAlert = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.data.type === 'hotel') {
|
if (ctx.data.type === "hotel") {
|
||||||
await rcon.hotelAlert(ctx.data.message)
|
await rcon.hotelAlert(ctx.data.message);
|
||||||
} else {
|
} else {
|
||||||
await rcon.staffAlert(ctx.data.message)
|
await rcon.staffAlert(ctx.data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: `mod_broadcast_${ctx.data.type}`,
|
action: `mod_broadcast_${ctx.data.type}`,
|
||||||
target: 'broadcast',
|
target: "broadcast",
|
||||||
after: { message: ctx.data.message },
|
after: { message: ctx.data.message },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+90
-52
@@ -1,102 +1,140 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { revalidateTag } from 'next/cache'
|
import { revalidateTag } from "next/cache";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permission-slugs'
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from '@/lib/services/permission-ranks'
|
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks";
|
||||||
import { rcon } from '@/lib/services/rcon'
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from '@/lib/services/staff-activity'
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
const createRankSchema = z.object({
|
const createRankSchema = z.object({
|
||||||
rank_name: z.string().trim().min(1).max(25),
|
rank_name: z.string().trim().min(1).max(25),
|
||||||
level: z.coerce.number().int().min(1),
|
level: z.coerce.number().int().min(1),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createRank = adminAction(
|
export const createRank = adminAction(
|
||||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = await createEmulatorRank(prisma, ctx.data)
|
const id = await createEmulatorRank(prisma, ctx.data);
|
||||||
await prisma.aclRole.upsert({
|
await prisma.aclRole.upsert({
|
||||||
where: { slug: `rank_${id}` },
|
where: { slug: `rank_${id}` },
|
||||||
create: { slug: `rank_${id}`, title: ctx.data.rank_name, description: 'CMS role synchronized from permission_ranks' },
|
create: {
|
||||||
|
slug: `rank_${id}`,
|
||||||
|
title: ctx.data.rank_name,
|
||||||
|
description: "CMS role synchronized from permission_ranks",
|
||||||
|
},
|
||||||
update: { title: ctx.data.rank_name },
|
update: { title: ctx.data.rank_name },
|
||||||
})
|
});
|
||||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_create', description: `Created rank #${id}`, targetType: 'rank', targetId: id })
|
await logStaffActivity({
|
||||||
await rcon.send('updatepermissions')
|
staffId: ctx.session.user.id,
|
||||||
revalidateTag('permissions', { expire: 0 })
|
action: "rank_create",
|
||||||
return actionOk({ id })
|
description: `Created rank #${id}`,
|
||||||
|
targetType: "rank",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
await rcon.send("updatepermissions");
|
||||||
|
revalidateTag("permissions", { expire: 0 });
|
||||||
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() })
|
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const deleteRank = adminAction(
|
export const deleteRank = adminAction(
|
||||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } })
|
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||||
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`)
|
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||||
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } })
|
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } });
|
||||||
await deleteEmulatorRank(prisma, ctx.data.id)
|
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||||
if (role) {
|
if (role) {
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } }),
|
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }),
|
||||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
prisma.aclRole.delete({ where: { id: role.id } }),
|
||||||
])
|
]);
|
||||||
}
|
}
|
||||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_delete', description: `Deleted rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id })
|
await logStaffActivity({
|
||||||
await rcon.send('updatepermissions')
|
staffId: ctx.session.user.id,
|
||||||
revalidateTag('permissions', { expire: 0 })
|
action: "rank_delete",
|
||||||
return actionOk()
|
description: `Deleted rank #${ctx.data.id}`,
|
||||||
|
targetType: "rank",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
});
|
||||||
|
await rcon.send("updatepermissions");
|
||||||
|
revalidateTag("permissions", { expire: 0 });
|
||||||
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const saveRankSchema = z.object({
|
const saveRankSchema = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
fields: z.record(z.string(), z.union([z.string(), z.number()])),
|
fields: z.record(z.string(), z.union([z.string(), z.number()])),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const saveRank = adminAction(
|
export const saveRank = adminAction(
|
||||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields)
|
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||||
if (typeof ctx.data.fields.rank_name === 'string') {
|
if (typeof ctx.data.fields.rank_name === "string") {
|
||||||
await prisma.aclRole.updateMany({ where: { slug: `rank_${ctx.data.id}` }, data: { title: ctx.data.fields.rank_name } })
|
await prisma.aclRole.updateMany({
|
||||||
|
where: { slug: `rank_${ctx.data.id}` },
|
||||||
|
data: { title: ctx.data.fields.rank_name },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_update', description: `Updated rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id })
|
await logStaffActivity({
|
||||||
await rcon.send('updatepermissions')
|
staffId: ctx.session.user.id,
|
||||||
revalidateTag('permissions', { expire: 0 })
|
action: "rank_update",
|
||||||
return actionOk()
|
description: `Updated rank #${ctx.data.id}`,
|
||||||
|
targetType: "rank",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
});
|
||||||
|
await rcon.send("updatepermissions");
|
||||||
|
revalidateTag("permissions", { expire: 0 });
|
||||||
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const setCmsPermsSchema = z.object({
|
const setCmsPermsSchema = z.object({
|
||||||
roleId: z.coerce.number().int().positive(),
|
roleId: z.coerce.number().int().positive(),
|
||||||
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
|
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const setCmsPermissions = adminAction(
|
export const setCmsPermissions = adminAction(
|
||||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const role = await prisma.aclRole.findUnique({ where: { id: ctx.data.roleId }, select: { id: true, slug: true } })
|
const role = await prisma.aclRole.findUnique({
|
||||||
if (!role) throw new ActionError('Role not found')
|
where: { id: ctx.data.roleId },
|
||||||
|
select: { id: true, slug: true },
|
||||||
|
});
|
||||||
|
if (!role) throw new ActionError("Role not found");
|
||||||
const permissions = await prisma.aclPermission.findMany({
|
const permissions = await prisma.aclPermission.findMany({
|
||||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
});
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } })
|
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } });
|
||||||
if (permissions.length) {
|
if (permissions.length) {
|
||||||
await tx.aclModelPermission.createMany({
|
await tx.aclModelPermission.createMany({
|
||||||
data: permissions.map((permission) => ({ modelId: role.id, modelType: 'Role', permissionId: permission.id })),
|
data: permissions.map((permission) => ({
|
||||||
})
|
modelId: role.id,
|
||||||
|
modelType: "Role",
|
||||||
|
permissionId: permission.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
await logStaffActivity({ staffId: ctx.session.user.id, action: 'acl_role_permissions_update', description: `Updated ${permissions.length} permissions for ${role.slug}`, targetType: 'acl_role', targetId: role.id })
|
await logStaffActivity({
|
||||||
revalidateTag('permissions', { expire: 0 })
|
staffId: ctx.session.user.id,
|
||||||
return actionOk()
|
action: "acl_role_permissions_update",
|
||||||
|
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
||||||
|
targetType: "acl_role",
|
||||||
|
targetId: role.id,
|
||||||
|
});
|
||||||
|
revalidateTag("permissions", { expire: 0 });
|
||||||
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+45
-45
@@ -1,107 +1,107 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { createPollSchema, pollQuestionSchema, updatePollSchema } from '@/lib/validators/poll'
|
import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll";
|
||||||
|
|
||||||
// ── Polls ───────────────────────────────────────────────────────────
|
// ── Polls ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const createPoll = adminAction(
|
export const createPoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const poll = await prisma.websitePoll.create({ data: ctx.data })
|
const poll = await prisma.websitePoll.create({ data: ctx.data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'poll_create',
|
action: "poll_create",
|
||||||
target: 'WebsitePoll',
|
target: "WebsitePoll",
|
||||||
targetId: poll.id,
|
targetId: poll.id,
|
||||||
after: { title: poll.title },
|
after: { title: poll.title },
|
||||||
})
|
});
|
||||||
return actionOk({ id: poll.id })
|
return actionOk({ id: poll.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updatePollInput = updatePollSchema.extend({
|
const updatePollInput = updatePollSchema.extend({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updatePoll = adminAction(
|
export const updatePoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data
|
const { id, ...data } = ctx.data;
|
||||||
const existing = await prisma.websitePoll.findUnique({ where: { id } })
|
const existing = await prisma.websitePoll.findUnique({ where: { id } });
|
||||||
if (!existing) throw new ActionError('Poll not found')
|
if (!existing) throw new ActionError("Poll not found");
|
||||||
|
|
||||||
await prisma.websitePoll.update({ where: { id }, data })
|
await prisma.websitePoll.update({ where: { id }, data });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'poll_update',
|
action: "poll_update",
|
||||||
target: 'WebsitePoll',
|
target: "WebsitePoll",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
before: { title: existing.title, status: existing.status },
|
before: { title: existing.title, status: existing.status },
|
||||||
after: data,
|
after: data,
|
||||||
})
|
});
|
||||||
return actionOk({ id })
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deletePollInput = z.object({
|
const deletePollInput = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deletePoll = adminAction(
|
export const deletePoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } })
|
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } });
|
||||||
if (!existing) throw new ActionError('Poll not found')
|
if (!existing) throw new ActionError("Poll not found");
|
||||||
|
|
||||||
await prisma.websitePoll.delete({ where: { id: ctx.data.id } })
|
await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'poll_delete',
|
action: "poll_delete",
|
||||||
target: 'WebsitePoll',
|
target: "WebsitePoll",
|
||||||
targetId: ctx.data.id,
|
targetId: ctx.data.id,
|
||||||
before: { title: existing.title },
|
before: { title: existing.title },
|
||||||
})
|
});
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Questions ───────────────────────────────────────────────────────
|
// ── Questions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const addPollQuestion = adminAction(
|
export const addPollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const question = await prisma.websitePollQuestion.create({ data: ctx.data })
|
const question = await prisma.websitePollQuestion.create({ data: ctx.data });
|
||||||
return actionOk({ id: question.id })
|
return actionOk({ id: question.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateQuestionInput = pollQuestionSchema.partial().extend({
|
const updateQuestionInput = pollQuestionSchema.partial().extend({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updatePollQuestion = adminAction(
|
export const updatePollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data
|
const { id, ...data } = ctx.data;
|
||||||
await prisma.websitePollQuestion.update({ where: { id }, data })
|
await prisma.websitePollQuestion.update({ where: { id }, data });
|
||||||
return actionOk({ id })
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deleteQuestionInput = z.object({
|
const deleteQuestionInput = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deletePollQuestion = adminAction(
|
export const deletePollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } })
|
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+45
-45
@@ -1,10 +1,10 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
|
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
|
||||||
// are not represented in prisma/schema.prisma yet — we use raw queries with
|
// are not represented in prisma/schema.prisma yet — we use raw queries with
|
||||||
@@ -19,26 +19,26 @@ const createPrefixSchema = z.object({
|
|||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
effect: z.string().optional(),
|
effect: z.string().optional(),
|
||||||
active: z.coerce.number().int().min(0).max(1).default(1),
|
active: z.coerce.number().int().min(0).max(1).default(1),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createPrefix = adminAction(
|
export const createPrefix = adminAction(
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { username, text, color, icon, effect, active } = ctx.data
|
const { username, text, color, icon, effect, active } = ctx.data;
|
||||||
|
|
||||||
const users = await prisma.$queryRaw<{ id: number }[]>`
|
const users = await prisma.$queryRaw<{ id: number }[]>`
|
||||||
SELECT id FROM users WHERE username = ${username} LIMIT 1
|
SELECT id FROM users WHERE username = ${username} LIMIT 1
|
||||||
`
|
`;
|
||||||
if (users.length === 0) throw new ActionError('User not found')
|
if (users.length === 0) throw new ActionError("User not found");
|
||||||
|
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
|
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
|
||||||
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ''}, ${effect || ''}, ${active})
|
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
|
||||||
`
|
`;
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Update prefix ───────────────────────────────────────────────────
|
// ── Update prefix ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,94 +49,94 @@ const updatePrefixSchema = z.object({
|
|||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
effect: z.string().optional(),
|
effect: z.string().optional(),
|
||||||
active: z.coerce.number().int().min(0).max(1).optional(),
|
active: z.coerce.number().int().min(0).max(1).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updatePrefix = adminAction(
|
export const updatePrefix = adminAction(
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, text, color, icon, effect, active } = ctx.data
|
const { id, text, color, icon, effect, active } = ctx.data;
|
||||||
|
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
UPDATE custom_prefixes
|
UPDATE custom_prefixes
|
||||||
SET text = ${text}, color = ${color}, icon = ${icon || ''}, effect = ${effect || ''}, active = ${active ?? 1}
|
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
`
|
`;
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Delete prefix ───────────────────────────────────────────────────
|
// ── Delete prefix ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const deletePrefixSchema = z.object({
|
const deletePrefixSchema = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deletePrefix = adminAction(
|
export const deletePrefix = adminAction(
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`
|
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`;
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Add blacklist word ──────────────────────────────────────────────
|
// ── Add blacklist word ──────────────────────────────────────────────
|
||||||
|
|
||||||
const addBlacklistWordSchema = z.object({
|
const addBlacklistWordSchema = z.object({
|
||||||
word: z.string().min(1).max(100),
|
word: z.string().min(1).max(100),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const addBlacklistWord = adminAction(
|
export const addBlacklistWord = adminAction(
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
||||||
`
|
`;
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Remove blacklist word ───────────────────────────────────────────
|
// ── Remove blacklist word ───────────────────────────────────────────
|
||||||
|
|
||||||
const removeBlacklistWordSchema = z.object({
|
const removeBlacklistWordSchema = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const removeBlacklistWord = adminAction(
|
export const removeBlacklistWord = adminAction(
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`
|
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`;
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Update prefix settings ──────────────────────────────────────────
|
// ── Update prefix settings ──────────────────────────────────────────
|
||||||
|
|
||||||
const SETTINGS_WHITELIST = new Set([
|
const SETTINGS_WHITELIST = new Set([
|
||||||
'enabled',
|
"enabled",
|
||||||
'max_length',
|
"max_length",
|
||||||
'min_rank',
|
"min_rank",
|
||||||
'allow_colors',
|
"allow_colors",
|
||||||
'allow_bold',
|
"allow_bold",
|
||||||
'allow_italic',
|
"allow_italic",
|
||||||
'default_color',
|
"default_color",
|
||||||
])
|
]);
|
||||||
|
|
||||||
const updatePrefixSettingsSchema = z.object({
|
const updatePrefixSettingsSchema = z.object({
|
||||||
settings: z.record(z.string(), z.string()),
|
settings: z.record(z.string(), z.string()),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updatePrefixSettings = adminAction(
|
export const updatePrefixSettings = adminAction(
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
for (const [key, value] of Object.entries(ctx.data.settings)) {
|
for (const [key, value] of Object.entries(ctx.data.settings)) {
|
||||||
if (!SETTINGS_WHITELIST.has(key)) continue
|
if (!SETTINGS_WHITELIST.has(key)) continue;
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
|
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
|
||||||
VALUES (${key}, ${value})
|
VALUES (${key}, ${value})
|
||||||
ON DUPLICATE KEY UPDATE \`value\` = ${value}
|
ON DUPLICATE KEY UPDATE \`value\` = ${value}
|
||||||
`
|
`;
|
||||||
}
|
}
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+10
-1
@@ -71,7 +71,16 @@ export async function deleteRoom({ id }: { id: number }) {
|
|||||||
revalidatePath("/admin/rooms");
|
revalidatePath("/admin/rooms");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRoom({ id, ...data }: { id: number; name?: string; description?: string; state?: string; usersMax?: number }) {
|
export async function updateRoom({
|
||||||
|
id,
|
||||||
|
...data
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
state?: string;
|
||||||
|
usersMax?: number;
|
||||||
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
await prisma.rooms.update({ where: { id }, data: data as any });
|
await prisma.rooms.update({ where: { id }, data: data as any });
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -10,7 +10,19 @@ export async function deleteSoundtrack({ id }: { id: number }) {
|
|||||||
revalidatePath("/admin/sounds");
|
revalidatePath("/admin/sounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSoundtrack({ id, name, author, track, length }: { id: number; name: string; author: string; track: string; length: number }) {
|
export async function updateSoundtrack({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
author,
|
||||||
|
track,
|
||||||
|
length,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
track: string;
|
||||||
|
length: number;
|
||||||
|
}) {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
await prisma.soundtracks.update({
|
await prisma.soundtracks.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -1,47 +1,45 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
const templateSchema = z.object({
|
const templateSchema = z.object({
|
||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
content: z.string().min(1),
|
content: z.string().min(1),
|
||||||
category: z.string().max(50).optional().default('general'),
|
category: z.string().max(50).optional().default("general"),
|
||||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createTemplate = adminAction(
|
export const createTemplate = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data })
|
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
|
||||||
return actionOk({ id: tpl.id })
|
return actionOk({ id: tpl.id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateTemplateInput = templateSchema
|
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||||
.partial()
|
|
||||||
.extend({ id: z.coerce.number().int().positive() })
|
|
||||||
|
|
||||||
export const updateTemplate = adminAction(
|
export const updateTemplate = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data
|
const { id, ...data } = ctx.data;
|
||||||
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } })
|
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } });
|
||||||
if (!existing) throw new ActionError('Template not found')
|
if (!existing) throw new ActionError("Template not found");
|
||||||
await prisma.websiteTicketTemplate.update({ where: { id }, data })
|
await prisma.websiteTicketTemplate.update({ where: { id }, data });
|
||||||
return actionOk({ id })
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() })
|
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const deleteTemplate = adminAction(
|
export const deleteTemplate = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } })
|
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+73
-73
@@ -1,18 +1,18 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction, authAction } from '@/lib/safe-action'
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { notify } from '@/lib/services/webhook'
|
import { notify } from "@/lib/services/webhook";
|
||||||
import {
|
import {
|
||||||
assignTicketSchema,
|
assignTicketSchema,
|
||||||
createTicketSchema,
|
createTicketSchema,
|
||||||
replyTicketSchema,
|
replyTicketSchema,
|
||||||
updateTicketPrioritySchema,
|
updateTicketPrioritySchema,
|
||||||
updateTicketStatusSchema,
|
updateTicketStatusSchema,
|
||||||
} from '@/lib/validators/ticket'
|
} from "@/lib/validators/ticket";
|
||||||
|
|
||||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
|
|||||||
category: ctx.data.category,
|
category: ctx.data.category,
|
||||||
creatorId: ctx.session.user.id,
|
creatorId: ctx.session.user.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Create the first message
|
// Create the first message
|
||||||
await prisma.websiteTicketMessage.create({
|
await prisma.websiteTicketMessage.create({
|
||||||
@@ -33,26 +33,26 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
|
|||||||
message: ctx.data.message,
|
message: ctx.data.message,
|
||||||
isStaff: 0,
|
isStaff: 0,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'ticket_create',
|
action: "ticket_create",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: `#${ticket.id} - ${ticket.subject}`,
|
target: `#${ticket.id} - ${ticket.subject}`,
|
||||||
details: `Category: ${ticket.category}`,
|
details: `Category: ${ticket.category}`,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk({ id: ticket.id })
|
return actionOk({ id: ticket.id });
|
||||||
})
|
});
|
||||||
|
|
||||||
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
|
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
|
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||||
if (ticket.status === 'closed') throw new ActionError('Ticket is closed')
|
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
|
||||||
|
|
||||||
await prisma.websiteTicketMessage.create({
|
await prisma.websiteTicketMessage.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -61,38 +61,38 @@ export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (
|
|||||||
message: ctx.data.message,
|
message: ctx.data.message,
|
||||||
isStaff: 0,
|
isStaff: 0,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// If ticket was in "waiting" (waiting for user), move back to open
|
// If ticket was in "waiting" (waiting for user), move back to open
|
||||||
if (ticket.status === 'waiting') {
|
if (ticket.status === "waiting") {
|
||||||
await prisma.websiteTicket.update({
|
await prisma.websiteTicket.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: { status: 'open' },
|
data: { status: "open" },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
})
|
});
|
||||||
|
|
||||||
export const closeTicketByUser = authAction(
|
export const closeTicketByUser = authAction(
|
||||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
|
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||||
if (ticket.status === 'closed') throw new ActionError('Ticket is already closed')
|
if (ticket.status === "closed") throw new ActionError("Ticket is already closed");
|
||||||
|
|
||||||
await prisma.websiteTicket.update({
|
await prisma.websiteTicket.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: { status: 'closed', closedAt: new Date() },
|
data: { status: "closed", closedAt: new Date() },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Admin actions ────────────────────────────────────────────────────
|
// ── Admin actions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -101,9 +101,9 @@ export const adminReplyTicket = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
await prisma.websiteTicketMessage.create({
|
await prisma.websiteTicketMessage.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -112,118 +112,118 @@ export const adminReplyTicket = adminAction(
|
|||||||
message: ctx.data.message,
|
message: ctx.data.message,
|
||||||
isStaff: 1,
|
isStaff: 1,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Auto-assign if not assigned yet
|
// Auto-assign if not assigned yet
|
||||||
const updates: Record<string, unknown> = { status: 'waiting' }
|
const updates: Record<string, unknown> = { status: "waiting" };
|
||||||
if (!ticket.assigneeId) {
|
if (!ticket.assigneeId) {
|
||||||
updates.assigneeId = ctx.session.user.id
|
updates.assigneeId = ctx.session.user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.websiteTicket.update({
|
await prisma.websiteTicket.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: updates,
|
data: updates,
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'ticket_reply',
|
action: "ticket_reply",
|
||||||
target: 'WebsiteTicket',
|
target: "WebsiteTicket",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export const updateTicketStatus = adminAction(
|
export const updateTicketStatus = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
const data: Record<string, unknown> = { status: ctx.data.status }
|
const data: Record<string, unknown> = { status: ctx.data.status };
|
||||||
if (ctx.data.status === 'closed') {
|
if (ctx.data.status === "closed") {
|
||||||
data.closedAt = new Date()
|
data.closedAt = new Date();
|
||||||
}
|
}
|
||||||
if (ctx.data.status === 'in_progress' && !ticket.assigneeId) {
|
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
|
||||||
data.assigneeId = ctx.session.user.id
|
data.assigneeId = ctx.session.user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.websiteTicket.update({
|
await prisma.websiteTicket.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data,
|
data,
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'ticket_status_change',
|
action: "ticket_status_change",
|
||||||
target: 'WebsiteTicket',
|
target: "WebsiteTicket",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
before: { status: ticket.status },
|
before: { status: ticket.status },
|
||||||
after: { status: ctx.data.status },
|
after: { status: ctx.data.status },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export const assignTicket = adminAction(
|
export const assignTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
await prisma.websiteTicket.update({
|
await prisma.websiteTicket.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: {
|
data: {
|
||||||
assigneeId: ctx.data.assigneeId,
|
assigneeId: ctx.data.assigneeId,
|
||||||
status: ctx.data.assigneeId ? 'in_progress' : 'open',
|
status: ctx.data.assigneeId ? "in_progress" : "open",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'ticket_assign',
|
action: "ticket_assign",
|
||||||
target: 'WebsiteTicket',
|
target: "WebsiteTicket",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
before: { assigneeId: ticket.assigneeId },
|
before: { assigneeId: ticket.assigneeId },
|
||||||
after: { assigneeId: ctx.data.assigneeId },
|
after: { assigneeId: ctx.data.assigneeId },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export const updateTicketPriority = adminAction(
|
export const updateTicketPriority = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!ticket) throw new ActionError('Ticket not found')
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
await prisma.websiteTicket.update({
|
await prisma.websiteTicket.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
data: { priority: ctx.data.priority },
|
data: { priority: ctx.data.priority },
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'ticket_priority_change',
|
action: "ticket_priority_change",
|
||||||
target: 'WebsiteTicket',
|
target: "WebsiteTicket",
|
||||||
targetId: ctx.data.ticketId,
|
targetId: ctx.data.ticketId,
|
||||||
before: { priority: ticket.priority },
|
before: { priority: ticket.priority },
|
||||||
after: { priority: ctx.data.priority },
|
after: { priority: ctx.data.priority },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+53
-56
@@ -1,76 +1,73 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import fs from 'node:fs/promises'
|
import fs from "node:fs/promises";
|
||||||
import path from 'node:path'
|
import path from "node:path";
|
||||||
import JSON5 from 'json5'
|
import JSON5 from "json5";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
|
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
|
||||||
import { patchJson5 } from '@/lib/json5-patch'
|
import { patchJson5 } from "@/lib/json5-patch";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
const saveTranslationsSchema = z.object({
|
const saveTranslationsSchema = z.object({
|
||||||
locale: z.enum(['en', 'it']),
|
locale: z.enum(["en", "it"]),
|
||||||
data: z.record(z.string(), z.unknown()),
|
data: z.record(z.string(), z.unknown()),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
|
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
|
||||||
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
|
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||||
|
|
||||||
const filePath = path.join(process.cwd(), 'messages', `${ctx.data.locale}.json`)
|
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
|
||||||
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), 'utf-8')
|
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
})
|
});
|
||||||
|
|
||||||
const saveClientTranslationsSchema = z.object({
|
const saveClientTranslationsSchema = z.object({
|
||||||
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
|
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
|
||||||
data: z.record(z.string(), z.string()),
|
data: z.record(z.string(), z.string()),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const saveClientTranslations = adminAction(
|
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => {
|
||||||
{ schema: saveClientTranslationsSchema },
|
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||||
async (ctx) => {
|
|
||||||
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
|
|
||||||
|
|
||||||
const file = getClientTranslationFile(ctx.data.fileId)
|
const file = getClientTranslationFile(ctx.data.fileId);
|
||||||
if (!file) throw new ActionError('Unknown file')
|
if (!file) throw new ActionError("Unknown file");
|
||||||
if (file.readOnly) throw new ActionError('File is read-only')
|
if (file.readOnly) throw new ActionError("File is read-only");
|
||||||
|
|
||||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||||
// Turbopack's static tracer can't prove that — without the hint it
|
// Turbopack's static tracer can't prove that — without the hint it
|
||||||
// pulls the entire project into the NFT list.
|
// pulls the entire project into the NFT list.
|
||||||
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath)
|
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath);
|
||||||
const raw = await fs.readFile(absPath, 'utf-8')
|
const raw = await fs.readFile(absPath, "utf-8");
|
||||||
|
|
||||||
if (file.format === 'json') {
|
if (file.format === "json") {
|
||||||
// Plain JSON — no comments to preserve, just round-trip.
|
// Plain JSON — no comments to preserve, just round-trip.
|
||||||
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), 'utf-8')
|
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8");
|
||||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] })
|
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON5: surgical line-level patch keeps headers and section comments
|
||||||
|
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
||||||
|
// only when an edited key cannot be located via the patch contract.
|
||||||
|
const original: Record<string, string> = {};
|
||||||
|
const parsed = JSON5.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
original[k] = v == null ? "" : String(v);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// JSON5: surgical line-level patch keeps headers and section comments
|
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
||||||
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
|
||||||
// only when an edited key cannot be located via the patch contract.
|
|
||||||
const original: Record<string, string> = {}
|
|
||||||
const parsed = JSON5.parse(raw)
|
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
for (const [k, v] of Object.entries(parsed)) {
|
|
||||||
original[k] = v == null ? '' : String(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data)
|
if (unpatchedKeys.length === 0) {
|
||||||
|
await fs.writeFile(absPath, content, "utf-8");
|
||||||
|
return actionOk({ commentsLost: false, unpatchedKeys });
|
||||||
|
}
|
||||||
|
|
||||||
if (unpatchedKeys.length === 0) {
|
// At least one key could not be patched surgically (e.g. unusual
|
||||||
await fs.writeFile(absPath, content, 'utf-8')
|
// formatting or a brand-new key). Fall back to a full re-serialization
|
||||||
return actionOk({ commentsLost: false, unpatchedKeys })
|
// and warn the caller that comments were lost.
|
||||||
}
|
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8");
|
||||||
|
return actionOk({ commentsLost: true, unpatchedKeys });
|
||||||
// At least one key could not be patched surgically (e.g. unusual
|
});
|
||||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
|
||||||
// and warn the caller that comments were lost.
|
|
||||||
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), 'utf-8')
|
|
||||||
return actionOk({ commentsLost: true, unpatchedKeys })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
+192
-213
@@ -1,36 +1,31 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import crypto from 'node:crypto'
|
import crypto from "node:crypto";
|
||||||
import { hash } from 'bcryptjs'
|
import { hash } from "bcryptjs";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { Prisma } from '@/generated/prisma/client'
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from '@/lib/safe-action-shared'
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from '@/lib/services/audit'
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { rcon } from '@/lib/services/rcon'
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { notify } from '@/lib/services/webhook'
|
import { notify } from "@/lib/services/webhook";
|
||||||
import {
|
import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user";
|
||||||
banUserSchema,
|
|
||||||
createUserSchema,
|
|
||||||
giveBadgeSchema,
|
|
||||||
updateUserSchema,
|
|
||||||
} from '@/lib/validators/user'
|
|
||||||
|
|
||||||
const DEFAULT_LOOK = 'hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64'
|
const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
|
||||||
|
|
||||||
export const createUser = adminAction(
|
export const createUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { username, mail, password, rank, motto } = ctx.data
|
const { username, mail, password, rank, motto } = ctx.data;
|
||||||
|
|
||||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||||
throw new ActionError('Cannot assign rank equal or higher than your own')
|
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hash(password, 12)
|
const hashedPassword = await hash(password, 12);
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await prisma.$transaction(async (tx) => {
|
const user = await prisma.$transaction(async (tx) => {
|
||||||
@@ -45,221 +40,211 @@ export const createUser = adminAction(
|
|||||||
credits: 5000,
|
credits: 5000,
|
||||||
pixels: 5000,
|
pixels: 5000,
|
||||||
accountCreated: now,
|
accountCreated: now,
|
||||||
ipRegister: '0.0.0.0',
|
ipRegister: "0.0.0.0",
|
||||||
ipCurrent: '0.0.0.0',
|
ipCurrent: "0.0.0.0",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
await tx.usersSettings.create({ data: { userId: created.id } })
|
await tx.usersSettings.create({ data: { userId: created.id } });
|
||||||
await tx.usersCurrency.createMany({
|
await tx.usersCurrency.createMany({
|
||||||
data: [
|
data: [
|
||||||
{ userId: created.id, type: 0, amount: 5000 },
|
{ userId: created.id, type: 0, amount: 5000 },
|
||||||
{ userId: created.id, type: 5, amount: 5000 },
|
{ userId: created.id, type: 5, amount: 5000 },
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'user_create',
|
action: "user_create",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
after: { username, mail, rank },
|
after: { username, mail, rank },
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'user_edit',
|
action: "user_edit",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: username,
|
target: username,
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
details: 'Account created by admin',
|
details: "Account created by admin",
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk({ id: user.id, username: user.username })
|
return actionOk({ id: user.id, username: user.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||||
const target = (err.meta?.target as string[]) ?? []
|
const target = (err.meta?.target as string[]) ?? [];
|
||||||
if (target.includes('username')) throw new ActionError('Username already taken')
|
if (target.includes("username")) throw new ActionError("Username already taken");
|
||||||
if (target.includes('mail')) throw new ActionError('Email already registered')
|
if (target.includes("mail")) throw new ActionError("Email already registered");
|
||||||
throw new ActionError('Username or email already in use')
|
throw new ActionError("Username or email already in use");
|
||||||
}
|
}
|
||||||
throw err
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateUserInput = updateUserSchema.extend({
|
const updateUserInput = updateUserSchema.extend({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateUser = adminAction(
|
export const updateUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, diamonds, duckets, ...userData } = ctx.data
|
const { id, diamonds, duckets, ...userData } = ctx.data;
|
||||||
|
|
||||||
const targetUser = await guardRank(id, ctx.session.user.rank)
|
const targetUser = await guardRank(id, ctx.session.user.rank);
|
||||||
|
|
||||||
if (
|
if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||||
userData.rank !== undefined &&
|
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||||
userData.rank >= ctx.session.user.rank &&
|
|
||||||
ctx.session.user.rank < 7
|
|
||||||
) {
|
|
||||||
throw new ActionError('Cannot assign rank equal or higher than your own')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({ where: { id }, data: userData })
|
await prisma.user.update({ where: { id }, data: userData });
|
||||||
|
|
||||||
if (diamonds !== undefined) {
|
if (diamonds !== undefined) {
|
||||||
await prisma.usersCurrency.upsert({
|
await prisma.usersCurrency.upsert({
|
||||||
where: { userId_type: { userId: id, type: 5 } },
|
where: { userId_type: { userId: id, type: 5 } },
|
||||||
update: { amount: diamonds },
|
update: { amount: diamonds },
|
||||||
create: { userId: id, type: 5, amount: diamonds },
|
create: { userId: id, type: 5, amount: diamonds },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
if (duckets !== undefined) {
|
if (duckets !== undefined) {
|
||||||
await prisma.usersCurrency.upsert({
|
await prisma.usersCurrency.upsert({
|
||||||
where: { userId_type: { userId: id, type: 0 } },
|
where: { userId_type: { userId: id, type: 0 } },
|
||||||
update: { amount: duckets },
|
update: { amount: duckets },
|
||||||
create: { userId: id, type: 0, amount: duckets },
|
create: { userId: id, type: 0, amount: duckets },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'user_edit',
|
action: "user_edit",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
|
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
|
||||||
after: userData,
|
after: userData,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'user_edit',
|
action: "user_edit",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: targetUser.username,
|
target: targetUser.username,
|
||||||
targetId: id,
|
targetId: id,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const banInput = banUserSchema.extend({})
|
const banInput = banUserSchema.extend({});
|
||||||
|
|
||||||
export const banUser = adminAction(
|
export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => {
|
||||||
{ permission: PERMS.USERS_BAN, schema: banInput },
|
const { userId, reason, duration, type, ip } = ctx.data;
|
||||||
async (ctx) => {
|
|
||||||
const { userId, reason, duration, type, ip } = ctx.data
|
|
||||||
|
|
||||||
const targetUser = await guardRank(userId, ctx.session.user.rank)
|
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const banExpire = duration > 0 ? now + duration * 3600 : 0
|
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||||
|
|
||||||
await prisma.ban.create({
|
await prisma.ban.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
userStaffId: ctx.session.user.id,
|
userStaffId: ctx.session.user.id,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
banExpire,
|
banExpire,
|
||||||
banReason: reason,
|
banReason: reason,
|
||||||
type: type || 'account',
|
type: type || "account",
|
||||||
ip: ip || '',
|
ip: ip || "",
|
||||||
machineId: '',
|
machineId: "",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
await rcon.disconnectUser(userId)
|
await rcon.disconnectUser(userId);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'ban',
|
action: "ban",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
after: { reason, type, duration },
|
after: { reason, type, duration },
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'ban',
|
action: "ban",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: targetUser.username,
|
target: targetUser.username,
|
||||||
details: reason,
|
details: reason,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
});
|
||||||
)
|
|
||||||
|
|
||||||
const unbanInput = z.object({ userId: z.coerce.number().int().positive() })
|
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const unbanUser = adminAction(
|
export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => {
|
||||||
{ permission: PERMS.USERS_BAN, schema: unbanInput },
|
const { userId } = ctx.data;
|
||||||
async (ctx) => {
|
|
||||||
const { userId } = ctx.data
|
|
||||||
|
|
||||||
const targetUser = await guardRank(userId, ctx.session.user.rank)
|
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||||
|
|
||||||
await prisma.ban.deleteMany({ where: { userId } })
|
await prisma.ban.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'unban',
|
action: "unban",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'unban',
|
action: "unban",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: targetUser.username,
|
target: targetUser.username,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
});
|
||||||
)
|
|
||||||
|
|
||||||
export const giveBadge = adminAction(
|
export const giveBadge = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { userId, badgeCode } = ctx.data
|
const { userId, badgeCode } = ctx.data;
|
||||||
|
|
||||||
await guardRank(userId, ctx.session.user.rank)
|
await guardRank(userId, ctx.session.user.rank);
|
||||||
|
|
||||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
|
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
|
||||||
if (existing) throw new ActionError('Badge already assigned')
|
if (existing) throw new ActionError("Badge already assigned");
|
||||||
|
|
||||||
await prisma.usersBadges.create({ data: { userId, badgeCode } })
|
await prisma.usersBadges.create({ data: { userId, badgeCode } });
|
||||||
await rcon.giveBadge(userId, badgeCode)
|
await rcon.giveBadge(userId, badgeCode);
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Remove Badge ────────────────────────────────────────────────────
|
// ── Remove Badge ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const removeBadgeSchema = z.object({
|
const removeBadgeSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
badgeCode: z.string().min(1),
|
badgeCode: z.string().min(1),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const removeBadge = adminAction(
|
export const removeBadge = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { userId, badgeCode } = ctx.data
|
const { userId, badgeCode } = ctx.data;
|
||||||
|
|
||||||
await guardRank(userId, ctx.session.user.rank)
|
await guardRank(userId, ctx.session.user.rank);
|
||||||
|
|
||||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
|
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
|
||||||
if (!existing) throw new ActionError('Badge not found')
|
if (!existing) throw new ActionError("Badge not found");
|
||||||
|
|
||||||
await prisma.usersBadges.delete({ where: { id: existing.id } })
|
await prisma.usersBadges.delete({ where: { id: existing.id } });
|
||||||
await rcon.removeBadge(userId, badgeCode)
|
await rcon.removeBadge(userId, badgeCode);
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Rank guard helper ───────────────────────────────────────────────
|
// ── Rank guard helper ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -267,169 +252,163 @@ async function guardRank(targetUserId: number, sessionRank: number) {
|
|||||||
const target = await prisma.user.findUnique({
|
const target = await prisma.user.findUnique({
|
||||||
where: { id: targetUserId },
|
where: { id: targetUserId },
|
||||||
select: { username: true, rank: true, mail: true },
|
select: { username: true, rank: true, mail: true },
|
||||||
})
|
});
|
||||||
if (!target) throw new ActionError('User not found')
|
if (!target) throw new ActionError("User not found");
|
||||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||||
throw new ActionError('Cannot modify user with equal or higher rank')
|
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||||
}
|
}
|
||||||
return target
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reset Password ──────────────────────────────────────────────────
|
// ── Reset Password ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const resetPasswordSchema = z.object({
|
const resetPasswordSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const resetPassword = adminAction(
|
export const resetPassword = adminAction(
|
||||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
|
|
||||||
const newPassword = crypto.randomBytes(12).toString('base64url').slice(0, 16)
|
const newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16);
|
||||||
const hashed = await hash(newPassword, 10)
|
const hashed = await hash(newPassword, 10);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: ctx.data.userId },
|
where: { id: ctx.data.userId },
|
||||||
data: { password: hashed },
|
data: { password: hashed },
|
||||||
})
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'reset_password',
|
action: "reset_password",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'user_edit',
|
action: "user_edit",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: target.username,
|
target: target.username,
|
||||||
details: 'Password reset',
|
details: "Password reset",
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk({ newPassword })
|
return actionOk({ newPassword });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Disconnect User ─────────────────────────────────────────────────
|
// ── Disconnect User ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const disconnectSchema = z.object({
|
const disconnectSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const disconnectUser = adminAction(
|
export const disconnectUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
const success = await rcon.disconnectUser(ctx.data.userId)
|
const success = await rcon.disconnectUser(ctx.data.userId);
|
||||||
if (!success) throw new ActionError('Failed to disconnect. Is the emulator running?')
|
if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?");
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'user_disconnect',
|
action: "user_disconnect",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: 'disconnect',
|
action: "disconnect",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: target.username,
|
target: target.username,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Alert User (in-game message) ────────────────────────────────────
|
// ── Alert User (in-game message) ────────────────────────────────────
|
||||||
|
|
||||||
const alertUserSchema = z.object({
|
const alertUserSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
message: z.string().min(1).max(500),
|
message: z.string().min(1).max(500),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const alertUser = adminAction(
|
export const alertUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message)
|
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||||
if (!success) throw new ActionError('Failed to send alert. Is the emulator running?')
|
if (!success) throw new ActionError("Failed to send alert. Is the emulator running?");
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// ── Mute User ───────────────────────────────────────────────────────
|
// ── Mute User ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const muteSchema = z.object({
|
const muteSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
duration: z.coerce.number().int().min(0).default(0),
|
duration: z.coerce.number().int().min(0).default(0),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const muteUser = adminAction(
|
export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => {
|
||||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
async (ctx) => {
|
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
if (!success) throw new ActionError("Failed to mute. Is the emulator running?");
|
||||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration)
|
|
||||||
if (!success) throw new ActionError('Failed to mute. Is the emulator running?')
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'user_mute',
|
action: "user_mute",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
after: { duration: ctx.data.duration },
|
after: { duration: ctx.data.duration },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
});
|
||||||
)
|
|
||||||
|
|
||||||
// ── Unmute User ─────────────────────────────────────────────────────
|
// ── Unmute User ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const unmuteSchema = z.object({
|
const unmuteSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const unmuteUser = adminAction(
|
export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => {
|
||||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
async (ctx) => {
|
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||||
await guardRank(ctx.data.userId, ctx.session.user.rank)
|
if (!success) throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||||
const success = await rcon.unmuteUser(ctx.data.userId)
|
|
||||||
if (!success) throw new ActionError('Failed to unmute. Is the emulator running?')
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'user_unmute',
|
action: "user_unmute",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
});
|
||||||
)
|
|
||||||
|
|
||||||
// ── Send Credits via RCON ───────────────────────────────────────────
|
// ── Send Credits via RCON ───────────────────────────────────────────
|
||||||
|
|
||||||
const sendCreditsSchema = z.object({
|
const sendCreditsSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
amount: z.coerce.number().int().min(1).max(1000000),
|
amount: z.coerce.number().int().min(1).max(1000000),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const sendCredits = adminAction(
|
export const sendCredits = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount)
|
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||||
if (!success) throw new ActionError('Failed to send credits. Is the emulator running?')
|
if (!success) throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: 'user_send_credits',
|
action: "user_send_credits",
|
||||||
target: 'User',
|
target: "User",
|
||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
after: { amount: ctx.data.amount },
|
after: { amount: ctx.data.amount },
|
||||||
})
|
});
|
||||||
|
|
||||||
return actionOk()
|
return actionOk();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
+19
-19
@@ -1,16 +1,16 @@
|
|||||||
'use server'
|
"use server";
|
||||||
|
|
||||||
import { revalidateTag } from 'next/cache'
|
import { revalidateTag } from "next/cache";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { PERMS } from '@/lib/permission-slugs'
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from '@/lib/safe-action'
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from '@/lib/safe-action-shared'
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
const toggleWatchSchema = z.object({
|
const toggleWatchSchema = z.object({
|
||||||
targetUserId: z.coerce.number().int().positive(),
|
targetUserId: z.coerce.number().int().positive(),
|
||||||
reason: z.string().max(255).optional(),
|
reason: z.string().max(255).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle a watch entry for the calling staff on the given target user.
|
* Toggle a watch entry for the calling staff on the given target user.
|
||||||
@@ -21,23 +21,23 @@ const toggleWatchSchema = z.object({
|
|||||||
export const toggleUserWatch = adminAction(
|
export const toggleUserWatch = adminAction(
|
||||||
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
|
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const staffId = ctx.session.user.id
|
const staffId = ctx.session.user.id;
|
||||||
const { targetUserId, reason } = ctx.data
|
const { targetUserId, reason } = ctx.data;
|
||||||
|
|
||||||
const existing = await prisma.userWatch.findUnique({
|
const existing = await prisma.userWatch.findUnique({
|
||||||
where: { staffId_targetUserId: { staffId, targetUserId } },
|
where: { staffId_targetUserId: { staffId, targetUserId } },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.userWatch.delete({ where: { id: existing.id } })
|
await prisma.userWatch.delete({ where: { id: existing.id } });
|
||||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
|
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||||
return actionOk({ watching: false })
|
return actionOk({ watching: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.userWatch.create({
|
await prisma.userWatch.create({
|
||||||
data: { staffId, targetUserId, reason: reason ?? '' },
|
data: { staffId, targetUserId, reason: reason ?? "" },
|
||||||
})
|
});
|
||||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
|
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||||
return actionOk({ watching: true })
|
return actionOk({ watching: true });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { MessageSquare, Shield, Terminal, Users } from 'lucide-react'
|
import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { StatsCard } from '@/components/admin/stats-card'
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
async function getActivityData() {
|
async function getActivityData() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const todayStart = now - (now % 86400)
|
const todayStart = now - (now % 86400);
|
||||||
const weekAgo = now - 7 * 86400
|
const weekAgo = now - 7 * 86400;
|
||||||
|
|
||||||
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
|
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
|
||||||
const rangeStart = todayStart - 6 * 86400
|
const rangeStart = todayStart - 6 * 86400;
|
||||||
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
|
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
|
||||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||||
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
|
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
|
||||||
@@ -25,57 +25,61 @@ async function getActivityData() {
|
|||||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||||
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
|
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
|
||||||
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
|
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
|
||||||
])
|
]);
|
||||||
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
|
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
|
||||||
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]))
|
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||||
const chatMap = toMap(chatRows)
|
const chatMap = toMap(chatRows);
|
||||||
const cmdMap = toMap(cmdRows)
|
const cmdMap = toMap(cmdRows);
|
||||||
const banMap = toMap(banRows)
|
const banMap = toMap(banRows);
|
||||||
const regMap = toMap(regRows)
|
const regMap = toMap(regRows);
|
||||||
|
|
||||||
const dailyData = []
|
const dailyData = [];
|
||||||
for (let i = 6; i >= 0; i--) {
|
for (let i = 6; i >= 0; i--) {
|
||||||
const dayStart = todayStart - i * 86400
|
const dayStart = todayStart - i * 86400;
|
||||||
const bucket = Math.floor(dayStart / 86400)
|
const bucket = Math.floor(dayStart / 86400);
|
||||||
const date = new Date(dayStart * 1000)
|
const date = new Date(dayStart * 1000);
|
||||||
dailyData.push({
|
dailyData.push({
|
||||||
label: date.toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
|
label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric" }),
|
||||||
chats: chatMap.get(bucket) ?? 0,
|
chats: chatMap.get(bucket) ?? 0,
|
||||||
commands: cmdMap.get(bucket) ?? 0,
|
commands: cmdMap.get(bucket) ?? 0,
|
||||||
bans: banMap.get(bucket) ?? 0,
|
bans: banMap.get(bucket) ?? 0,
|
||||||
registrations: regMap.get(bucket) ?? 0,
|
registrations: regMap.get(bucket) ?? 0,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
|
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
|
||||||
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
prisma.$queryRaw<
|
||||||
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
|
{ count: bigint }[]
|
||||||
|
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||||
|
prisma.$queryRaw<
|
||||||
|
{ count: bigint }[]
|
||||||
|
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
|
||||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||||
])
|
]);
|
||||||
const weekChats = Number(weekChatRows[0]?.count ?? 0)
|
const weekChats = Number(weekChatRows[0]?.count ?? 0);
|
||||||
const weekCommands = Number(weekCommandRows[0]?.count ?? 0)
|
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
|
||||||
|
|
||||||
// Most active users (by chat messages)
|
// Most active users (by chat messages)
|
||||||
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
|
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
|
||||||
SELECT user_from_id AS userId, COUNT(*) AS messages
|
SELECT user_from_id AS userId, COUNT(*) AS messages
|
||||||
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
|
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
|
||||||
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
|
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
|
||||||
`
|
`;
|
||||||
|
|
||||||
const userIds = topChatters.map((c) => c.userId)
|
const userIds = topChatters.map((c) => c.userId);
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { id: { in: userIds } },
|
where: { id: { in: userIds } },
|
||||||
select: { id: true, username: true },
|
select: { id: true, username: true },
|
||||||
})
|
});
|
||||||
const userMap = new Map(users.map((u) => [u.id, u.username]))
|
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
const topChattersWithNames = topChatters.map((c) => ({
|
const topChattersWithNames = topChatters.map((c) => ({
|
||||||
userId: c.userId,
|
userId: c.userId,
|
||||||
username: userMap.get(c.userId) ?? `User #${c.userId}`,
|
username: userMap.get(c.userId) ?? `User #${c.userId}`,
|
||||||
messages: Number(c.messages),
|
messages: Number(c.messages),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyData,
|
dailyData,
|
||||||
@@ -84,16 +88,16 @@ async function getActivityData() {
|
|||||||
weekBans,
|
weekBans,
|
||||||
weekRegistrations,
|
weekRegistrations,
|
||||||
topChatters: topChattersWithNames,
|
topChatters: topChattersWithNames,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ActivityPage() {
|
export default async function ActivityPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getActivityData()
|
const data = await getActivityData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -174,10 +178,7 @@ export default async function ActivityPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{data.topChatters.map((user, i) => (
|
{data.topChatters.map((user, i) => (
|
||||||
<div
|
<div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||||
key={user.userId}
|
|
||||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
@@ -193,5 +194,5 @@ export default async function ActivityPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from 'lucide-react'
|
import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from "lucide-react";
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from "next/dynamic";
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { StatsCard } from '@/components/admin/stats-card'
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const RevenueChart = dynamic(
|
const RevenueChart = dynamic(
|
||||||
() => import('@/components/admin/revenue-chart').then((m) => ({ default: m.RevenueChart })),
|
() => import("@/components/admin/revenue-chart").then((m) => ({ default: m.RevenueChart })),
|
||||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||||
)
|
);
|
||||||
|
|
||||||
async function getEconomyData() {
|
async function getEconomyData() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const todayStart = now - (now % 86400)
|
const todayStart = now - (now % 86400);
|
||||||
const weekAgo = now - 7 * 86400
|
const weekAgo = now - 7 * 86400;
|
||||||
|
|
||||||
// Total currency in circulation — parallel instead of sequential
|
// Total currency in circulation — parallel instead of sequential
|
||||||
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
|
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
|
||||||
prisma.user.aggregate({ _sum: { credits: true } }),
|
prisma.user.aggregate({ _sum: { credits: true } }),
|
||||||
prisma.user.aggregate({ _sum: { pixels: true } }),
|
prisma.user.aggregate({ _sum: { pixels: true } }),
|
||||||
prisma.user.aggregate({ _sum: { points: true } }),
|
prisma.user.aggregate({ _sum: { points: true } }),
|
||||||
])
|
]);
|
||||||
const totalCredits = creditsAgg._sum.credits ?? 0
|
const totalCredits = creditsAgg._sum.credits ?? 0;
|
||||||
const totalPixels = pixelsAgg._sum.pixels ?? 0
|
const totalPixels = pixelsAgg._sum.pixels ?? 0;
|
||||||
const totalPoints = pointsAgg._sum.points ?? 0
|
const totalPoints = pointsAgg._sum.points ?? 0;
|
||||||
|
|
||||||
// Shop revenue (7 days)
|
// Shop revenue (7 days)
|
||||||
const shopRevenue = await prisma.logsShopPurchases.aggregate({
|
const shopRevenue = await prisma.logsShopPurchases.aggregate({
|
||||||
where: { timestamp: { gte: weekAgo } },
|
where: { timestamp: { gte: weekAgo } },
|
||||||
_sum: { costCredits: true, costPoints: true },
|
_sum: { costCredits: true, costPoints: true },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Marketplace stats
|
// Marketplace stats
|
||||||
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } })
|
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } });
|
||||||
const marketplaceSold = await prisma.marketplaceItems.count({
|
const marketplaceSold = await prisma.marketplaceItems.count({
|
||||||
where: { state: 2, soldTimestamp: { gte: weekAgo } },
|
where: { state: 2, soldTimestamp: { gte: weekAgo } },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
|
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
|
||||||
const rangeStart = todayStart - 6 * 86400
|
const rangeStart = todayStart - 6 * 86400;
|
||||||
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
|
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
|
||||||
SELECT FLOOR(timestamp / 86400) AS day_bucket,
|
SELECT FLOOR(timestamp / 86400) AS day_bucket,
|
||||||
COALESCE(SUM(cost_credits), 0) AS credits,
|
COALESCE(SUM(cost_credits), 0) AS credits,
|
||||||
@@ -49,60 +49,55 @@ async function getEconomyData() {
|
|||||||
FROM logs_shop_purchases
|
FROM logs_shop_purchases
|
||||||
WHERE timestamp >= ${rangeStart}
|
WHERE timestamp >= ${rangeStart}
|
||||||
GROUP BY day_bucket ORDER BY day_bucket
|
GROUP BY day_bucket ORDER BY day_bucket
|
||||||
`
|
`;
|
||||||
const revMap = new Map(
|
const revMap = new Map(
|
||||||
revRows.map((r) => [
|
revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]),
|
||||||
Number(r.day_bucket),
|
);
|
||||||
{ credits: Number(r.credits), points: Number(r.points) },
|
const revenueData = [];
|
||||||
]),
|
|
||||||
)
|
|
||||||
const revenueData = []
|
|
||||||
for (let i = 6; i >= 0; i--) {
|
for (let i = 6; i >= 0; i--) {
|
||||||
const dayStart = todayStart - i * 86400
|
const dayStart = todayStart - i * 86400;
|
||||||
const bucket = Math.floor(dayStart / 86400)
|
const bucket = Math.floor(dayStart / 86400);
|
||||||
const date = new Date(dayStart * 1000)
|
const date = new Date(dayStart * 1000);
|
||||||
const entry = revMap.get(bucket)
|
const entry = revMap.get(bucket);
|
||||||
revenueData.push({
|
revenueData.push({
|
||||||
label: date.toLocaleDateString('en', { weekday: 'short' }),
|
label: date.toLocaleDateString("en", { weekday: "short" }),
|
||||||
credits: entry?.credits ?? 0,
|
credits: entry?.credits ?? 0,
|
||||||
points: entry?.points ?? 0,
|
points: entry?.points ?? 0,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trades (7 days)
|
// Trades (7 days)
|
||||||
const weekTrades = await prisma.roomTradeLog.count({
|
const weekTrades = await prisma.roomTradeLog.count({
|
||||||
where: { timestamp: { gte: weekAgo } },
|
where: { timestamp: { gte: weekAgo } },
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCredits,
|
totalCredits,
|
||||||
totalPixels,
|
totalPixels,
|
||||||
totalPoints,
|
totalPoints,
|
||||||
shopPurchases: typeof shopRevenue._count === 'number' ? shopRevenue._count : 0,
|
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
|
||||||
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
|
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
|
||||||
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
|
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
|
||||||
marketplaceActive,
|
marketplaceActive,
|
||||||
marketplaceSold,
|
marketplaceSold,
|
||||||
weekTrades,
|
weekTrades,
|
||||||
revenueData,
|
revenueData,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function EconomyPage() {
|
export default async function EconomyPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getEconomyData()
|
const data = await getEconomyData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p>
|
||||||
Currency circulation, shop revenue and marketplace activity
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -196,5 +191,5 @@ export default async function EconomyPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,27 @@
|
|||||||
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from 'lucide-react'
|
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from "next/dynamic";
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { StatsCard } from '@/components/admin/stats-card'
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const DashboardChart = dynamic(
|
const DashboardChart = dynamic(
|
||||||
() => import('@/components/admin/dashboard-chart').then((m) => ({ default: m.DashboardChart })),
|
() => import("@/components/admin/dashboard-chart").then((m) => ({ default: m.DashboardChart })),
|
||||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||||
)
|
);
|
||||||
|
|
||||||
const PeakHoursHeatmap = dynamic(
|
const PeakHoursHeatmap = dynamic(
|
||||||
() =>
|
() => import("@/components/admin/peak-hours-heatmap").then((m) => ({ default: m.PeakHoursHeatmap })),
|
||||||
import('@/components/admin/peak-hours-heatmap').then((m) => ({ default: m.PeakHoursHeatmap })),
|
|
||||||
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
|
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
|
||||||
)
|
);
|
||||||
|
|
||||||
async function getAnalyticsData() {
|
async function getAnalyticsData() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const todayStart = now - (now % 86400)
|
const todayStart = now - (now % 86400);
|
||||||
const weekAgo = now - 7 * 86400
|
const weekAgo = now - 7 * 86400;
|
||||||
const monthAgo = now - 30 * 86400
|
const monthAgo = now - 30 * 86400;
|
||||||
|
|
||||||
const [
|
const [
|
||||||
totalUsers,
|
totalUsers,
|
||||||
@@ -36,15 +35,17 @@ async function getAnalyticsData() {
|
|||||||
weekBans,
|
weekBans,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
prisma.user.count({ where: { online: '1' } }),
|
prisma.user.count({ where: { online: "1" } }),
|
||||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||||
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
|
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
|
||||||
prisma.rooms.count(),
|
prisma.rooms.count(),
|
||||||
prisma.rooms.count({ where: { users: { gt: 0 } } }),
|
prisma.rooms.count({ where: { users: { gt: 0 } } }),
|
||||||
prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
prisma.$queryRaw<
|
||||||
|
{ count: bigint }[]
|
||||||
|
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||||
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
|
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
|
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
|
||||||
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||||
@@ -52,17 +53,17 @@ async function getAnalyticsData() {
|
|||||||
FROM users
|
FROM users
|
||||||
WHERE account_created >= ${monthAgo}
|
WHERE account_created >= ${monthAgo}
|
||||||
GROUP BY day_bucket ORDER BY day_bucket
|
GROUP BY day_bucket ORDER BY day_bucket
|
||||||
`
|
`;
|
||||||
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]))
|
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||||
const registrationChart = []
|
const registrationChart = [];
|
||||||
for (let i = 29; i >= 0; i--) {
|
for (let i = 29; i >= 0; i--) {
|
||||||
const dayStart = todayStart - i * 86400
|
const dayStart = todayStart - i * 86400;
|
||||||
const bucket = Math.floor(dayStart / 86400)
|
const bucket = Math.floor(dayStart / 86400);
|
||||||
const date = new Date(dayStart * 1000)
|
const date = new Date(dayStart * 1000);
|
||||||
registrationChart.push({
|
registrationChart.push({
|
||||||
label: date.toLocaleDateString('en', { month: 'short', day: 'numeric' }),
|
label: date.toLocaleDateString("en", { month: "short", day: "numeric" }),
|
||||||
value: regCountMap.get(bucket) ?? 0,
|
value: regCountMap.get(bucket) ?? 0,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peak hours — single GROUP BY query instead of fetching all user rows
|
// Peak hours — single GROUP BY query instead of fetching all user rows
|
||||||
@@ -73,13 +74,13 @@ async function getAnalyticsData() {
|
|||||||
FROM users
|
FROM users
|
||||||
WHERE last_online >= ${weekAgo}
|
WHERE last_online >= ${weekAgo}
|
||||||
GROUP BY d, h
|
GROUP BY d, h
|
||||||
`
|
`;
|
||||||
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
|
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||||
let max = 0
|
let max = 0;
|
||||||
for (const r of peakRows) {
|
for (const r of peakRows) {
|
||||||
const count = Number(r.c)
|
const count = Number(r.c);
|
||||||
grid[r.d][r.h] = count
|
grid[r.d][r.h] = count;
|
||||||
if (count > max) max = count
|
if (count > max) max = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -94,16 +95,16 @@ async function getAnalyticsData() {
|
|||||||
weekBans,
|
weekBans,
|
||||||
registrationChart,
|
registrationChart,
|
||||||
peakHours: { grid, max },
|
peakHours: { grid, max },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AnalyticsPage() {
|
export default async function AnalyticsPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getAnalyticsData()
|
const data = await getAnalyticsData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -187,5 +188,5 @@ export default async function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,11 @@ export default async function EditArticle({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
|
{error ? (
|
||||||
|
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||||
|
Error: {error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<ArticleForm
|
<ArticleForm
|
||||||
action={async (fd) => {
|
action={async (fd) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
|
{error ? (
|
||||||
|
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||||
|
Error: {error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<ArticleForm action={createArticle} />
|
<ArticleForm action={createArticle} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">Error: {error}</p> : null}
|
{error ? (
|
||||||
|
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||||
|
Error: {error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { createBanner, deleteBanner, updateBanner } from '@/actions/banners'
|
import { createBanner, deleteBanner, updateBanner } from "@/actions/banners";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { DateTimePicker } from '@/components/ui/date-time-picker'
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
interface Banner {
|
interface Banner {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
subtitle: string
|
subtitle: string;
|
||||||
image: string
|
image: string;
|
||||||
link: string
|
link: string;
|
||||||
color: string
|
color: string;
|
||||||
isActive: number
|
isActive: number;
|
||||||
sortOrder: number
|
sortOrder: number;
|
||||||
startDate: string | null
|
startDate: string | null;
|
||||||
endDate: string | null
|
endDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
banners: Banner[]
|
banners: Banner[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BannersManager({ banners }: Props) {
|
export function BannersManager({ banners }: Props) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '',
|
title: "",
|
||||||
subtitle: '',
|
subtitle: "",
|
||||||
image: '',
|
image: "",
|
||||||
link: '',
|
link: "",
|
||||||
color: '',
|
color: "",
|
||||||
isActive: 1,
|
isActive: 1,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
startDate: '' as string | null,
|
startDate: "" as string | null,
|
||||||
endDate: '' as string | null,
|
endDate: "" as string | null,
|
||||||
})
|
});
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setForm({
|
setForm({
|
||||||
title: '',
|
title: "",
|
||||||
subtitle: '',
|
subtitle: "",
|
||||||
image: '',
|
image: "",
|
||||||
link: '',
|
link: "",
|
||||||
color: '',
|
color: "",
|
||||||
isActive: 1,
|
isActive: 1,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
startDate: '',
|
startDate: "",
|
||||||
endDate: '',
|
endDate: "",
|
||||||
})
|
});
|
||||||
setEditingId(null)
|
setEditingId(null);
|
||||||
setShowForm(false)
|
setShowForm(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(b: Banner) {
|
function handleEdit(b: Banner) {
|
||||||
@@ -72,20 +72,20 @@ export function BannersManager({ banners }: Props) {
|
|||||||
sortOrder: b.sortOrder,
|
sortOrder: b.sortOrder,
|
||||||
startDate: b.startDate,
|
startDate: b.startDate,
|
||||||
endDate: b.endDate,
|
endDate: b.endDate,
|
||||||
})
|
});
|
||||||
setEditingId(b.id)
|
setEditingId(b.id);
|
||||||
setShowForm(true)
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null }
|
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null };
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
run(() => updateBanner({ id: editingId, ...data }), { successMessage: 'Banner updated!' })
|
run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" });
|
||||||
} else {
|
} else {
|
||||||
run(() => createBanner(data), { successMessage: 'Banner created!' })
|
run(() => createBanner(data), { successMessage: "Banner created!" });
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,8 +93,8 @@ export function BannersManager({ banners }: Props) {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetForm()
|
resetForm();
|
||||||
setShowForm(!showForm)
|
setShowForm(!showForm);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" /> New Banner
|
<Plus className="h-4 w-4 mr-2" /> New Banner
|
||||||
@@ -104,7 +104,7 @@ export function BannersManager({ banners }: Props) {
|
|||||||
{showForm && (
|
{showForm && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{editingId ? 'Edit Banner' : 'New Banner'}</CardTitle>
|
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -136,10 +136,7 @@ export function BannersManager({ banners }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Link URL</Label>
|
<Label>Link URL</Label>
|
||||||
<Input
|
<Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} />
|
||||||
value={form.link}
|
|
||||||
onChange={(e) => setForm({ ...form, link: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@@ -147,7 +144,7 @@ export function BannersManager({ banners }: Props) {
|
|||||||
<Label>Color</Label>
|
<Label>Color</Label>
|
||||||
<Input
|
<Input
|
||||||
type="color"
|
type="color"
|
||||||
value={form.color || '#000000'}
|
value={form.color || "#000000"}
|
||||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +188,7 @@ export function BannersManager({ banners }: Props) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{editingId ? 'Update' : 'Create'}
|
{editingId ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -206,10 +203,7 @@ export function BannersManager({ banners }: Props) {
|
|||||||
{banners.map((b) => (
|
{banners.map((b) => (
|
||||||
<Card key={b.id} className="overflow-hidden">
|
<Card key={b.id} className="overflow-hidden">
|
||||||
{b.image && (
|
{b.image && (
|
||||||
<div
|
<div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} />
|
||||||
className="h-32 bg-cover bg-center"
|
|
||||||
style={{ backgroundImage: `url(${b.image})` }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -217,19 +211,14 @@ export function BannersManager({ banners }: Props) {
|
|||||||
<h3 className="font-medium">{b.title}</h3>
|
<h3 className="font-medium">{b.title}</h3>
|
||||||
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
|
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
<Badge variant={b.isActive ? 'default' : 'outline'}>
|
<Badge variant={b.isActive ? "default" : "outline"}>
|
||||||
{b.isActive ? 'Active' : 'Inactive'}
|
{b.isActive ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{b.startDate && <Badge variant="secondary">Scheduled</Badge>}
|
{b.startDate && <Badge variant="secondary">Scheduled</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => handleEdit(b)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -237,7 +226,7 @@ export function BannersManager({ banners }: Props) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
run(() => deleteBanner({ id: b.id }), { successMessage: 'Banner deleted' })
|
run(() => deleteBanner({ id: b.id }), { successMessage: "Banner deleted" })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
@@ -249,5 +238,5 @@ export function BannersManager({ banners }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BannersManager } from './banners-manager'
|
import { BannersManager } from "./banners-manager";
|
||||||
|
|
||||||
export default async function BannersPage() {
|
export default async function BannersPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const banners = await prisma.websiteBanner.findMany({
|
const banners = await prisma.websiteBanner.findMany({
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: "asc" },
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -21,5 +21,5 @@ export default async function BannersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<BannersManager banners={banners} />
|
<BannersManager banners={banners} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,54 +1,54 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Languages, Package, Settings } from 'lucide-react'
|
import { Languages, Package, Settings } from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { CatalogItemData } from './catalog-items-table'
|
import type { CatalogItemData } from "./catalog-items-table";
|
||||||
import { CatalogItemsTable } from './catalog-items-table'
|
import { CatalogItemsTable } from "./catalog-items-table";
|
||||||
import { CatalogPageForm } from './catalog-page-form'
|
import { CatalogPageForm } from "./catalog-page-form";
|
||||||
import { CatalogTranslateTab } from './catalog-translate-tab'
|
import { CatalogTranslateTab } from "./catalog-translate-tab";
|
||||||
|
|
||||||
interface CatalogPageData {
|
interface CatalogPageData {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
parentId: number
|
parentId: number;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
enabled: string
|
enabled: string;
|
||||||
visible: string
|
visible: string;
|
||||||
minRank: number
|
minRank: number;
|
||||||
clubOnly: string
|
clubOnly: string;
|
||||||
orderNum: number
|
orderNum: number;
|
||||||
iconImage: number
|
iconImage: number;
|
||||||
iconColor: number
|
iconColor: number;
|
||||||
pageHeadline: string
|
pageHeadline: string;
|
||||||
pageTeaser: string
|
pageTeaser: string;
|
||||||
pageSpecial: string | null
|
pageSpecial: string | null;
|
||||||
pageText1: string | null
|
pageText1: string | null;
|
||||||
pageText2: string | null
|
pageText2: string | null;
|
||||||
pageTextDetails: string | null
|
pageTextDetails: string | null;
|
||||||
pageTextTeaser: string | null
|
pageTextTeaser: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseItemData {
|
interface BaseItemData {
|
||||||
id: number
|
id: number;
|
||||||
publicName: string
|
publicName: string;
|
||||||
itemName: string
|
itemName: string;
|
||||||
spriteId: number
|
spriteId: number;
|
||||||
type: string
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogDetailTabsProps {
|
interface CatalogDetailTabsProps {
|
||||||
catalogPage: CatalogPageData
|
catalogPage: CatalogPageData;
|
||||||
items: CatalogItemData[]
|
items: CatalogItemData[];
|
||||||
baseItems: BaseItemData[]
|
baseItems: BaseItemData[];
|
||||||
catalogNameMap: Record<number, string>
|
catalogNameMap: Record<number, string>;
|
||||||
childPages: { id: number; caption: string; enabled: string }[]
|
childPages: { id: number; caption: string; enabled: string }[];
|
||||||
pageId: number
|
pageId: number;
|
||||||
canEdit: boolean
|
canEdit: boolean;
|
||||||
furniDataIdList: number[]
|
furniDataIdList: number[];
|
||||||
furniDescriptionMap: Record<number, string>
|
furniDescriptionMap: Record<number, string>;
|
||||||
furniRevisionMap: Record<number, { classname: string; revision: number }>
|
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||||
allPages: { id: number; caption: string }[]
|
allPages: { id: number; caption: string }[];
|
||||||
interactionTypes: string[]
|
interactionTypes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogDetailTabs({
|
export function CatalogDetailTabs({
|
||||||
@@ -78,14 +78,15 @@ export function CatalogDetailTabs({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="translate" className="gap-2">
|
<TabsTrigger value="translate" className="gap-2">
|
||||||
<Languages className="h-4 w-4" />
|
<Languages className="h-4 w-4" />
|
||||||
Translate ({baseItems.length}){(() => {
|
Translate ({baseItems.length})
|
||||||
const furniSet = new Set(furniDataIdList)
|
{(() => {
|
||||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length
|
const furniSet = new Set(furniDataIdList);
|
||||||
|
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
||||||
return missing > 0 ? (
|
return missing > 0 ? (
|
||||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
||||||
{missing}
|
{missing}
|
||||||
</span>
|
</span>
|
||||||
) : null
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -122,5 +123,5 @@ export function CatalogDetailTabs({
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
* Re-export barrel for backward compatibility.
|
* Re-export barrel for backward compatibility.
|
||||||
* Implementation moved to ./catalog-items-table/ directory.
|
* Implementation moved to ./catalog-items-table/ directory.
|
||||||
*/
|
*/
|
||||||
export { CatalogItemsTable } from './catalog-items-table/catalog-items-table'
|
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
|
||||||
export { FurniIcon } from './catalog-items-table/furni-icon'
|
export { FurniIcon } from "./catalog-items-table/furni-icon";
|
||||||
export type { BaseItemData, CatalogItemData } from './catalog-items-table/types'
|
export type { BaseItemData, CatalogItemData } from "./catalog-items-table/types";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Check, ChevronsUpDown, Star } from 'lucide-react'
|
import { Check, ChevronsUpDown, Star } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from '@/components/ui/command'
|
} from "@/components/ui/command";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ── Toggle field ──────────────────────────────────────────────
|
// ── Toggle field ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -25,21 +25,21 @@ export function ToggleField({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
/** Boolean checked state */
|
/** Boolean checked state */
|
||||||
checked?: boolean
|
checked?: boolean;
|
||||||
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
|
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
|
||||||
value?: number | string
|
value?: number | string;
|
||||||
onChange: (v: boolean) => void
|
onChange: (v: boolean) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isChecked = checked ?? (value === 1 || value === '1')
|
const isChecked = checked ?? (value === 1 || value === "1");
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="text-xs">{label}</Label>
|
<Label className="text-xs">{label}</Label>
|
||||||
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" />
|
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Interaction Type Combobox ──────────────────────────────────
|
// ── Interaction Type Combobox ──────────────────────────────────
|
||||||
@@ -50,19 +50,19 @@ export function InteractionTypeCombobox({
|
|||||||
types,
|
types,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string;
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void;
|
||||||
types: string[]
|
types: string[];
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!inputValue.trim()) return types.slice(0, 50)
|
if (!inputValue.trim()) return types.slice(0, 50);
|
||||||
const q = inputValue.toLowerCase()
|
const q = inputValue.toLowerCase();
|
||||||
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50)
|
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
|
||||||
}, [types, inputValue])
|
}, [types, inputValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -74,7 +74,7 @@ export function InteractionTypeCombobox({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full justify-between font-normal h-8 text-sm"
|
className="w-full justify-between font-normal h-8 text-sm"
|
||||||
>
|
>
|
||||||
<span className="truncate">{value || 'Select type...'}</span>
|
<span className="truncate">{value || "Select type..."}</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -92,15 +92,15 @@ export function InteractionTypeCombobox({
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
|
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(inputValue.trim())
|
onChange(inputValue.trim());
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
setInputValue('')
|
setInputValue("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use "{inputValue.trim()}"
|
Use "{inputValue.trim()}"
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
'No types found.'
|
"No types found."
|
||||||
)}
|
)}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -109,14 +109,12 @@ export function InteractionTypeCombobox({
|
|||||||
key={t}
|
key={t}
|
||||||
value={t}
|
value={t}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(t)
|
onChange(t);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
setInputValue('')
|
setInputValue("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-4 w-4", value === t ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn('mr-2 h-4 w-4', value === t ? 'opacity-100' : 'opacity-0')}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{t}</span>
|
<span className="truncate">{t}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
@@ -125,7 +123,7 @@ export function InteractionTypeCombobox({
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Move Page Combobox with smart suggestions ─────────────────
|
// ── Move Page Combobox with smart suggestions ─────────────────
|
||||||
@@ -137,35 +135,32 @@ export function MovePageCombobox({
|
|||||||
currentPageId,
|
currentPageId,
|
||||||
suggestedPageIds,
|
suggestedPageIds,
|
||||||
}: {
|
}: {
|
||||||
value: number | null
|
value: number | null;
|
||||||
onChange: (id: number | null) => void
|
onChange: (id: number | null) => void;
|
||||||
pages: { id: number; caption: string }[]
|
pages: { id: number; caption: string }[];
|
||||||
currentPageId: number
|
currentPageId: number;
|
||||||
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
|
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
|
||||||
suggestedPageIds?: number[]
|
suggestedPageIds?: number[];
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId])
|
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId]);
|
||||||
|
|
||||||
const selectedPage = value ? pages.find((p) => p.id === value) : null
|
const selectedPage = value ? pages.find((p) => p.id === value) : null;
|
||||||
|
|
||||||
// Build suggested pages list
|
// Build suggested pages list
|
||||||
const suggested = useMemo(() => {
|
const suggested = useMemo(() => {
|
||||||
if (!suggestedPageIds?.length) return []
|
if (!suggestedPageIds?.length) return [];
|
||||||
const pageMap = new Map(options.map((p) => [p.id, p]))
|
const pageMap = new Map(options.map((p) => [p.id, p]));
|
||||||
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
|
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
}[]
|
}[];
|
||||||
}, [suggestedPageIds, options])
|
}, [suggestedPageIds, options]);
|
||||||
|
|
||||||
// Non-suggested pages (exclude those already in suggestions)
|
// Non-suggested pages (exclude those already in suggestions)
|
||||||
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested])
|
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]);
|
||||||
const remaining = useMemo(
|
const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]);
|
||||||
() => options.filter((p) => !suggestedSet.has(p.id)),
|
|
||||||
[options, suggestedSet],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -178,9 +173,7 @@ export function MovePageCombobox({
|
|||||||
>
|
>
|
||||||
{selectedPage ? (
|
{selectedPage ? (
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{selectedPage.id}</span>
|
||||||
#{selectedPage.id}
|
|
||||||
</span>
|
|
||||||
{selectedPage.caption}
|
{selectedPage.caption}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -203,20 +196,13 @@ export function MovePageCombobox({
|
|||||||
key={`s-${page.id}`}
|
key={`s-${page.id}`}
|
||||||
value={`${page.id} ${page.caption}`}
|
value={`${page.id} ${page.caption}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(page.id)
|
onChange(page.id);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn(
|
|
||||||
'mr-2 h-4 w-4',
|
|
||||||
value === page.id ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
|
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
||||||
#{page.id}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{page.caption}</span>
|
<span className="truncate">{page.caption}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
@@ -224,19 +210,17 @@ export function MovePageCombobox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All pages */}
|
{/* All pages */}
|
||||||
<CommandGroup heading={suggested.length > 0 ? 'All pages' : undefined}>
|
<CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}>
|
||||||
{remaining.map((page) => (
|
{remaining.map((page) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={page.id}
|
key={page.id}
|
||||||
value={`${page.id} ${page.caption}`}
|
value={`${page.id} ${page.caption}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(page.id)
|
onChange(page.id);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn('mr-2 h-4 w-4', value === page.id ? 'opacity-100' : 'opacity-0')}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
||||||
<span className="truncate">{page.caption}</span>
|
<span className="truncate">{page.caption}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -246,5 +230,5 @@ export function MovePageCombobox({
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Image as ImageIcon } from 'lucide-react'
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
|
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
||||||
|
|
||||||
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) {
|
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) {
|
||||||
if (!classname) {
|
if (!classname) {
|
||||||
@@ -9,34 +9,34 @@ export function FurniIcon({ classname, revision }: { classname: string; revision
|
|||||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
||||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localUrl = getLocalIconUrl(classname)
|
const localUrl = getLocalIconUrl(classname);
|
||||||
|
|
||||||
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
|
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||||
const img = e.target as HTMLImageElement
|
const img = e.target as HTMLImageElement;
|
||||||
const stage = img.dataset.fallback
|
const stage = img.dataset.fallback;
|
||||||
if (!stage && revision > 0) {
|
if (!stage && revision > 0) {
|
||||||
img.dataset.fallback = 'cdn'
|
img.dataset.fallback = "cdn";
|
||||||
img.src = getHabboCdnIconUrl(classname, revision)
|
img.src = getHabboCdnIconUrl(classname, revision);
|
||||||
} else {
|
} else {
|
||||||
img.dataset.fallback = 'none'
|
img.dataset.fallback = "none";
|
||||||
img.style.opacity = '0'
|
img.style.opacity = "0";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{ }
|
||||||
<img
|
<img
|
||||||
src={localUrl}
|
src={localUrl}
|
||||||
alt={classname}
|
alt={classname}
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
style={{ imageRendering: 'pixelated' }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { CatalogItemsTable } from './catalog-items-table'
|
export { CatalogItemsTable } from "./catalog-items-table";
|
||||||
export { FurniIcon } from './furni-icon'
|
export { FurniIcon } from "./furni-icon";
|
||||||
export type { BaseItemData, CatalogItemData, InlineEdit } from './types'
|
export type { BaseItemData, CatalogItemData, InlineEdit } from "./types";
|
||||||
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from './types'
|
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from "./types";
|
||||||
@@ -1,59 +1,53 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Loader2, Music } from 'lucide-react'
|
import { Loader2, Music } from "lucide-react";
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
import {
|
||||||
getCachedSongPickerOptions,
|
getCachedSongPickerOptions,
|
||||||
loadSongPickerOptions,
|
loadSongPickerOptions,
|
||||||
type SoundtrackOption,
|
type SoundtrackOption,
|
||||||
} from '@/lib/client-cache/song-picker-cache'
|
} from "@/lib/client-cache/song-picker-cache";
|
||||||
|
|
||||||
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
|
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
|
||||||
// callsites keep working. New code should import from the lib module directly.
|
// callsites keep working. New code should import from the lib module directly.
|
||||||
export { invalidateSongPickerCache } from '@/lib/client-cache/song-picker-cache'
|
export { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: number
|
value: number;
|
||||||
onChange: (id: number) => void
|
onChange: (id: number) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLength(seconds: number): string {
|
function formatLength(seconds: number): string {
|
||||||
if (!seconds || seconds <= 0) return ''
|
if (!seconds || seconds <= 0) return "";
|
||||||
const m = Math.floor(seconds / 60)
|
const m = Math.floor(seconds / 60);
|
||||||
const s = seconds % 60
|
const s = seconds % 60;
|
||||||
return `${m}:${String(s).padStart(2, '0')}`
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SongPicker({ value, onChange, disabled }: Props) {
|
export function SongPicker({ value, onChange, disabled }: Props) {
|
||||||
const initial = getCachedSongPickerOptions()
|
const initial = getCachedSongPickerOptions();
|
||||||
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? [])
|
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
|
||||||
const [loading, setLoading] = useState(!initial)
|
const [loading, setLoading] = useState(!initial);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getCachedSongPickerOptions()) return
|
if (getCachedSongPickerOptions()) return;
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
loadSongPickerOptions()
|
loadSongPickerOptions()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (!cancelled) setOptions(items)
|
if (!cancelled) setOptions(items);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setOptions([])
|
if (!cancelled) setOptions([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false);
|
||||||
})
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -81,14 +75,12 @@ export function SongPicker({ value, onChange, disabled }: Props) {
|
|||||||
<span className="font-medium">{opt.name}</span>
|
<span className="font-medium">{opt.name}</span>
|
||||||
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>}
|
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>}
|
||||||
{opt.length > 0 && (
|
{opt.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<span className="text-xs text-muted-foreground font-mono">{formatLength(opt.length)}</span>
|
||||||
{formatLength(opt.length)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,71 +1,71 @@
|
|||||||
export interface BaseItemData {
|
export interface BaseItemData {
|
||||||
id: number
|
id: number;
|
||||||
spriteId: number
|
spriteId: number;
|
||||||
publicName: string
|
publicName: string;
|
||||||
itemName: string
|
itemName: string;
|
||||||
type: string
|
type: string;
|
||||||
width: number
|
width: number;
|
||||||
length: number
|
length: number;
|
||||||
stackHeight: number
|
stackHeight: number;
|
||||||
allowStack: number
|
allowStack: number;
|
||||||
allowSit: number
|
allowSit: number;
|
||||||
allowLay: number
|
allowLay: number;
|
||||||
allowWalk: number
|
allowWalk: number;
|
||||||
allowGift: number
|
allowGift: number;
|
||||||
allowTrade: number
|
allowTrade: number;
|
||||||
allowRecycle: number
|
allowRecycle: number;
|
||||||
allowMarketplaceSell: number
|
allowMarketplaceSell: number;
|
||||||
allowInventoryStack: number
|
allowInventoryStack: number;
|
||||||
interactionType: string
|
interactionType: string;
|
||||||
interactionModesCount: number
|
interactionModesCount: number;
|
||||||
vendingIds: string
|
vendingIds: string;
|
||||||
customparams: string
|
customparams: string;
|
||||||
effectIdMale: number
|
effectIdMale: number;
|
||||||
effectIdFemale: number
|
effectIdFemale: number;
|
||||||
clothingOnWalk: string
|
clothingOnWalk: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogItemData {
|
export interface CatalogItemData {
|
||||||
id: number
|
id: number;
|
||||||
catalogName: string
|
catalogName: string;
|
||||||
itemIds: string
|
itemIds: string;
|
||||||
costCredits: number
|
costCredits: number;
|
||||||
costPoints: number
|
costPoints: number;
|
||||||
pointsType: number
|
pointsType: number;
|
||||||
amount: number
|
amount: number;
|
||||||
limitedSells: number
|
limitedSells: number;
|
||||||
limitedStack: number
|
limitedStack: number;
|
||||||
orderNumber: number
|
orderNumber: number;
|
||||||
offerId: number
|
offerId: number;
|
||||||
songId: number
|
songId: number;
|
||||||
haveOffer: string
|
haveOffer: string;
|
||||||
clubOnly: string
|
clubOnly: string;
|
||||||
extradata: string
|
extradata: string;
|
||||||
baseName: string
|
baseName: string;
|
||||||
baseItemName: string
|
baseItemName: string;
|
||||||
spriteId: number
|
spriteId: number;
|
||||||
baseItem: BaseItemData | null
|
baseItem: BaseItemData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InlineEdit {
|
export interface InlineEdit {
|
||||||
costCredits: number
|
costCredits: number;
|
||||||
costPoints: number
|
costPoints: number;
|
||||||
pointsType: number
|
pointsType: number;
|
||||||
amount: number
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
|
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
|
||||||
0: { label: 'Duckets', color: 'text-[var(--admin-info)]' },
|
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
|
||||||
5: { label: 'Diamonds', color: 'text-[var(--admin-accent-text)]' },
|
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
|
||||||
101: { label: 'Snowflakes', color: 'text-[var(--admin-info)]' },
|
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
|
||||||
103: { label: 'Hearts', color: 'text-[var(--admin-error)]' },
|
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
|
||||||
104: { label: 'Stars', color: 'text-[var(--admin-warning)]' },
|
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
|
||||||
105: { label: 'Shells', color: 'text-[var(--admin-warning)]' },
|
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
|
||||||
}
|
};
|
||||||
|
|
||||||
export const DEFAULT_NEW_ITEM = {
|
export const DEFAULT_NEW_ITEM = {
|
||||||
catalogName: '',
|
catalogName: "",
|
||||||
itemIds: '0',
|
itemIds: "0",
|
||||||
costCredits: 0,
|
costCredits: 0,
|
||||||
costPoints: 0,
|
costPoints: 0,
|
||||||
pointsType: 0,
|
pointsType: 0,
|
||||||
@@ -75,11 +75,11 @@ export const DEFAULT_NEW_ITEM = {
|
|||||||
orderNumber: 1,
|
orderNumber: 1,
|
||||||
offerId: -1,
|
offerId: -1,
|
||||||
songId: 0,
|
songId: 0,
|
||||||
haveOffer: '1',
|
haveOffer: "1",
|
||||||
clubOnly: '0',
|
clubOnly: "0",
|
||||||
extradata: '',
|
extradata: "",
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getPointsLabel(type: number) {
|
export function getPointsLabel(type: number) {
|
||||||
return POINTS_TYPES[type] || { label: `Type ${type}`, color: 'theme-text-muted' }
|
return POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" };
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -11,17 +11,17 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { deleteCatalogPage, updateCatalogPage } from '@/actions/catalog'
|
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
|
||||||
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
|
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||||
import { IconPicker } from '@/components/admin/catalog/icon-picker'
|
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||||
import { ImagePreview } from '@/components/admin/catalog-manager/image-preview'
|
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
|
||||||
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
|
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -29,120 +29,109 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from '@/components/ui/command'
|
} from "@/components/ui/command";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Switch } from "@/components/ui/switch";
|
||||||
SelectContent,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
SelectItem,
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
SelectTrigger,
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
SelectValue,
|
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
|
||||||
} from '@/components/ui/select'
|
import { cn } from "@/lib/utils";
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface CatalogPageData {
|
interface CatalogPageData {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
parentId: number
|
parentId: number;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
enabled: string
|
enabled: string;
|
||||||
visible: string
|
visible: string;
|
||||||
minRank: number
|
minRank: number;
|
||||||
clubOnly: string
|
clubOnly: string;
|
||||||
orderNum: number
|
orderNum: number;
|
||||||
iconImage: number
|
iconImage: number;
|
||||||
iconColor: number
|
iconColor: number;
|
||||||
pageHeadline: string
|
pageHeadline: string;
|
||||||
pageTeaser: string
|
pageTeaser: string;
|
||||||
pageSpecial: string | null
|
pageSpecial: string | null;
|
||||||
pageText1: string | null
|
pageText1: string | null;
|
||||||
pageText2: string | null
|
pageText2: string | null;
|
||||||
pageTextDetails: string | null
|
pageTextDetails: string | null;
|
||||||
pageTextTeaser: string | null
|
pageTextTeaser: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogPageFormProps {
|
interface CatalogPageFormProps {
|
||||||
catalogPage: CatalogPageData
|
catalogPage: CatalogPageData;
|
||||||
childPages: { id: number; caption: string; enabled: string }[]
|
childPages: { id: number; caption: string; enabled: string }[];
|
||||||
canEdit: boolean
|
canEdit: boolean;
|
||||||
allPages: { id: number; caption: string }[]
|
allPages: { id: number; caption: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogPageForm({
|
export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }: CatalogPageFormProps) {
|
||||||
catalogPage,
|
const { isPending, run } = useServerAction();
|
||||||
childPages,
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
canEdit,
|
|
||||||
allPages,
|
|
||||||
}: CatalogPageFormProps) {
|
|
||||||
const { isPending, run } = useServerAction()
|
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
caption: catalogPage.caption,
|
caption: catalogPage.caption,
|
||||||
parentId: catalogPage.parentId,
|
parentId: catalogPage.parentId,
|
||||||
pageLayout: catalogPage.pageLayout,
|
pageLayout: catalogPage.pageLayout,
|
||||||
enabled: catalogPage.enabled as '0' | '1',
|
enabled: catalogPage.enabled as "0" | "1",
|
||||||
visible: catalogPage.visible as '0' | '1',
|
visible: catalogPage.visible as "0" | "1",
|
||||||
minRank: catalogPage.minRank,
|
minRank: catalogPage.minRank,
|
||||||
clubOnly: catalogPage.clubOnly as '0' | '1',
|
clubOnly: catalogPage.clubOnly as "0" | "1",
|
||||||
orderNum: catalogPage.orderNum,
|
orderNum: catalogPage.orderNum,
|
||||||
iconImage: catalogPage.iconImage,
|
iconImage: catalogPage.iconImage,
|
||||||
iconColor: catalogPage.iconColor,
|
iconColor: catalogPage.iconColor,
|
||||||
pageHeadline: catalogPage.pageHeadline,
|
pageHeadline: catalogPage.pageHeadline,
|
||||||
pageTeaser: catalogPage.pageTeaser,
|
pageTeaser: catalogPage.pageTeaser,
|
||||||
pageSpecial: catalogPage.pageSpecial || '',
|
pageSpecial: catalogPage.pageSpecial || "",
|
||||||
pageText1: catalogPage.pageText1 || '',
|
pageText1: catalogPage.pageText1 || "",
|
||||||
pageText2: catalogPage.pageText2 || '',
|
pageText2: catalogPage.pageText2 || "",
|
||||||
pageTextDetails: catalogPage.pageTextDetails || '',
|
pageTextDetails: catalogPage.pageTextDetails || "",
|
||||||
pageTextTeaser: catalogPage.pageTextTeaser || '',
|
pageTextTeaser: catalogPage.pageTextTeaser || "",
|
||||||
})
|
});
|
||||||
|
|
||||||
// Ctrl+S / Cmd+S to save
|
// Ctrl+S / Cmd+S to save
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canEdit) return
|
if (!canEdit) return;
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
formRef.current?.requestSubmit()
|
formRef.current?.requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [canEdit])
|
}, [canEdit]);
|
||||||
|
|
||||||
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }))
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
|
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
|
||||||
successMessage: 'Page updated and catalog synced.',
|
successMessage: "Page updated and catalog synced.",
|
||||||
errorMessage: 'Failed to update.',
|
errorMessage: "Failed to update.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
const childCount = childPages.length
|
const childCount = childPages.length;
|
||||||
const msg =
|
const msg =
|
||||||
childCount > 0
|
childCount > 0
|
||||||
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to root. All items in this page will be deleted.`
|
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to root. All items in this page will be deleted.`
|
||||||
: 'Delete this catalog page and all its items?'
|
: "Delete this catalog page and all its items?";
|
||||||
if (!confirm(msg)) return
|
if (!confirm(msg)) return;
|
||||||
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
||||||
successMessage: 'Page deleted.',
|
successMessage: "Page deleted.",
|
||||||
redirectTo: '/admin/catalog',
|
redirectTo: "/admin/catalog",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || 'bg-muted text-muted-foreground'
|
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form ref={formRef} onSubmit={handleSubmit}>
|
<form ref={formRef} onSubmit={handleSubmit}>
|
||||||
@@ -175,7 +164,7 @@ export function CatalogPageForm({
|
|||||||
<Input
|
<Input
|
||||||
id="caption"
|
id="caption"
|
||||||
value={form.caption}
|
value={form.caption}
|
||||||
onChange={(e) => update('caption', e.target.value)}
|
onChange={(e) => update("caption", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +172,7 @@ export function CatalogPageForm({
|
|||||||
<Label>Parent Page</Label>
|
<Label>Parent Page</Label>
|
||||||
<ParentCombobox
|
<ParentCombobox
|
||||||
value={form.parentId}
|
value={form.parentId}
|
||||||
onChange={(id) => update('parentId', id)}
|
onChange={(id) => update("parentId", id)}
|
||||||
pages={allPages}
|
pages={allPages}
|
||||||
currentPageId={catalogPage.id}
|
currentPageId={catalogPage.id}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -195,11 +184,11 @@ export function CatalogPageForm({
|
|||||||
<Label>Layout</Label>
|
<Label>Layout</Label>
|
||||||
<LayoutSelect
|
<LayoutSelect
|
||||||
value={form.pageLayout}
|
value={form.pageLayout}
|
||||||
onChange={(v) => update('pageLayout', v)}
|
onChange={(v) => update("pageLayout", v)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? 'Custom layout'}
|
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? "Custom layout"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,7 +199,7 @@ export function CatalogPageForm({
|
|||||||
id="orderNum"
|
id="orderNum"
|
||||||
type="number"
|
type="number"
|
||||||
value={form.orderNum}
|
value={form.orderNum}
|
||||||
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +211,7 @@ export function CatalogPageForm({
|
|||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
value={form.minRank}
|
value={form.minRank}
|
||||||
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
|
onChange={(e) => update("minRank", parseInt(e.target.value, 10) || 1)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,7 +224,7 @@ export function CatalogPageForm({
|
|||||||
<Label>Headline</Label>
|
<Label>Headline</Label>
|
||||||
<CatalogImagePicker
|
<CatalogImagePicker
|
||||||
value={form.pageHeadline}
|
value={form.pageHeadline}
|
||||||
onChange={(v) => update('pageHeadline', v)}
|
onChange={(v) => update("pageHeadline", v)}
|
||||||
type="header"
|
type="header"
|
||||||
label="Headline image"
|
label="Headline image"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -246,7 +235,7 @@ export function CatalogPageForm({
|
|||||||
<Label>Teaser</Label>
|
<Label>Teaser</Label>
|
||||||
<CatalogImagePicker
|
<CatalogImagePicker
|
||||||
value={form.pageTeaser}
|
value={form.pageTeaser}
|
||||||
onChange={(v) => update('pageTeaser', v)}
|
onChange={(v) => update("pageTeaser", v)}
|
||||||
type="teaser"
|
type="teaser"
|
||||||
label="Teaser image"
|
label="Teaser image"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -258,7 +247,7 @@ export function CatalogPageForm({
|
|||||||
id="pageText1"
|
id="pageText1"
|
||||||
label="Text 1"
|
label="Text 1"
|
||||||
value={form.pageText1}
|
value={form.pageText1}
|
||||||
onChange={(v) => update('pageText1', v)}
|
onChange={(v) => update("pageText1", v)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={4}
|
rows={4}
|
||||||
max={5000}
|
max={5000}
|
||||||
@@ -267,7 +256,7 @@ export function CatalogPageForm({
|
|||||||
id="pageText2"
|
id="pageText2"
|
||||||
label="Text 2"
|
label="Text 2"
|
||||||
value={form.pageText2}
|
value={form.pageText2}
|
||||||
onChange={(v) => update('pageText2', v)}
|
onChange={(v) => update("pageText2", v)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={4}
|
rows={4}
|
||||||
max={5000}
|
max={5000}
|
||||||
@@ -278,7 +267,7 @@ export function CatalogPageForm({
|
|||||||
id="pageTextDetails"
|
id="pageTextDetails"
|
||||||
label="Details"
|
label="Details"
|
||||||
value={form.pageTextDetails}
|
value={form.pageTextDetails}
|
||||||
onChange={(v) => update('pageTextDetails', v)}
|
onChange={(v) => update("pageTextDetails", v)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
rows={3}
|
||||||
max={5000}
|
max={5000}
|
||||||
@@ -287,7 +276,7 @@ export function CatalogPageForm({
|
|||||||
id="pageTextTeaser"
|
id="pageTextTeaser"
|
||||||
label="Text Teaser"
|
label="Text Teaser"
|
||||||
value={form.pageTextTeaser}
|
value={form.pageTextTeaser}
|
||||||
onChange={(v) => update('pageTextTeaser', v)}
|
onChange={(v) => update("pageTextTeaser", v)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
rows={3}
|
||||||
max={5000}
|
max={5000}
|
||||||
@@ -297,7 +286,7 @@ export function CatalogPageForm({
|
|||||||
id="pageSpecial"
|
id="pageSpecial"
|
||||||
label="Special"
|
label="Special"
|
||||||
value={form.pageSpecial}
|
value={form.pageSpecial}
|
||||||
onChange={(v) => update('pageSpecial', v)}
|
onChange={(v) => update("pageSpecial", v)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={2}
|
rows={2}
|
||||||
max={2048}
|
max={2048}
|
||||||
@@ -311,7 +300,7 @@ export function CatalogPageForm({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<IconPicker
|
<IconPicker
|
||||||
value={form.iconImage}
|
value={form.iconImage}
|
||||||
onChange={(id) => update('iconImage', id)}
|
onChange={(id) => update("iconImage", id)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -328,12 +317,11 @@ export function CatalogPageForm({
|
|||||||
min={0}
|
min={0}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
value={form.iconColor}
|
value={form.iconColor}
|
||||||
onChange={(e) => update('iconColor', parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => update("iconColor", parseInt(e.target.value, 10) || 0)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Legacy tint value. Leave at default unless you need to match a specific
|
Legacy tint value. Leave at default unless you need to match a specific theme.
|
||||||
theme.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,12 +349,10 @@ export function CatalogPageForm({
|
|||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{child.caption}
|
{child.caption}
|
||||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
<span className="ml-2 text-muted-foreground font-mono text-xs">#{child.id}</span>
|
||||||
#{child.id}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={child.enabled === '1' ? 'default' : 'outline'}>
|
<Badge variant={child.enabled === "1" ? "default" : "outline"}>
|
||||||
{child.enabled === '1' ? 'Enabled' : 'Disabled'}
|
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -389,13 +375,8 @@ export function CatalogPageForm({
|
|||||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
<CatalogIcon iconImage={form.iconImage} size={36} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium">{form.caption || 'Untitled'}</p>
|
<p className="truncate text-sm font-medium">{form.caption || "Untitled"}</p>
|
||||||
<Badge
|
<Badge className={cn("mt-1 h-5 px-1.5 text-[10px] font-mono border-0", layoutColorClass)}>
|
||||||
className={cn(
|
|
||||||
'mt-1 h-5 px-1.5 text-[10px] font-mono border-0',
|
|
||||||
layoutColorClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{form.pageLayout}
|
{form.pageLayout}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,24 +388,24 @@ export function CatalogPageForm({
|
|||||||
id="tg-enabled"
|
id="tg-enabled"
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
description="Active in the catalog"
|
description="Active in the catalog"
|
||||||
checked={form.enabled === '1'}
|
checked={form.enabled === "1"}
|
||||||
onChange={(v) => update('enabled', v ? '1' : '0')}
|
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
id="tg-visible"
|
id="tg-visible"
|
||||||
label="Visible"
|
label="Visible"
|
||||||
description="Shown in navigation"
|
description="Shown in navigation"
|
||||||
checked={form.visible === '1'}
|
checked={form.visible === "1"}
|
||||||
onChange={(v) => update('visible', v ? '1' : '0')}
|
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
id="tg-club"
|
id="tg-club"
|
||||||
label="Club Only"
|
label="Club Only"
|
||||||
description="Requires HC subscription"
|
description="Requires HC subscription"
|
||||||
checked={form.clubOnly === '1'}
|
checked={form.clubOnly === "1"}
|
||||||
onChange={(v) => update('clubOnly', v ? '1' : '0')}
|
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,7 +452,7 @@ export function CatalogPageForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle row ─────────────────────────────────────────────────
|
// ── Toggle row ─────────────────────────────────────────────────
|
||||||
@@ -484,12 +465,12 @@ function ToggleRow({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
description: string
|
description: string;
|
||||||
checked: boolean
|
checked: boolean;
|
||||||
onChange: (v: boolean) => void
|
onChange: (v: boolean) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -501,7 +482,7 @@ function ToggleRow({
|
|||||||
</div>
|
</div>
|
||||||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Text field with counter ────────────────────────────────────
|
// ── Text field with counter ────────────────────────────────────
|
||||||
@@ -514,12 +495,12 @@ function _TextField({
|
|||||||
disabled,
|
disabled,
|
||||||
max,
|
max,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
max?: number
|
max?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -539,7 +520,7 @@ function _TextField({
|
|||||||
maxLength={max}
|
maxLength={max}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextareaField({
|
function TextareaField({
|
||||||
@@ -551,13 +532,13 @@ function TextareaField({
|
|||||||
rows = 3,
|
rows = 3,
|
||||||
max,
|
max,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
rows?: number
|
rows?: number;
|
||||||
max?: number
|
max?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -578,7 +559,7 @@ function TextareaField({
|
|||||||
maxLength={max}
|
maxLength={max}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layout select with color swatch ────────────────────────────
|
// ── Layout select with color swatch ────────────────────────────
|
||||||
@@ -588,9 +569,9 @@ function LayoutSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string;
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||||
@@ -599,19 +580,19 @@ function LayoutSelect({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATALOG_LAYOUTS.map((layout) => {
|
{CATALOG_LAYOUTS.map((layout) => {
|
||||||
const colorClass = LAYOUT_COLORS[layout] || 'bg-muted text-muted-foreground'
|
const colorClass = LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<SelectItem key={layout} value={layout}>
|
<SelectItem key={layout} value={layout}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className={cn('inline-block h-2 w-2 rounded-full', colorClass)} />
|
<span className={cn("inline-block h-2 w-2 rounded-full", colorClass)} />
|
||||||
{layout}
|
{layout}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Parent page combobox with search ────────────────────────────
|
// ── Parent page combobox with search ────────────────────────────
|
||||||
@@ -623,16 +604,16 @@ function ParentCombobox({
|
|||||||
currentPageId,
|
currentPageId,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
value: number
|
value: number;
|
||||||
onChange: (id: number) => void
|
onChange: (id: number) => void;
|
||||||
pages: { id: number; caption: string }[]
|
pages: { id: number; caption: string }[];
|
||||||
currentPageId: number
|
currentPageId: number;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const options = pages.filter((p) => p.id !== currentPageId)
|
const options = pages.filter((p) => p.id !== currentPageId);
|
||||||
const selected = value === -1 ? { id: -1, caption: 'Root' } : pages.find((p) => p.id === value)
|
const selected = value === -1 ? { id: -1, caption: "Root" } : pages.find((p) => p.id === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -648,7 +629,7 @@ function ParentCombobox({
|
|||||||
{selected ? (
|
{selected ? (
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||||
{selected.id === -1 ? '—' : `#${selected.id}`}
|
{selected.id === -1 ? "—" : `#${selected.id}`}
|
||||||
</span>
|
</span>
|
||||||
{selected.caption}
|
{selected.caption}
|
||||||
</span>
|
</span>
|
||||||
@@ -667,11 +648,11 @@ function ParentCombobox({
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
value="root"
|
value="root"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(-1)
|
onChange(-1);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className={cn('mr-2 h-4 w-4', value === -1 ? 'opacity-100' : 'opacity-0')} />
|
<Check className={cn("mr-2 h-4 w-4", value === -1 ? "opacity-100" : "opacity-0")} />
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">—</span>
|
<span className="text-muted-foreground font-mono text-xs mr-1.5">—</span>
|
||||||
Root
|
Root
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -680,13 +661,11 @@ function ParentCombobox({
|
|||||||
key={page.id}
|
key={page.id}
|
||||||
value={`${page.id} ${page.caption}`}
|
value={`${page.id} ${page.caption}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(page.id)
|
onChange(page.id);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn('mr-2 h-4 w-4', value === page.id ? 'opacity-100' : 'opacity-0')}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
|
||||||
<span className="truncate">{page.caption}</span>
|
<span className="truncate">{page.caption}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -696,5 +675,5 @@ function ParentCombobox({
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -11,46 +11,39 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { translateCatalogItems } from '@/actions/catalog-items'
|
import { translateCatalogItems } from "@/actions/catalog-items";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
TableBody,
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
TableCell,
|
import { FurniIcon } from "./catalog-items-table";
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
import { FurniIcon } from './catalog-items-table'
|
|
||||||
|
|
||||||
interface BaseItemForTranslation {
|
interface BaseItemForTranslation {
|
||||||
id: number
|
id: number;
|
||||||
publicName: string
|
publicName: string;
|
||||||
itemName: string
|
itemName: string;
|
||||||
spriteId: number
|
spriteId: number;
|
||||||
type: string
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogTranslateTabProps {
|
interface CatalogTranslateTabProps {
|
||||||
baseItems: BaseItemForTranslation[]
|
baseItems: BaseItemForTranslation[];
|
||||||
catalogNameMap: Record<number, string>
|
catalogNameMap: Record<number, string>;
|
||||||
canEdit: boolean
|
canEdit: boolean;
|
||||||
furniDataIdList: number[]
|
furniDataIdList: number[];
|
||||||
furniDescriptionMap: Record<number, string>
|
furniDescriptionMap: Record<number, string>;
|
||||||
furniRevisionMap?: Record<number, { classname: string; revision: number }>
|
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterMode = 'all' | 'missing' | 'modified'
|
type FilterMode = "all" | "missing" | "modified";
|
||||||
|
|
||||||
export function CatalogTranslateTab({
|
export function CatalogTranslateTab({
|
||||||
baseItems,
|
baseItems,
|
||||||
@@ -60,164 +53,164 @@ export function CatalogTranslateTab({
|
|||||||
furniDescriptionMap,
|
furniDescriptionMap,
|
||||||
furniRevisionMap = {},
|
furniRevisionMap = {},
|
||||||
}: CatalogTranslateTabProps) {
|
}: CatalogTranslateTabProps) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const [isSuggesting, setIsSuggesting] = useState(false)
|
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||||
const [filterMode, setFilterMode] = useState<FilterMode>('all')
|
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
||||||
|
|
||||||
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList])
|
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// ── Editable state: names + descriptions ─────────────────────────
|
// ── Editable state: names + descriptions ─────────────────────────
|
||||||
const [editedNames, setEditedNames] = useState<Record<number, string>>(
|
const [editedNames, setEditedNames] = useState<Record<number, string>>(
|
||||||
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
|
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
|
||||||
)
|
);
|
||||||
|
|
||||||
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
|
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
|
||||||
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])),
|
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
|
||||||
)
|
);
|
||||||
|
|
||||||
// Track which items have name or description changes
|
// Track which items have name or description changes
|
||||||
const isNameChanged = useCallback(
|
const isNameChanged = useCallback(
|
||||||
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
|
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
|
||||||
[editedNames],
|
[editedNames],
|
||||||
)
|
);
|
||||||
|
|
||||||
const isDescChanged = useCallback(
|
const isDescChanged = useCallback(
|
||||||
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ''),
|
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
|
||||||
[editedDescs, furniDescriptionMap],
|
[editedDescs, furniDescriptionMap],
|
||||||
)
|
);
|
||||||
|
|
||||||
const changedItems = useMemo(
|
const changedItems = useMemo(
|
||||||
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
|
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
|
||||||
[baseItems, isNameChanged, isDescChanged],
|
[baseItems, isNameChanged, isDescChanged],
|
||||||
)
|
);
|
||||||
|
|
||||||
const missingItems = useMemo(
|
const missingItems = useMemo(
|
||||||
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
|
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
|
||||||
[baseItems, furniDataIdSet],
|
[baseItems, furniDataIdSet],
|
||||||
)
|
);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
switch (filterMode) {
|
switch (filterMode) {
|
||||||
case 'missing':
|
case "missing":
|
||||||
return baseItems.filter((item) => !furniDataIdSet.has(item.id))
|
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
|
||||||
case 'modified':
|
case "modified":
|
||||||
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item))
|
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item));
|
||||||
default:
|
default:
|
||||||
return baseItems
|
return baseItems;
|
||||||
}
|
}
|
||||||
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged])
|
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
|
||||||
|
|
||||||
const toggleOne = useCallback((id: number) => {
|
const toggleOne = useCallback((id: number) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id);
|
||||||
else next.add(id)
|
else next.add(id);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const toggleAll = useCallback(() => {
|
const toggleAll = useCallback(() => {
|
||||||
setSelected((prev) =>
|
setSelected((prev) =>
|
||||||
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)),
|
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)),
|
||||||
)
|
);
|
||||||
}, [filteredItems])
|
}, [filteredItems]);
|
||||||
|
|
||||||
// Items to act on: selected ones if any, otherwise all
|
// Items to act on: selected ones if any, otherwise all
|
||||||
const targetItems = useMemo(() => {
|
const targetItems = useMemo(() => {
|
||||||
if (selected.size === 0) return baseItems
|
if (selected.size === 0) return baseItems;
|
||||||
return baseItems.filter((i) => selected.has(i.id))
|
return baseItems.filter((i) => selected.has(i.id));
|
||||||
}, [baseItems, selected])
|
}, [baseItems, selected]);
|
||||||
|
|
||||||
function handleNameChange(id: number, value: string) {
|
function handleNameChange(id: number, value: string) {
|
||||||
setEditedNames((prev) => ({ ...prev, [id]: value }))
|
setEditedNames((prev) => ({ ...prev, [id]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDescChange(id: number, value: string) {
|
function handleDescChange(id: number, value: string) {
|
||||||
setEditedDescs((prev) => ({ ...prev, [id]: value }))
|
setEditedDescs((prev) => ({ ...prev, [id]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])))
|
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])));
|
||||||
setEditedDescs(
|
setEditedDescs(
|
||||||
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ''])),
|
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSuggestFromHabboFurni() {
|
async function handleSuggestFromHabboFurni() {
|
||||||
if (targetItems.length === 0) return
|
if (targetItems.length === 0) return;
|
||||||
setIsSuggesting(true)
|
setIsSuggesting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Collect classnames from selected items (or all if none selected)
|
// Collect classnames from selected items (or all if none selected)
|
||||||
const classnames = targetItems.map((item) => item.itemName).filter(Boolean)
|
const classnames = targetItems.map((item) => item.itemName).filter(Boolean);
|
||||||
if (classnames.length === 0) {
|
if (classnames.length === 0) {
|
||||||
toast.info('No classnames found to look up.')
|
toast.info("No classnames found to look up.");
|
||||||
setIsSuggesting(false)
|
setIsSuggesting(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch in chunks of 50 to avoid too-long URLs
|
// Batch in chunks of 50 to avoid too-long URLs
|
||||||
const CHUNK_SIZE = 50
|
const CHUNK_SIZE = 50;
|
||||||
const allSuggestions: Record<string, { name: string; description: string }> = {}
|
const allSuggestions: Record<string, { name: string; description: string }> = {};
|
||||||
|
|
||||||
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
|
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
|
||||||
const chunk = classnames.slice(i, i + CHUNK_SIZE)
|
const chunk = classnames.slice(i, i + CHUNK_SIZE);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(','))}`,
|
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
|
||||||
)
|
);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
Object.assign(allSuggestions, data.suggestions || {})
|
Object.assign(allSuggestions, data.suggestions || {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply suggestions to items that have a match (names + descriptions)
|
// Apply suggestions to items that have a match (names + descriptions)
|
||||||
let nameCount = 0
|
let nameCount = 0;
|
||||||
let descCount = 0
|
let descCount = 0;
|
||||||
const newNames = { ...editedNames }
|
const newNames = { ...editedNames };
|
||||||
const newDescs = { ...editedDescs }
|
const newDescs = { ...editedDescs };
|
||||||
|
|
||||||
for (const item of targetItems) {
|
for (const item of targetItems) {
|
||||||
const suggestion = allSuggestions[item.itemName]
|
const suggestion = allSuggestions[item.itemName];
|
||||||
if (!suggestion) continue
|
if (!suggestion) continue;
|
||||||
|
|
||||||
if (suggestion.name && suggestion.name !== item.publicName) {
|
if (suggestion.name && suggestion.name !== item.publicName) {
|
||||||
newNames[item.id] = suggestion.name
|
newNames[item.id] = suggestion.name;
|
||||||
nameCount++
|
nameCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDesc = furniDescriptionMap[item.id] ?? ''
|
const currentDesc = furniDescriptionMap[item.id] ?? "";
|
||||||
if (suggestion.description && suggestion.description !== currentDesc) {
|
if (suggestion.description && suggestion.description !== currentDesc) {
|
||||||
newDescs[item.id] = suggestion.description
|
newDescs[item.id] = suggestion.description;
|
||||||
descCount++
|
descCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditedNames(newNames)
|
setEditedNames(newNames);
|
||||||
setEditedDescs(newDescs)
|
setEditedDescs(newDescs);
|
||||||
|
|
||||||
const total = nameCount + descCount
|
const total = nameCount + descCount;
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
const parts = []
|
const parts = [];
|
||||||
if (nameCount > 0) parts.push(`${nameCount} name(s)`)
|
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
|
||||||
if (descCount > 0) parts.push(`${descCount} description(s)`)
|
if (descCount > 0) parts.push(`${descCount} description(s)`);
|
||||||
toast.success(`HabboFurni: ${parts.join(' + ')} suggested from habbo.it. Review and save.`)
|
toast.success(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`);
|
||||||
} else {
|
} else {
|
||||||
toast.info(
|
toast.info(
|
||||||
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
|
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`HabboFurni error: ${(err as Error).message}`)
|
toast.error(`HabboFurni error: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSuggesting(false)
|
setIsSuggesting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveAll() {
|
async function handleSaveAll() {
|
||||||
if (changedItems.length === 0) return
|
if (changedItems.length === 0) return;
|
||||||
|
|
||||||
run(
|
run(
|
||||||
() =>
|
() =>
|
||||||
@@ -229,26 +222,26 @@ export function CatalogTranslateTab({
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
successMessage: 'Translations saved.',
|
successMessage: "Translations saved.",
|
||||||
errorMessage: 'Failed to save translations.',
|
errorMessage: "Failed to save translations.",
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const d = data as Record<string, number | boolean>
|
const d = data as Record<string, number | boolean>;
|
||||||
const parts = []
|
const parts = [];
|
||||||
if (d.namesUpdated && (d.namesUpdated as number) > 0)
|
if (d.namesUpdated && (d.namesUpdated as number) > 0)
|
||||||
parts.push(`${d.namesUpdated} name(s) updated`)
|
parts.push(`${d.namesUpdated} name(s) updated`);
|
||||||
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
|
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
|
||||||
parts.push(`${d.descriptionsUpdated} description(s) updated`)
|
parts.push(`${d.descriptionsUpdated} description(s) updated`);
|
||||||
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`)
|
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`);
|
||||||
if (d.furniDataUpdated) parts.push('FurnitureData.json synced')
|
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
|
||||||
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
|
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
|
||||||
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`)
|
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`);
|
||||||
}
|
}
|
||||||
parts.push('emulator cache refreshed')
|
parts.push("emulator cache refreshed");
|
||||||
toast.success(`${parts.join(' · ')}.`)
|
toast.success(`${parts.join(" · ")}.`);
|
||||||
router.refresh()
|
router.refresh();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -274,8 +267,7 @@ export function CatalogTranslateTab({
|
|||||||
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
|
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
<span>
|
<span>
|
||||||
{missingItems.length} item(s) missing from FurnitureData.json — will be
|
{missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save
|
||||||
auto-inserted on save
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -294,7 +286,7 @@ export function CatalogTranslateTab({
|
|||||||
) : (
|
) : (
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ''}
|
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -305,11 +297,7 @@ export function CatalogTranslateTab({
|
|||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}>
|
||||||
size="sm"
|
|
||||||
onClick={handleSaveAll}
|
|
||||||
disabled={isPending || changedItems.length === 0}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -327,28 +315,28 @@ export function CatalogTranslateTab({
|
|||||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={filterMode === 'all' ? 'default' : 'outline'}
|
variant={filterMode === "all" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setFilterMode('all')}
|
onClick={() => setFilterMode("all")}
|
||||||
>
|
>
|
||||||
All ({baseItems.length})
|
All ({baseItems.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={filterMode === 'missing' ? 'default' : 'outline'}
|
variant={filterMode === "missing" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setFilterMode('missing')}
|
onClick={() => setFilterMode("missing")}
|
||||||
disabled={missingItems.length === 0}
|
disabled={missingItems.length === 0}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||||
Missing ({missingItems.length})
|
Missing ({missingItems.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={filterMode === 'modified' ? 'default' : 'outline'}
|
variant={filterMode === "modified" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setFilterMode('modified')}
|
onClick={() => setFilterMode("modified")}
|
||||||
disabled={changedItems.length === 0}
|
disabled={changedItems.length === 0}
|
||||||
>
|
>
|
||||||
Modified ({changedItems.length})
|
Modified ({changedItems.length})
|
||||||
@@ -362,9 +350,7 @@ export function CatalogTranslateTab({
|
|||||||
No items to translate in this page.
|
No items to translate in this page.
|
||||||
</p>
|
</p>
|
||||||
) : filteredItems.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
<p className="text-sm text-muted-foreground text-center py-8">No items match the current filter.</p>
|
||||||
No items match the current filter.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
<div className="rounded-md border overflow-auto max-h-[600px]">
|
||||||
@@ -389,19 +375,19 @@ export function CatalogTranslateTab({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const nameChanged = isNameChanged(item)
|
const nameChanged = isNameChanged(item);
|
||||||
const descChanged = isDescChanged(item)
|
const descChanged = isDescChanged(item);
|
||||||
const isChanged = nameChanged || descChanged
|
const isChanged = nameChanged || descChanged;
|
||||||
const isMissing = !furniDataIdSet.has(item.id)
|
const isMissing = !furniDataIdSet.has(item.id);
|
||||||
const currentCatalogName = catalogNameMap[item.id]
|
const currentCatalogName = catalogNameMap[item.id];
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={
|
className={
|
||||||
isChanged
|
isChanged
|
||||||
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]'
|
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
|
||||||
: isMissing
|
: isMissing
|
||||||
? 'bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]'
|
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -414,24 +400,18 @@ export function CatalogTranslateTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-1">
|
<TableCell className="px-1">
|
||||||
{(() => {
|
{(() => {
|
||||||
const revInfo = furniRevisionMap[item.id]
|
const revInfo = furniRevisionMap[item.id];
|
||||||
return revInfo ? (
|
return revInfo ? (
|
||||||
<FurniIcon
|
<FurniIcon classname={revInfo.classname} revision={revInfo.revision} />
|
||||||
classname={revInfo.classname}
|
|
||||||
revision={revInfo.revision}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<FurniIcon classname={item.itemName} revision={0} />
|
<FurniIcon classname={item.itemName} revision={0} />
|
||||||
)
|
);
|
||||||
})()}
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs">
|
||||||
variant={item.type === 's' ? 'default' : 'secondary'}
|
{item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type}
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{item.type === 's' ? 'Floor' : item.type === 'i' ? 'Wall' : item.type}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
@@ -439,10 +419,10 @@ export function CatalogTranslateTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
value={editedNames[item.id] || ''}
|
value={editedNames[item.id] || ""}
|
||||||
onChange={(e) => handleNameChange(item.id, e.target.value)}
|
onChange={(e) => handleNameChange(item.id, e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
className={`h-8 text-sm ${nameChanged ? 'border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]' : ''}`}
|
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
|
||||||
/>
|
/>
|
||||||
{currentCatalogName && currentCatalogName !== item.publicName && (
|
{currentCatalogName && currentCatalogName !== item.publicName && (
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||||
@@ -452,11 +432,11 @@ export function CatalogTranslateTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
value={editedDescs[item.id] || ''}
|
value={editedDescs[item.id] || ""}
|
||||||
onChange={(e) => handleDescChange(item.id, e.target.value)}
|
onChange={(e) => handleDescChange(item.id, e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
placeholder="No description"
|
placeholder="No description"
|
||||||
className={`h-8 text-sm ${descChanged ? 'border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]' : ''}`}
|
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -464,7 +444,7 @@ export function CatalogTranslateTab({
|
|||||||
{isChanged ? (
|
{isChanged ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
|
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
|
||||||
>
|
>
|
||||||
Modified
|
Modified
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -494,7 +474,7 @@ export function CatalogTranslateTab({
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -503,5 +483,5 @@ export function CatalogTranslateTab({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function CatalogDetailLoading() {
|
export default function CatalogDetailLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -37,5 +37,5 @@ export default function CatalogDetailLoading() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from "react";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { loadCatalogItemsData } from '@/lib/services/catalog-items-loader'
|
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||||
import { getAncestors } from '@/lib/services/catalog-tree'
|
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||||
import { CatalogDetailTabs } from './catalog-detail-tabs'
|
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
||||||
|
|
||||||
export default async function CatalogEditPage({
|
export default async function CatalogEditPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string; id: string }>
|
params: Promise<{ locale: string; id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params;
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageId = parseInt(id, 10)
|
const pageId = parseInt(id, 10);
|
||||||
if (Number.isNaN(pageId)) notFound()
|
if (Number.isNaN(pageId)) notFound();
|
||||||
|
|
||||||
const catalogPage = await prisma.catalogPages.findUnique({
|
const catalogPage = await prisma.catalogPages.findUnique({
|
||||||
where: { id: pageId },
|
where: { id: pageId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!catalogPage) notFound()
|
if (!catalogPage) notFound();
|
||||||
|
|
||||||
const [children, itemsData, ancestors] = await Promise.all([
|
const [children, itemsData, ancestors] = await Promise.all([
|
||||||
prisma.catalogPages.findMany({
|
prisma.catalogPages.findMany({
|
||||||
where: { parentId: pageId },
|
where: { parentId: pageId },
|
||||||
orderBy: { orderNum: 'asc' },
|
orderBy: { orderNum: "asc" },
|
||||||
select: { id: true, caption: true, enabled: true },
|
select: { id: true, caption: true, enabled: true },
|
||||||
}),
|
}),
|
||||||
loadCatalogItemsData(pageId),
|
loadCatalogItemsData(pageId),
|
||||||
getAncestors(catalogPage.parentId),
|
getAncestors(catalogPage.parentId),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items: catalogItems,
|
items: catalogItems,
|
||||||
@@ -47,24 +47,21 @@ export default async function CatalogEditPage({
|
|||||||
furniRevisionMap,
|
furniRevisionMap,
|
||||||
interactionTypes,
|
interactionTypes,
|
||||||
allPages,
|
allPages,
|
||||||
} = itemsData
|
} = itemsData;
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Link href={'/admin/catalog'} className="hover:text-foreground transition-colors">
|
<Link href={"/admin/catalog"} className="hover:text-foreground transition-colors">
|
||||||
Catalog
|
Catalog
|
||||||
</Link>
|
</Link>
|
||||||
{ancestors.map((bc) => (
|
{ancestors.map((bc) => (
|
||||||
<Fragment key={bc.id}>
|
<Fragment key={bc.id}>
|
||||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
||||||
<Link
|
<Link href={`/admin/catalog/${bc.id}`} className="hover:text-foreground transition-colors">
|
||||||
href={`/admin/catalog/${bc.id}`}
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{bc.caption}
|
{bc.caption}
|
||||||
</Link>
|
</Link>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -77,9 +74,7 @@ export default async function CatalogEditPage({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
{catalogPage.caption}
|
{catalogPage.caption}
|
||||||
<span className="ml-2 text-base font-normal text-muted-foreground">
|
<span className="ml-2 text-base font-normal text-muted-foreground">#{catalogPage.id}</span>
|
||||||
#{catalogPage.id}
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Layout: {catalogPage.pageLayout} · {catalogItems.length} items
|
Layout: {catalogPage.pageLayout} · {catalogItems.length} items
|
||||||
@@ -121,5 +116,5 @@ export default async function CatalogEditPage({
|
|||||||
interactionTypes={interactionTypes}
|
interactionTypes={interactionTypes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,31 @@
|
|||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BcPageDetail } from '../bc-manager'
|
import { BcPageDetail } from "../bc-manager";
|
||||||
|
|
||||||
export default async function BcPageDetailPage({
|
export default async function BcPageDetailPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string; id: string }>
|
params: Promise<{ locale: string; id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id: idStr } = await paramsPromise
|
const { id: idStr } = await paramsPromise;
|
||||||
const id = parseInt(idStr, 10)
|
const id = parseInt(idStr, 10);
|
||||||
if (Number.isNaN(id)) notFound()
|
if (Number.isNaN(id)) notFound();
|
||||||
|
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
|
|
||||||
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } })
|
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
|
||||||
if (!bcPage) notFound()
|
if (!bcPage) notFound();
|
||||||
|
|
||||||
const bcItems = await prisma.catalogItemsBc.findMany({
|
const bcItems = await prisma.catalogItemsBc.findMany({
|
||||||
where: { pageId: id },
|
where: { pageId: id },
|
||||||
orderBy: { orderNumber: 'asc' },
|
orderBy: { orderNumber: "asc" },
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BcPageDetail
|
<BcPageDetail
|
||||||
@@ -41,11 +41,11 @@ export default async function BcPageDetailPage({
|
|||||||
enabled: bcPage.enabled,
|
enabled: bcPage.enabled,
|
||||||
pageHeadline: bcPage.pageHeadline,
|
pageHeadline: bcPage.pageHeadline,
|
||||||
pageTeaser: bcPage.pageTeaser,
|
pageTeaser: bcPage.pageTeaser,
|
||||||
pageSpecial: bcPage.pageSpecial ?? '',
|
pageSpecial: bcPage.pageSpecial ?? "",
|
||||||
pageText1: bcPage.pageText1 ?? '',
|
pageText1: bcPage.pageText1 ?? "",
|
||||||
pageText2: bcPage.pageText2 ?? '',
|
pageText2: bcPage.pageText2 ?? "",
|
||||||
pageTextDetails: bcPage.pageTextDetails ?? '',
|
pageTextDetails: bcPage.pageTextDetails ?? "",
|
||||||
pageTextTeaser: bcPage.pageTextTeaser ?? '',
|
pageTextTeaser: bcPage.pageTextTeaser ?? "",
|
||||||
}}
|
}}
|
||||||
items={bcItems.map((i) => ({
|
items={bcItems.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
@@ -57,5 +57,5 @@ export default async function BcPageDetailPage({
|
|||||||
}))}
|
}))}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,82 +1,69 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from 'lucide-react'
|
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from '@/actions/catalog-bc'
|
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from "@/actions/catalog-bc";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
import { Input } from "@/components/ui/input";
|
||||||
DialogContent,
|
import { Label } from "@/components/ui/label";
|
||||||
DialogFooter,
|
import { Switch } from "@/components/ui/switch";
|
||||||
DialogHeader,
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
DialogTitle,
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
} from '@/components/ui/dialog'
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface BcPageData {
|
export interface BcPageData {
|
||||||
id: number
|
id: number;
|
||||||
parentId: number
|
parentId: number;
|
||||||
caption: string
|
caption: string;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
iconColor: number
|
iconColor: number;
|
||||||
iconImage: number
|
iconImage: number;
|
||||||
orderNum: number
|
orderNum: number;
|
||||||
visible: string
|
visible: string;
|
||||||
enabled: string
|
enabled: string;
|
||||||
pageHeadline: string
|
pageHeadline: string;
|
||||||
pageTeaser: string
|
pageTeaser: string;
|
||||||
pageSpecial: string
|
pageSpecial: string;
|
||||||
pageText1: string
|
pageText1: string;
|
||||||
pageText2: string
|
pageText2: string;
|
||||||
pageTextDetails: string
|
pageTextDetails: string;
|
||||||
pageTextTeaser: string
|
pageTextTeaser: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BcItemData {
|
export interface BcItemData {
|
||||||
id: number
|
id: number;
|
||||||
pageId: number
|
pageId: number;
|
||||||
itemIds: string
|
itemIds: string;
|
||||||
catalogName: string
|
catalogName: string;
|
||||||
orderNumber: number
|
orderNumber: number;
|
||||||
extradata: string
|
extradata: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BcPageDetailProps {
|
interface BcPageDetailProps {
|
||||||
page: BcPageData
|
page: BcPageData;
|
||||||
items: BcItemData[]
|
items: BcItemData[];
|
||||||
canEdit: boolean
|
canEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Component ───────────────────────────────────────────────────
|
// ── Main Component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||||
const { isPending, run } = useServerAction()
|
const { isPending, run } = useServerAction();
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [editingItem, setEditingItem] = useState<BcItemData | null>(null)
|
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
|
||||||
const [addingItem, setAddingItem] = useState(false)
|
const [addingItem, setAddingItem] = useState(false);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
caption: page.caption,
|
caption: page.caption,
|
||||||
orderNum: page.orderNum,
|
orderNum: page.orderNum,
|
||||||
enabled: page.enabled as '0' | '1',
|
enabled: page.enabled as "0" | "1",
|
||||||
visible: page.visible as '0' | '1',
|
visible: page.visible as "0" | "1",
|
||||||
pageHeadline: page.pageHeadline,
|
pageHeadline: page.pageHeadline,
|
||||||
pageTeaser: page.pageTeaser,
|
pageTeaser: page.pageTeaser,
|
||||||
pageSpecial: page.pageSpecial,
|
pageSpecial: page.pageSpecial,
|
||||||
@@ -84,31 +71,31 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
pageText2: page.pageText2,
|
pageText2: page.pageText2,
|
||||||
pageTextDetails: page.pageTextDetails,
|
pageTextDetails: page.pageTextDetails,
|
||||||
pageTextTeaser: page.pageTextTeaser,
|
pageTextTeaser: page.pageTextTeaser,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Ctrl+S / Cmd+S to save
|
// Ctrl+S / Cmd+S to save
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canEdit) return
|
if (!canEdit) return;
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
formRef.current?.requestSubmit()
|
formRef.current?.requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [canEdit])
|
}, [canEdit]);
|
||||||
|
|
||||||
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }))
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
run(() => updateBcPage({ id: page.id, ...form }), {
|
run(() => updateBcPage({ id: page.id, ...form }), {
|
||||||
successMessage: 'BC page updated and catalog synced.',
|
successMessage: "BC page updated and catalog synced.",
|
||||||
errorMessage: 'Failed to update.',
|
errorMessage: "Failed to update.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,7 +137,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Input
|
<Input
|
||||||
id="caption"
|
id="caption"
|
||||||
value={form.caption}
|
value={form.caption}
|
||||||
onChange={(e) => update('caption', e.target.value)}
|
onChange={(e) => update("caption", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +147,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
id="orderNum"
|
id="orderNum"
|
||||||
type="number"
|
type="number"
|
||||||
value={form.orderNum}
|
value={form.orderNum}
|
||||||
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +157,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Headline</Label>
|
<Label>Headline</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.pageHeadline}
|
value={form.pageHeadline}
|
||||||
onChange={(e) => update('pageHeadline', e.target.value)}
|
onChange={(e) => update("pageHeadline", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
placeholder="Headline image path"
|
placeholder="Headline image path"
|
||||||
/>
|
/>
|
||||||
@@ -179,7 +166,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Teaser</Label>
|
<Label>Teaser</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.pageTeaser}
|
value={form.pageTeaser}
|
||||||
onChange={(e) => update('pageTeaser', e.target.value)}
|
onChange={(e) => update("pageTeaser", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
placeholder="Teaser image path"
|
placeholder="Teaser image path"
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +177,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Text 1</Label>
|
<Label>Text 1</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.pageText1}
|
value={form.pageText1}
|
||||||
onChange={(e) => update('pageText1', e.target.value)}
|
onChange={(e) => update("pageText1", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -199,7 +186,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Text 2</Label>
|
<Label>Text 2</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.pageText2}
|
value={form.pageText2}
|
||||||
onChange={(e) => update('pageText2', e.target.value)}
|
onChange={(e) => update("pageText2", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -211,7 +198,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Details</Label>
|
<Label>Details</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.pageTextDetails}
|
value={form.pageTextDetails}
|
||||||
onChange={(e) => update('pageTextDetails', e.target.value)}
|
onChange={(e) => update("pageTextDetails", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -220,7 +207,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Text Teaser</Label>
|
<Label>Text Teaser</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.pageTextTeaser}
|
value={form.pageTextTeaser}
|
||||||
onChange={(e) => update('pageTextTeaser', e.target.value)}
|
onChange={(e) => update("pageTextTeaser", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +218,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
<Label>Special</Label>
|
<Label>Special</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.pageSpecial}
|
value={form.pageSpecial}
|
||||||
onChange={(e) => update('pageSpecial', e.target.value)}
|
onChange={(e) => update("pageSpecial", e.target.value)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
@@ -263,9 +250,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">No items in this page.</p>
|
||||||
No items in this page.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -288,7 +273,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{item.orderNumber}</TableCell>
|
<TableCell className="text-xs">{item.orderNumber}</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
||||||
{item.extradata || '\u2014'}
|
{item.extradata || "\u2014"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -308,10 +293,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!confirm(`Delete item "${item.catalogName}"?`)) return
|
if (!confirm(`Delete item "${item.catalogName}"?`)) return;
|
||||||
run(() => deleteBcItem({ id: item.id }), {
|
run(() => deleteBcItem({ id: item.id }), {
|
||||||
successMessage: 'Item deleted.',
|
successMessage: "Item deleted.",
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
@@ -340,16 +325,16 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
id="tg-enabled"
|
id="tg-enabled"
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
description="Active in the BC catalog"
|
description="Active in the BC catalog"
|
||||||
checked={form.enabled === '1'}
|
checked={form.enabled === "1"}
|
||||||
onChange={(v) => update('enabled', v ? '1' : '0')}
|
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
id="tg-visible"
|
id="tg-visible"
|
||||||
label="Visible"
|
label="Visible"
|
||||||
description="Shown in BC navigation"
|
description="Shown in BC navigation"
|
||||||
checked={form.visible === '1'}
|
checked={form.visible === "1"}
|
||||||
onChange={(v) => update('visible', v ? '1' : '0')}
|
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,10 +345,8 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
Layout: <span className="font-mono">{page.pageLayout}</span>
|
Layout: <span className="font-mono">{page.pageLayout}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Parent:{' '}
|
Parent:{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">{page.parentId <= 0 ? "Root" : `#${page.parentId}`}</span>
|
||||||
{page.parentId <= 0 ? 'Root' : `#${page.parentId}`}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -398,9 +381,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
onClose={() => setEditingItem(null)}
|
onClose={() => setEditingItem(null)}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
run(() => updateBcItem({ id: editingItem.id, ...data }), {
|
run(() => updateBcItem({ id: editingItem.id, ...data }), {
|
||||||
successMessage: 'Item updated.',
|
successMessage: "Item updated.",
|
||||||
onSuccess: () => setEditingItem(null),
|
onSuccess: () => setEditingItem(null),
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -411,24 +394,24 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|||||||
item={{
|
item={{
|
||||||
id: 0,
|
id: 0,
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
itemIds: '',
|
itemIds: "",
|
||||||
catalogName: '',
|
catalogName: "",
|
||||||
orderNumber: 1,
|
orderNumber: 1,
|
||||||
extradata: '',
|
extradata: "",
|
||||||
}}
|
}}
|
||||||
mode="add"
|
mode="add"
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
onClose={() => setAddingItem(false)}
|
onClose={() => setAddingItem(false)}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
run(() => createBcItem({ pageId: page.id, ...data }), {
|
run(() => createBcItem({ pageId: page.id, ...data }), {
|
||||||
successMessage: 'Item created.',
|
successMessage: "Item created.",
|
||||||
onSuccess: () => setAddingItem(false),
|
onSuccess: () => setAddingItem(false),
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle row ───────────────────────────────────────────────────────
|
// ── Toggle row ───────────────────────────────────────────────────────
|
||||||
@@ -441,12 +424,12 @@ function ToggleRow({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
description: string
|
description: string;
|
||||||
checked: boolean
|
checked: boolean;
|
||||||
onChange: (v: boolean) => void
|
onChange: (v: boolean) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -458,7 +441,7 @@ function ToggleRow({
|
|||||||
</div>
|
</div>
|
||||||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Item Dialog ──────────────────────────────────────────────────────
|
// ── Item Dialog ──────────────────────────────────────────────────────
|
||||||
@@ -470,29 +453,24 @@ function ItemDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
item: BcItemData
|
item: BcItemData;
|
||||||
mode: 'add' | 'edit'
|
mode: "add" | "edit";
|
||||||
isPending: boolean
|
isPending: boolean;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSubmit: (data: {
|
onSubmit: (data: { itemIds: string; catalogName: string; orderNumber: number; extradata: string }) => void;
|
||||||
itemIds: string
|
|
||||||
catalogName: string
|
|
||||||
orderNumber: number
|
|
||||||
extradata: string
|
|
||||||
}) => void
|
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
itemIds: item.itemIds,
|
itemIds: item.itemIds,
|
||||||
catalogName: item.catalogName,
|
catalogName: item.catalogName,
|
||||||
orderNumber: item.orderNumber,
|
orderNumber: item.orderNumber,
|
||||||
extradata: item.extradata,
|
extradata: item.extradata,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{mode === 'add' ? 'Add BC Item' : `Edit Item #${item.id}`}</DialogTitle>
|
<DialogTitle>{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -515,9 +493,7 @@ function ItemDialog({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={form.orderNumber}
|
value={form.orderNumber}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))}
|
||||||
setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -534,15 +510,12 @@ function ItemDialog({
|
|||||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={() => onSubmit(form)} disabled={isPending || !form.catalogName || !form.itemIds}>
|
||||||
onClick={() => onSubmit(form)}
|
|
||||||
disabled={isPending || !form.catalogName || !form.itemIds}
|
|
||||||
>
|
|
||||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{mode === 'add' ? 'Add Item' : 'Save'}
|
{mode === "add" ? "Add Item" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function BuilderClubPage() {
|
export default function BuilderClubPage() {
|
||||||
redirect('/admin/catalog?catalog=bc')
|
redirect("/admin/catalog?catalog=bc");
|
||||||
}
|
}
|
||||||
@@ -1,69 +1,57 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Loader2, Plus } from 'lucide-react'
|
import { Loader2, Plus } from "lucide-react";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { createCatalogPage } from '@/actions/catalog'
|
import { createCatalogPage } from "@/actions/catalog";
|
||||||
import { createBcPage } from '@/actions/catalog-bc'
|
import { createBcPage } from "@/actions/catalog-bc";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
Dialog,
|
import { Input } from "@/components/ui/input";
|
||||||
DialogContent,
|
import { Label } from "@/components/ui/label";
|
||||||
DialogHeader,
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
DialogTitle,
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
DialogTrigger,
|
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
import { CATALOG_LAYOUTS } from '@/lib/catalog-layouts'
|
|
||||||
|
|
||||||
interface CatalogActionsProps {
|
interface CatalogActionsProps {
|
||||||
catalogType?: 'normal' | 'bc'
|
catalogType?: "normal" | "bc";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps) {
|
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState("");
|
||||||
const [parentId, setParentId] = useState('-1')
|
const [parentId, setParentId] = useState("-1");
|
||||||
const [pageLayout, setPageLayout] = useState('default_3x3')
|
const [pageLayout, setPageLayout] = useState("default_3x3");
|
||||||
const isBc = catalogType === 'bc'
|
const isBc = catalogType === "bc";
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
if (!caption.trim()) {
|
if (!caption.trim()) {
|
||||||
toast.error('Caption is required')
|
toast.error("Caption is required");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
caption: caption.trim(),
|
caption: caption.trim(),
|
||||||
parentId: parseInt(parentId, 10),
|
parentId: parseInt(parentId, 10),
|
||||||
pageLayout,
|
pageLayout,
|
||||||
}
|
};
|
||||||
|
|
||||||
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input)
|
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
|
||||||
|
|
||||||
run(fn, {
|
run(fn, {
|
||||||
successMessage: `Catalog page "${caption.trim()}" created.`,
|
successMessage: `Catalog page "${caption.trim()}" created.`,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
setCaption('')
|
setCaption("");
|
||||||
setParentId('-1')
|
setParentId("-1");
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
window.location.href = isBc
|
window.location.href = isBc
|
||||||
? `/admin/catalog/builder-club/${data.id}`
|
? `/admin/catalog/builder-club/${data.id}`
|
||||||
: `/admin/catalog/${data.id}`
|
: `/admin/catalog/${data.id}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +64,7 @@ export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps)
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isBc ? 'Create Builder Club Page' : 'Create Catalog Page'}</DialogTitle>
|
<DialogTitle>{isBc ? "Create Builder Club Page" : "Create Catalog Page"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -124,5 +112,5 @@ export function CatalogActions({ catalogType = 'normal' }: CatalogActionsProps)
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,57 +1,56 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Pencil } from 'lucide-react'
|
import { Pencil } from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { toggleCatalogPage } from '@/actions/catalog'
|
import { toggleCatalogPage } from "@/actions/catalog";
|
||||||
import { toggleBcPage } from '@/actions/catalog-bc'
|
import { toggleBcPage } from "@/actions/catalog-bc";
|
||||||
import { DataTable } from '@/components/admin/data-table'
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
interface CatalogPageRow {
|
interface CatalogPageRow {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
parentId: number
|
parentId: number;
|
||||||
parentCaption: string
|
parentCaption: string;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
enabled: string
|
enabled: string;
|
||||||
visible: string
|
visible: string;
|
||||||
minRank: number
|
minRank: number;
|
||||||
orderNum: number
|
orderNum: number;
|
||||||
itemCount: number
|
itemCount: number;
|
||||||
childCount: number
|
childCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogTableProps {
|
interface CatalogTableProps {
|
||||||
data: PaginatedResult<CatalogPageRow>
|
data: PaginatedResult<CatalogPageRow>;
|
||||||
canEdit: boolean
|
canEdit: boolean;
|
||||||
catalogType?: 'normal' | 'bc'
|
catalogType?: "normal" | "bc";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogTableProps) {
|
export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogTableProps) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const isBc = catalogType === 'bc'
|
const isBc = catalogType === "bc";
|
||||||
|
|
||||||
// Parent URLs stay in the same catalog; root takes you to the catalog index.
|
// Parent URLs stay in the same catalog; root takes you to the catalog index.
|
||||||
const detailHref = (id: number) =>
|
const detailHref = (id: number) => (isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`);
|
||||||
isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`
|
|
||||||
|
|
||||||
function handleToggle(id: number, action: 'toggleEnabled' | 'toggleVisible') {
|
function handleToggle(id: number, action: "toggleEnabled" | "toggleVisible") {
|
||||||
if (isBc) {
|
if (isBc) {
|
||||||
const field = action === 'toggleEnabled' ? 'enabled' : 'visible'
|
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||||
run(() => toggleBcPage({ id, field }), { successMessage: 'Updated.' })
|
run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
|
||||||
} else {
|
} else {
|
||||||
run(() => toggleCatalogPage({ id, action }), { successMessage: 'Updated.' })
|
run(() => toggleCatalogPage({ id, action }), { successMessage: "Updated." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<CatalogPageRow>[] = [
|
const columns: DataTableColumn<CatalogPageRow>[] = [
|
||||||
{ key: 'id', label: 'ID', sortable: true },
|
{ key: "id", label: "ID", sortable: true },
|
||||||
{
|
{
|
||||||
key: 'caption',
|
key: "caption",
|
||||||
label: 'Caption',
|
label: "Caption",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value, row) => (
|
render: (value, row) => (
|
||||||
<Link href={detailHref(row.id)} className="font-medium text-primary hover:underline">
|
<Link href={detailHref(row.id)} className="font-medium text-primary hover:underline">
|
||||||
@@ -60,8 +59,8 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'parentId',
|
key: "parentId",
|
||||||
label: 'Parent',
|
label: "Parent",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.parentId === -1 ? (
|
row.parentId === -1 ? (
|
||||||
@@ -73,86 +72,78 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pageLayout',
|
key: "pageLayout",
|
||||||
label: 'Layout',
|
label: "Layout",
|
||||||
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
|
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'enabled',
|
key: "enabled",
|
||||||
label: 'Enabled',
|
label: "Enabled",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterKey: 'filter_enabled',
|
filterKey: "filter_enabled",
|
||||||
filterOptions: [
|
filterOptions: [
|
||||||
{ label: 'Enabled', value: '1' },
|
{ label: "Enabled", value: "1" },
|
||||||
{ label: 'Disabled', value: '0' },
|
{ label: "Disabled", value: "0" },
|
||||||
],
|
],
|
||||||
render: (value, row) =>
|
render: (value, row) =>
|
||||||
canEdit ? (
|
canEdit ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggle(row.id, 'toggleEnabled')}
|
onClick={() => handleToggle(row.id, "toggleEnabled")}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="cursor-pointer disabled:opacity-50"
|
className="cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||||
{value === '1' ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||||
{value === '1' ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'visible',
|
key: "visible",
|
||||||
label: 'Visible',
|
label: "Visible",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterKey: 'filter_visible',
|
filterKey: "filter_visible",
|
||||||
filterOptions: [
|
filterOptions: [
|
||||||
{ label: 'Visible', value: '1' },
|
{ label: "Visible", value: "1" },
|
||||||
{ label: 'Hidden', value: '0' },
|
{ label: "Hidden", value: "0" },
|
||||||
],
|
],
|
||||||
render: (value, row) =>
|
render: (value, row) =>
|
||||||
canEdit ? (
|
canEdit ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggle(row.id, 'toggleVisible')}
|
onClick={() => handleToggle(row.id, "toggleVisible")}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="cursor-pointer disabled:opacity-50"
|
className="cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||||
{value === '1' ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant={value === '1' ? 'default' : 'outline'}>
|
<Badge variant={value === "1" ? "default" : "outline"}>{value === "1" ? "Yes" : "No"}</Badge>
|
||||||
{value === '1' ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(isBc
|
...(isBc
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
key: 'minRank' as const,
|
key: "minRank" as const,
|
||||||
label: 'Min Rank',
|
label: "Min Rank",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
} satisfies DataTableColumn<CatalogPageRow>,
|
} satisfies DataTableColumn<CatalogPageRow>,
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
key: 'itemCount',
|
key: "itemCount",
|
||||||
label: 'Items',
|
label: "Items",
|
||||||
render: (value) => <span>{String(value)}</span>,
|
render: (value) => <span>{String(value)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'childCount',
|
key: "childCount",
|
||||||
label: 'Children',
|
label: "Children",
|
||||||
render: (value) => <span>{String(value)}</span>,
|
render: (value) => <span>{String(value)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: "actions",
|
||||||
label: 'Actions',
|
label: "Actions",
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" asChild>
|
||||||
<Link href={detailHref(row.id)}>
|
<Link href={detailHref(row.id)}>
|
||||||
@@ -161,7 +152,7 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -169,10 +160,8 @@ export function CatalogTable({ data, canEdit, catalogType = 'normal' }: CatalogT
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
searchPlaceholder="Search by caption, layout or item..."
|
searchPlaceholder="Search by caption, layout or item..."
|
||||||
bulkActions={
|
bulkActions={
|
||||||
canEdit
|
canEdit ? [{ label: "Delete Selected", value: "delete", variant: "destructive" }] : undefined
|
||||||
? [{ label: 'Delete Selected', value: 'delete', variant: 'destructive' }]
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { FolderTree, HardHat, List, Package } from 'lucide-react'
|
import { FolderTree, HardHat, List, Package } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CatalogViewToggleProps {
|
interface CatalogViewToggleProps {
|
||||||
currentView: string
|
currentView: string;
|
||||||
catalogType?: string
|
catalogType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogViewToggle({ currentView, catalogType = 'normal' }: CatalogViewToggleProps) {
|
export function CatalogViewToggle({ currentView, catalogType = "normal" }: CatalogViewToggleProps) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
function setView(view: string) {
|
function setView(view: string) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (view === 'tree') {
|
if (view === "tree") {
|
||||||
params.delete('view')
|
params.delete("view");
|
||||||
} else {
|
} else {
|
||||||
params.set('view', view)
|
params.set("view", view);
|
||||||
}
|
}
|
||||||
router.push(`?${params.toString()}`)
|
router.push(`?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCatalog(type: string) {
|
function setCatalog(type: string) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (type === 'normal') {
|
if (type === "normal") {
|
||||||
params.delete('catalog')
|
params.delete("catalog");
|
||||||
} else {
|
} else {
|
||||||
params.set('catalog', type)
|
params.set("catalog", type);
|
||||||
}
|
}
|
||||||
params.delete('view') // reset to tree when switching
|
params.delete("view"); // reset to tree when switching
|
||||||
router.push(`?${params.toString()}`)
|
router.push(`?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,8 +42,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn('h-8 rounded-r-none', catalogType === 'normal' && 'bg-accent')}
|
className={cn("h-8 rounded-r-none", catalogType === "normal" && "bg-accent")}
|
||||||
onClick={() => setCatalog('normal')}
|
onClick={() => setCatalog("normal")}
|
||||||
>
|
>
|
||||||
<Package className="mr-1.5 h-4 w-4" />
|
<Package className="mr-1.5 h-4 w-4" />
|
||||||
Catalog
|
Catalog
|
||||||
@@ -51,8 +51,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn('h-8 rounded-l-none', catalogType === 'bc' && 'bg-accent')}
|
className={cn("h-8 rounded-l-none", catalogType === "bc" && "bg-accent")}
|
||||||
onClick={() => setCatalog('bc')}
|
onClick={() => setCatalog("bc")}
|
||||||
>
|
>
|
||||||
<HardHat className="mr-1.5 h-4 w-4" />
|
<HardHat className="mr-1.5 h-4 w-4" />
|
||||||
Builder Club
|
Builder Club
|
||||||
@@ -64,8 +64,8 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn('h-8 rounded-r-none', currentView === 'tree' && 'bg-accent')}
|
className={cn("h-8 rounded-r-none", currentView === "tree" && "bg-accent")}
|
||||||
onClick={() => setView('tree')}
|
onClick={() => setView("tree")}
|
||||||
>
|
>
|
||||||
<FolderTree className="mr-1.5 h-4 w-4" />
|
<FolderTree className="mr-1.5 h-4 w-4" />
|
||||||
Tree
|
Tree
|
||||||
@@ -73,13 +73,13 @@ export function CatalogViewToggle({ currentView, catalogType = 'normal' }: Catal
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn('h-8 rounded-l-none', currentView === 'table' && 'bg-accent')}
|
className={cn("h-8 rounded-l-none", currentView === "table" && "bg-accent")}
|
||||||
onClick={() => setView('table')}
|
onClick={() => setView("table")}
|
||||||
>
|
>
|
||||||
<List className="mr-1.5 h-4 w-4" />
|
<List className="mr-1.5 h-4 w-4" />
|
||||||
Table
|
Table
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function CatalogLoading() {
|
export default function CatalogLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -40,5 +40,5 @@ export default function CatalogLoading() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
+141
-146
@@ -1,87 +1,87 @@
|
|||||||
import dynamic from 'next/dynamic'
|
import dynamic from "next/dynamic";
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { CatalogTree } from '@/components/admin/catalog-tree'
|
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||||
import { Prisma } from '@/generated/prisma/client'
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { CatalogActions } from './catalog-actions'
|
import { CatalogActions } from "./catalog-actions";
|
||||||
import { CatalogTable } from './catalog-table'
|
import { CatalogTable } from "./catalog-table";
|
||||||
import { CatalogViewToggle } from './catalog-view-toggle'
|
import { CatalogViewToggle } from "./catalog-view-toggle";
|
||||||
|
|
||||||
const CatalogManagerDialog = dynamic(() =>
|
const CatalogManagerDialog = dynamic(() =>
|
||||||
import('@/components/admin/catalog-manager/catalog-manager-dialog').then((m) => ({
|
import("@/components/admin/catalog-manager/catalog-manager-dialog").then((m) => ({
|
||||||
default: m.CatalogManagerDialog,
|
default: m.CatalogManagerDialog,
|
||||||
})),
|
})),
|
||||||
)
|
);
|
||||||
export default async function CatalogPagesPage({
|
export default async function CatalogPagesPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
searchParams: Promise<Record<string, string>>
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
await paramsPromise
|
await paramsPromise;
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
|
|
||||||
const params = await searchParams
|
const params = await searchParams;
|
||||||
const sp = new URLSearchParams(params)
|
const sp = new URLSearchParams(params);
|
||||||
const { search, perPage, page, sort, order } = parseListParams(sp)
|
const { search, perPage, page, sort, order } = parseListParams(sp);
|
||||||
const viewMode = sp.get('view') || 'tree'
|
const viewMode = sp.get("view") || "tree";
|
||||||
const catalogType = sp.get('catalog') || 'normal'
|
const catalogType = sp.get("catalog") || "normal";
|
||||||
|
|
||||||
// ── Builder Club mode ──────────────────────────────────────────
|
// ── Builder Club mode ──────────────────────────────────────────
|
||||||
type TreeNodeShape = {
|
type TreeNodeShape = {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
parentId: number
|
parentId: number;
|
||||||
enabled: string
|
enabled: string;
|
||||||
visible: string
|
visible: string;
|
||||||
iconImage: number
|
iconImage: number;
|
||||||
iconColor: number
|
iconColor: number;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
childCount: number
|
childCount: number;
|
||||||
itemCount: number
|
itemCount: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (catalogType === 'bc') {
|
if (catalogType === "bc") {
|
||||||
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
|
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
|
||||||
prisma.catalogPagesBc.count(),
|
prisma.catalogPagesBc.count(),
|
||||||
prisma.catalogItemsBc.count(),
|
prisma.catalogItemsBc.count(),
|
||||||
prisma.catalogPagesBc.count({ where: { enabled: '1' } }),
|
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
// ── BC tree view ──────────────────────────────────────────────
|
// ── BC tree view ──────────────────────────────────────────────
|
||||||
let bcRootPages: TreeNodeShape[] = []
|
let bcRootPages: TreeNodeShape[] = [];
|
||||||
if (viewMode === 'tree') {
|
if (viewMode === "tree") {
|
||||||
const bcRootRaw = await prisma.catalogPagesBc.findMany({
|
const bcRootRaw = await prisma.catalogPagesBc.findMany({
|
||||||
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
|
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
|
||||||
orderBy: { orderNum: 'asc' },
|
orderBy: { orderNum: "asc" },
|
||||||
})
|
});
|
||||||
const bcRootIds = bcRootRaw.map((p) => p.id)
|
const bcRootIds = bcRootRaw.map((p) => p.id);
|
||||||
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
||||||
bcRootIds.length > 0
|
bcRootIds.length > 0
|
||||||
? prisma.catalogPagesBc.groupBy({
|
? prisma.catalogPagesBc.groupBy({
|
||||||
by: ['parentId'],
|
by: ["parentId"],
|
||||||
where: { parentId: { in: bcRootIds } },
|
where: { parentId: { in: bcRootIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
bcRootIds.length > 0
|
bcRootIds.length > 0
|
||||||
? prisma.catalogItemsBc.groupBy({
|
? prisma.catalogItemsBc.groupBy({
|
||||||
by: ['pageId'],
|
by: ["pageId"],
|
||||||
where: { pageId: { in: bcRootIds } },
|
where: { pageId: { in: bcRootIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
])
|
]);
|
||||||
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]))
|
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
|
||||||
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]))
|
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
|
||||||
bcRootPages = bcRootRaw.map((p) => ({
|
bcRootPages = bcRootRaw.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
caption: p.caption,
|
caption: p.caption,
|
||||||
@@ -93,45 +93,45 @@ export default async function CatalogPagesPage({
|
|||||||
pageLayout: p.pageLayout,
|
pageLayout: p.pageLayout,
|
||||||
childCount: bcChildMap[p.id] ?? 0,
|
childCount: bcChildMap[p.id] ?? 0,
|
||||||
itemCount: bcItemMap[p.id] ?? 0,
|
itemCount: bcItemMap[p.id] ?? 0,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BC table view ─────────────────────────────────────────────
|
// ── BC table view ─────────────────────────────────────────────
|
||||||
let bcRows: {
|
let bcRows: {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
parentId: number
|
parentId: number;
|
||||||
parentCaption: string
|
parentCaption: string;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
enabled: string
|
enabled: string;
|
||||||
visible: string
|
visible: string;
|
||||||
minRank: number
|
minRank: number;
|
||||||
orderNum: number
|
orderNum: number;
|
||||||
itemCount: number
|
itemCount: number;
|
||||||
childCount: number
|
childCount: number;
|
||||||
}[] = []
|
}[] = [];
|
||||||
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 }
|
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
||||||
|
|
||||||
if (viewMode === 'table') {
|
if (viewMode === "table") {
|
||||||
const parentFilter = sp.get('parent')
|
const parentFilter = sp.get("parent");
|
||||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
|
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {};
|
||||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
|
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
||||||
if (search) {
|
if (search) {
|
||||||
const pattern = { contains: search }
|
const pattern = { contains: search };
|
||||||
where.OR = [{ caption: pattern }]
|
where.OR = [{ caption: pattern }];
|
||||||
}
|
}
|
||||||
if (hasParent) where.parentId = parentId
|
if (hasParent) where.parentId = parentId;
|
||||||
|
|
||||||
// Prisma sort key is snake_case for BC model. Translate known keys.
|
// Prisma sort key is snake_case for BC model. Translate known keys.
|
||||||
const sortKeyMap: Record<string, string> = {
|
const sortKeyMap: Record<string, string> = {
|
||||||
parentId: 'parentId',
|
parentId: "parentId",
|
||||||
pageLayout: 'pageLayout',
|
pageLayout: "pageLayout",
|
||||||
orderNum: 'orderNum',
|
orderNum: "orderNum",
|
||||||
}
|
};
|
||||||
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'orderNum'
|
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
|
||||||
const orderBy = { [sortKey]: sort ? order : 'asc' }
|
const orderBy = { [sortKey]: sort ? order : "asc" };
|
||||||
|
|
||||||
const [pages, total] = await Promise.all([
|
const [pages, total] = await Promise.all([
|
||||||
prisma.catalogPagesBc.findMany({
|
prisma.catalogPagesBc.findMany({
|
||||||
@@ -141,23 +141,23 @@ export default async function CatalogPagesPage({
|
|||||||
take: perPage,
|
take: perPage,
|
||||||
}),
|
}),
|
||||||
prisma.catalogPagesBc.count({ where }),
|
prisma.catalogPagesBc.count({ where }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
bcPagination = calcPagination(total, page, perPage)
|
bcPagination = calcPagination(total, page, perPage);
|
||||||
|
|
||||||
const pageIds = pages.map((p) => p.id)
|
const pageIds = pages.map((p) => p.id);
|
||||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
|
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
|
||||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
||||||
pageIds.length > 0
|
pageIds.length > 0
|
||||||
? prisma.catalogPagesBc.groupBy({
|
? prisma.catalogPagesBc.groupBy({
|
||||||
by: ['parentId'],
|
by: ["parentId"],
|
||||||
where: { parentId: { in: pageIds } },
|
where: { parentId: { in: pageIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
pageIds.length > 0
|
pageIds.length > 0
|
||||||
? prisma.catalogItemsBc.groupBy({
|
? prisma.catalogItemsBc.groupBy({
|
||||||
by: ['pageId'],
|
by: ["pageId"],
|
||||||
where: { pageId: { in: pageIds } },
|
where: { pageId: { in: pageIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
@@ -168,17 +168,16 @@ export default async function CatalogPagesPage({
|
|||||||
select: { id: true, caption: true },
|
select: { id: true, caption: true },
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
])
|
]);
|
||||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
|
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
|
||||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
|
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
|
||||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
|
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
|
||||||
|
|
||||||
bcRows = pages.map((p) => ({
|
bcRows = pages.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
caption: p.caption,
|
caption: p.caption,
|
||||||
parentId: p.parentId,
|
parentId: p.parentId,
|
||||||
parentCaption:
|
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||||
p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
|
||||||
pageLayout: String(p.pageLayout),
|
pageLayout: String(p.pageLayout),
|
||||||
enabled: String(p.enabled),
|
enabled: String(p.enabled),
|
||||||
visible: String(p.visible),
|
visible: String(p.visible),
|
||||||
@@ -186,7 +185,7 @@ export default async function CatalogPagesPage({
|
|||||||
orderNum: p.orderNum,
|
orderNum: p.orderNum,
|
||||||
itemCount: itemCountMap[p.id] ?? 0,
|
itemCount: itemCountMap[p.id] ?? 0,
|
||||||
childCount: childCountMap[p.id] ?? 0,
|
childCount: childCountMap[p.id] ?? 0,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,17 +202,13 @@ export default async function CatalogPagesPage({
|
|||||||
{canEdit && <CatalogActions catalogType="bc" />}
|
{canEdit && <CatalogActions catalogType="bc" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{viewMode === 'tree' ? (
|
{viewMode === "tree" ? (
|
||||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
||||||
) : (
|
) : (
|
||||||
<CatalogTable
|
<CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
|
||||||
data={{ rows: bcRows, ...bcPagination }}
|
|
||||||
canEdit={canEdit}
|
|
||||||
catalogType="bc"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Normal catalog mode ────────────────────────────────────────
|
// ── Normal catalog mode ────────────────────────────────────────
|
||||||
@@ -222,35 +217,35 @@ export default async function CatalogPagesPage({
|
|||||||
const [totalPages, totalItems, enabledPages] = await Promise.all([
|
const [totalPages, totalItems, enabledPages] = await Promise.all([
|
||||||
prisma.catalogPages.count(),
|
prisma.catalogPages.count(),
|
||||||
prisma.catalogItems.count(),
|
prisma.catalogItems.count(),
|
||||||
prisma.catalogPages.count({ where: { enabled: '1' } }),
|
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
// ── Tree view data ──────────────────────────────────────────────
|
// ── Tree view data ──────────────────────────────────────────────
|
||||||
let rootPages: TreeNodeShape[] = []
|
let rootPages: TreeNodeShape[] = [];
|
||||||
if (viewMode === 'tree') {
|
if (viewMode === "tree") {
|
||||||
const rootPagesRaw = await prisma.catalogPages.findMany({
|
const rootPagesRaw = await prisma.catalogPages.findMany({
|
||||||
where: { parentId: -1 },
|
where: { parentId: -1 },
|
||||||
orderBy: { orderNum: 'asc' },
|
orderBy: { orderNum: "asc" },
|
||||||
})
|
});
|
||||||
const rootIds = rootPagesRaw.map((p) => p.id)
|
const rootIds = rootPagesRaw.map((p) => p.id);
|
||||||
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
||||||
rootIds.length > 0
|
rootIds.length > 0
|
||||||
? prisma.catalogPages.groupBy({
|
? prisma.catalogPages.groupBy({
|
||||||
by: ['parentId'],
|
by: ["parentId"],
|
||||||
where: { parentId: { in: rootIds } },
|
where: { parentId: { in: rootIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
rootIds.length > 0
|
rootIds.length > 0
|
||||||
? prisma.catalogItems.groupBy({
|
? prisma.catalogItems.groupBy({
|
||||||
by: ['pageId'],
|
by: ["pageId"],
|
||||||
where: { pageId: { in: rootIds } },
|
where: { pageId: { in: rootIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
])
|
]);
|
||||||
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]))
|
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
|
||||||
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]))
|
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
|
||||||
rootPages = rootPagesRaw.map((p) => ({
|
rootPages = rootPagesRaw.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
caption: p.caption,
|
caption: p.caption,
|
||||||
@@ -262,34 +257,34 @@ export default async function CatalogPagesPage({
|
|||||||
pageLayout: p.pageLayout,
|
pageLayout: p.pageLayout,
|
||||||
childCount: rootChildMap[p.id] ?? 0,
|
childCount: rootChildMap[p.id] ?? 0,
|
||||||
itemCount: rootItemMap[p.id] ?? 0,
|
itemCount: rootItemMap[p.id] ?? 0,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Table view data (skip if tree) ────────────────────────────
|
// ── Table view data (skip if tree) ────────────────────────────
|
||||||
let rows: {
|
let rows: {
|
||||||
id: number
|
id: number;
|
||||||
caption: string
|
caption: string;
|
||||||
parentId: number
|
parentId: number;
|
||||||
parentCaption: string
|
parentCaption: string;
|
||||||
pageLayout: string
|
pageLayout: string;
|
||||||
enabled: string
|
enabled: string;
|
||||||
visible: string
|
visible: string;
|
||||||
minRank: number
|
minRank: number;
|
||||||
orderNum: number
|
orderNum: number;
|
||||||
itemCount: number
|
itemCount: number;
|
||||||
childCount: number
|
childCount: number;
|
||||||
}[] = []
|
}[] = [];
|
||||||
let pagination = { total: 0, page: 1, perPage, lastPage: 0 }
|
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
||||||
|
|
||||||
if (viewMode === 'table') {
|
if (viewMode === "table") {
|
||||||
const parentFilter = sp.get('parent')
|
const parentFilter = sp.get("parent");
|
||||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
|
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {};
|
||||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
|
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
||||||
if (search) {
|
if (search) {
|
||||||
const searchPattern = `%${search}%`
|
const searchPattern = `%${search}%`;
|
||||||
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty
|
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
|
||||||
|
|
||||||
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
|
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
|
||||||
SELECT DISTINCT cp.id FROM catalog_pages cp
|
SELECT DISTINCT cp.id FROM catalog_pages cp
|
||||||
@@ -301,13 +296,13 @@ export default async function CatalogPagesPage({
|
|||||||
OR ib.public_name LIKE ${searchPattern}
|
OR ib.public_name LIKE ${searchPattern}
|
||||||
OR ib.item_name LIKE ${searchPattern})
|
OR ib.item_name LIKE ${searchPattern})
|
||||||
${parentClause}
|
${parentClause}
|
||||||
`)
|
`);
|
||||||
where.id = { in: matchedIds.map((r) => r.id) }
|
where.id = { in: matchedIds.map((r) => r.id) };
|
||||||
} else if (hasParent) {
|
} else if (hasParent) {
|
||||||
where.parentId = parentId
|
where.parentId = parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy = sort ? { [sort]: order } : { orderNum: 'asc' as const }
|
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
|
||||||
|
|
||||||
const [pages, total] = await Promise.all([
|
const [pages, total] = await Promise.all([
|
||||||
prisma.catalogPages.findMany({
|
prisma.catalogPages.findMany({
|
||||||
@@ -317,24 +312,24 @@ export default async function CatalogPagesPage({
|
|||||||
take: perPage,
|
take: perPage,
|
||||||
}),
|
}),
|
||||||
prisma.catalogPages.count({ where }),
|
prisma.catalogPages.count({ where }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
pagination = calcPagination(total, page, perPage)
|
pagination = calcPagination(total, page, perPage);
|
||||||
|
|
||||||
// Count children and items per page, and resolve parent names in parallel
|
// Count children and items per page, and resolve parent names in parallel
|
||||||
const pageIds = pages.map((p) => p.id)
|
const pageIds = pages.map((p) => p.id);
|
||||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
|
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
|
||||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
||||||
pageIds.length > 0
|
pageIds.length > 0
|
||||||
? prisma.catalogPages.groupBy({
|
? prisma.catalogPages.groupBy({
|
||||||
by: ['parentId'],
|
by: ["parentId"],
|
||||||
where: { parentId: { in: pageIds } },
|
where: { parentId: { in: pageIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
pageIds.length > 0
|
pageIds.length > 0
|
||||||
? prisma.catalogItems.groupBy({
|
? prisma.catalogItems.groupBy({
|
||||||
by: ['pageId'],
|
by: ["pageId"],
|
||||||
where: { pageId: { in: pageIds } },
|
where: { pageId: { in: pageIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
@@ -345,16 +340,16 @@ export default async function CatalogPagesPage({
|
|||||||
select: { id: true, caption: true },
|
select: { id: true, caption: true },
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
])
|
]);
|
||||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
|
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
|
||||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
|
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
|
||||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
|
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
|
||||||
|
|
||||||
rows = pages.map((p) => ({
|
rows = pages.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
caption: p.caption,
|
caption: p.caption,
|
||||||
parentId: p.parentId,
|
parentId: p.parentId,
|
||||||
parentCaption: p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||||
pageLayout: p.pageLayout,
|
pageLayout: p.pageLayout,
|
||||||
enabled: p.enabled,
|
enabled: p.enabled,
|
||||||
visible: p.visible,
|
visible: p.visible,
|
||||||
@@ -362,7 +357,7 @@ export default async function CatalogPagesPage({
|
|||||||
orderNum: p.orderNum,
|
orderNum: p.orderNum,
|
||||||
itemCount: itemCountMap[p.id] ?? 0,
|
itemCount: itemCountMap[p.id] ?? 0,
|
||||||
childCount: childCountMap[p.id] ?? 0,
|
childCount: childCountMap[p.id] ?? 0,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -381,11 +376,11 @@ export default async function CatalogPagesPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'tree' ? (
|
{viewMode === "tree" ? (
|
||||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
||||||
) : (
|
) : (
|
||||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -616,7 +616,7 @@ export default async function CommandoCentrum() {
|
|||||||
{e.version}
|
{e.version}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||||
{snippet}
|
{snippet}
|
||||||
</pre>
|
</pre>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export default async function EmulatorErrorsPage({
|
export default async function EmulatorErrorsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string>>
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = await searchParams
|
const params = await searchParams;
|
||||||
const page = Math.max(1, Number(params.page) || 1)
|
const page = Math.max(1, Number(params.page) || 1);
|
||||||
const perPage = 50
|
const perPage = 50;
|
||||||
|
|
||||||
const [errors, total] = await Promise.all([
|
const [errors, total] = await Promise.all([
|
||||||
prisma.emulatorErrors.findMany({
|
prisma.emulatorErrors.findMany({
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: "desc" },
|
||||||
skip: (page - 1) * perPage,
|
skip: (page - 1) * perPage,
|
||||||
take: perPage,
|
take: perPage,
|
||||||
}),
|
}),
|
||||||
prisma.emulatorErrors.count(),
|
prisma.emulatorErrors.count(),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const lastPage = Math.ceil(total / perPage)
|
const lastPage = Math.ceil(total / perPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -52,7 +52,7 @@ export default async function EmulatorErrorsPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
|
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
|
||||||
{Buffer.from(error.stacktrace).toString('utf-8')}
|
{Buffer.from(error.stacktrace).toString("utf-8")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -79,5 +79,5 @@ export default async function EmulatorErrorsPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,55 +1,45 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from "lucide-react";
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function HealthCheckClient() {
|
export function HealthCheckClient() {
|
||||||
const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking')
|
const [status, setStatus] = useState<"checking" | "online" | "offline">("checking");
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
async function checkEmulator() {
|
async function checkEmulator() {
|
||||||
setChecking(true)
|
setChecking(true);
|
||||||
setStatus('checking')
|
setStatus("checking");
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/devops/health')
|
const res = await fetch("/api/admin/devops/health");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setStatus(data.emulator ? 'online' : 'offline')
|
setStatus(data.emulator ? "online" : "offline");
|
||||||
} else {
|
} else {
|
||||||
setStatus('offline')
|
setStatus("offline");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus('offline')
|
setStatus("offline");
|
||||||
} finally {
|
} finally {
|
||||||
setChecking(false)
|
setChecking(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkEmulator()
|
checkEmulator();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge variant={status === "online" ? "default" : status === "offline" ? "destructive" : "secondary"}>
|
||||||
variant={
|
{status === "checking" ? "Checking..." : status === "online" ? "Online" : "Offline"}
|
||||||
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={checkEmulator} disabled={checking}>
|
||||||
variant="ghost"
|
<RefreshCw className={`h-3 w-3 ${checking ? "animate-spin" : ""}`} />
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={checkEmulator}
|
|
||||||
disabled={checking}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,61 @@
|
|||||||
import { AlertTriangle, Database, HeartPulse, Server } from 'lucide-react'
|
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { HealthCheckClient } from './health-check-client'
|
import { HealthCheckClient } from "./health-check-client";
|
||||||
|
|
||||||
async function getDevOpsData() {
|
async function getDevOpsData() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const dayAgo = now - 86400
|
const dayAgo = now - 86400;
|
||||||
|
|
||||||
// DB health — simple count query
|
// DB health — simple count query
|
||||||
let dbStatus: 'healthy' | 'error' = 'healthy'
|
let dbStatus: "healthy" | "error" = "healthy";
|
||||||
let dbLatency = 0
|
let dbLatency = 0;
|
||||||
try {
|
try {
|
||||||
const start = Date.now()
|
const start = Date.now();
|
||||||
await prisma.user.count({ take: 1 })
|
await prisma.user.count({ take: 1 });
|
||||||
dbLatency = Date.now() - start
|
dbLatency = Date.now() - start;
|
||||||
} catch {
|
} catch {
|
||||||
dbStatus = 'error'
|
dbStatus = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent emulator errors
|
// Recent emulator errors
|
||||||
const recentErrors = await prisma.emulatorErrors.findMany({
|
const recentErrors = await prisma.emulatorErrors.findMany({
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: "desc" },
|
||||||
take: 20,
|
take: 20,
|
||||||
})
|
});
|
||||||
|
|
||||||
const errorsToday = await prisma.emulatorErrors.count({
|
const errorsToday = await prisma.emulatorErrors.count({
|
||||||
where: { timestamp: { gte: dayAgo } },
|
where: { timestamp: { gte: dayAgo } },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Emulator settings (version info)
|
// Emulator settings (version info)
|
||||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
const emuVersion = await prisma.emulatorSettings.findFirst({
|
||||||
where: { key: 'version' },
|
where: { key: "version" },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Online user count as proxy for emulator health
|
// Online user count as proxy for emulator health
|
||||||
const onlineUsers = await prisma.user.count({ where: { online: '1' } })
|
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dbStatus,
|
dbStatus,
|
||||||
dbLatency,
|
dbLatency,
|
||||||
recentErrors,
|
recentErrors,
|
||||||
errorsToday,
|
errorsToday,
|
||||||
emuVersion: emuVersion?.value ?? 'Unknown',
|
emuVersion: emuVersion?.value ?? "Unknown",
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DevOpsPage() {
|
export default async function DevOpsPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getDevOpsData()
|
const data = await getDevOpsData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -72,8 +72,8 @@ export default async function DevOpsPage() {
|
|||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Badge variant={data.dbStatus === 'healthy' ? 'default' : 'destructive'}>
|
<Badge variant={data.dbStatus === "healthy" ? "default" : "destructive"}>
|
||||||
{data.dbStatus === 'healthy' ? 'Healthy' : 'Error'}
|
{data.dbStatus === "healthy" ? "Healthy" : "Error"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p>
|
<p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -134,7 +134,7 @@ export default async function DevOpsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
|
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
|
||||||
{Buffer.from(error.stacktrace).toString('utf-8')}
|
{Buffer.from(error.stacktrace).toString("utf-8")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -143,5 +143,5 @@ export default async function DevOpsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,62 +1,56 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { addEventPrize, deleteEventPrize } from '@/actions/events'
|
import { addEventPrize, deleteEventPrize } from "@/actions/events";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
|
|
||||||
interface Prize {
|
interface Prize {
|
||||||
id: number
|
id: number;
|
||||||
position: number
|
position: number;
|
||||||
prizeType: string
|
prizeType: string;
|
||||||
badgeCode: string | null
|
badgeCode: string | null;
|
||||||
credits: number
|
credits: number;
|
||||||
pixels: number
|
pixels: number;
|
||||||
points: number
|
points: number;
|
||||||
itemId: number | null
|
itemId: number | null;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventId: number
|
eventId: number;
|
||||||
prizes: Prize[]
|
prizes: Prize[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventPrizesManager({ eventId, prizes }: Props) {
|
export function EventPrizesManager({ eventId, prizes }: Props) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
position: prizes.length + 1,
|
position: prizes.length + 1,
|
||||||
prizeType: 'badge' as string,
|
prizeType: "badge" as string,
|
||||||
badgeCode: '',
|
badgeCode: "",
|
||||||
credits: 0,
|
credits: 0,
|
||||||
pixels: 0,
|
pixels: 0,
|
||||||
points: 0,
|
points: 0,
|
||||||
description: '',
|
description: "",
|
||||||
})
|
});
|
||||||
|
|
||||||
function handleAdd(e: React.FormEvent) {
|
function handleAdd(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||||
run(() => addEventPrize({ eventId, ...form } as any), {
|
run(() => addEventPrize({ eventId, ...form } as any), {
|
||||||
successMessage: 'Prize added!',
|
successMessage: "Prize added!",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowForm(false)
|
setShowForm(false);
|
||||||
setForm({ ...form, position: form.position + 1, badgeCode: '', description: '' })
|
setForm({ ...form, position: form.position + 1, badgeCode: "", description: "" });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,10 +75,7 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<Select
|
<Select value={form.prizeType} onValueChange={(v) => setForm({ ...form, prizeType: v })}>
|
||||||
value={form.prizeType}
|
|
||||||
onValueChange={(v) => setForm({ ...form, prizeType: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -133,16 +124,11 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{prizes.length === 0 && !showForm && (
|
{prizes.length === 0 && !showForm && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">No prizes configured yet.</p>
|
||||||
No prizes configured yet.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{prizes.map((prize) => (
|
{prizes.map((prize) => (
|
||||||
<div
|
<div key={prize.id} className="flex items-center justify-between border rounded-lg px-4 py-3">
|
||||||
key={prize.id}
|
|
||||||
className="flex items-center justify-between border rounded-lg px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-accent font-bold text-sm">
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-accent font-bold text-sm">
|
||||||
#{prize.position}
|
#{prize.position}
|
||||||
@@ -155,16 +141,14 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
|||||||
{prize.pixels > 0 && <span className="text-sm">{prize.pixels} pixels</span>}
|
{prize.pixels > 0 && <span className="text-sm">{prize.pixels} pixels</span>}
|
||||||
{prize.points > 0 && <span className="text-sm">{prize.points} points</span>}
|
{prize.points > 0 && <span className="text-sm">{prize.points} points</span>}
|
||||||
</div>
|
</div>
|
||||||
{prize.description && (
|
{prize.description && <p className="text-xs text-muted-foreground">{prize.description}</p>}
|
||||||
<p className="text-xs text-muted-foreground">{prize.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
run(() => deleteEventPrize({ id: prize.id }), { successMessage: 'Prize removed' })
|
run(() => deleteEventPrize({ id: prize.id }), { successMessage: "Prize removed" })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
@@ -173,5 +157,5 @@ export function EventPrizesManager({ eventId, prizes }: Props) {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
interface Registration {
|
interface Registration {
|
||||||
id: number
|
id: number;
|
||||||
userId: number
|
userId: number;
|
||||||
username: string
|
username: string;
|
||||||
createdAt: Date
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
registrations: Registration[]
|
registrations: Registration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventRegistrations({ registrations }: Props) {
|
export function EventRegistrations({ registrations }: Props) {
|
||||||
@@ -36,5 +36,5 @@ export function EventRegistrations({ registrations }: Props) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,43 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from "lucide-react";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { addEventWinner } from '@/actions/events'
|
import { addEventWinner } from "@/actions/events";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
interface Winner {
|
interface Winner {
|
||||||
id: number
|
id: number;
|
||||||
userId: number
|
userId: number;
|
||||||
position: number
|
position: number;
|
||||||
username: string
|
username: string;
|
||||||
createdAt: Date
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventId: number
|
eventId: number;
|
||||||
winners: Winner[]
|
winners: Winner[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventWinnersManager({ eventId, winners }: Props) {
|
export function EventWinnersManager({ eventId, winners }: Props) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [userId, setUserId] = useState('')
|
const [userId, setUserId] = useState("");
|
||||||
const [position, setPosition] = useState(winners.length + 1)
|
const [position, setPosition] = useState(winners.length + 1);
|
||||||
|
|
||||||
function handleAdd(e: React.FormEvent) {
|
function handleAdd(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
run(() => addEventWinner({ eventId, userId: Number(userId), position }), {
|
run(() => addEventWinner({ eventId, userId: Number(userId), position }), {
|
||||||
successMessage: 'Winner added!',
|
successMessage: "Winner added!",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowForm(false)
|
setShowForm(false);
|
||||||
setUserId('')
|
setUserId("");
|
||||||
setPosition(position + 1)
|
setPosition(position + 1);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,5 +95,5 @@ export function EventWinnersManager({ eventId, winners }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,75 +1,70 @@
|
|||||||
import { CalendarDays, Gift, Trophy, Users } from 'lucide-react'
|
import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
|
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { EventForm } from '../event-form'
|
import { EventForm } from "../event-form";
|
||||||
import { EventPrizesManager } from './event-prizes'
|
import { EventPrizesManager } from "./event-prizes";
|
||||||
import { EventRegistrations } from './event-registrations'
|
import { EventRegistrations } from "./event-registrations";
|
||||||
import { EventWinnersManager } from './event-winners'
|
import { EventWinnersManager } from "./event-winners";
|
||||||
|
|
||||||
export default async function EventDetailPage({
|
export default async function EventDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string; id: string }>
|
params: Promise<{ locale: string; id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params;
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
||||||
redirect('/admin/events')
|
redirect("/admin/events");
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = parseInt(id, 10)
|
const eventId = parseInt(id, 10);
|
||||||
if (Number.isNaN(eventId)) notFound()
|
if (Number.isNaN(eventId)) notFound();
|
||||||
|
|
||||||
const event = await prisma.websiteEvent.findUnique({
|
const event = await prisma.websiteEvent.findUnique({
|
||||||
where: { id: eventId },
|
where: { id: eventId },
|
||||||
include: {
|
include: {
|
||||||
type: true,
|
type: true,
|
||||||
prizes: { orderBy: { position: 'asc' } },
|
prizes: { orderBy: { position: "asc" } },
|
||||||
winners: { orderBy: { position: 'asc' } },
|
winners: { orderBy: { position: "asc" } },
|
||||||
registrations: { orderBy: { createdAt: 'desc' } },
|
registrations: { orderBy: { createdAt: "desc" } },
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!event) notFound()
|
if (!event) notFound();
|
||||||
|
|
||||||
const eventTypes = await prisma.websiteEventType.findMany({
|
const eventTypes = await prisma.websiteEventType.findMany({
|
||||||
where: { isActive: 1 },
|
where: { isActive: 1 },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch usernames for registrations and winners
|
// Fetch usernames for registrations and winners
|
||||||
const userIds = [
|
const userIds = [...event.registrations.map((r) => r.userId), ...event.winners.map((w) => w.userId)];
|
||||||
...event.registrations.map((r) => r.userId),
|
|
||||||
...event.winners.map((w) => w.userId),
|
|
||||||
]
|
|
||||||
const users =
|
const users =
|
||||||
userIds.length > 0
|
userIds.length > 0
|
||||||
? await prisma.user.findMany({
|
? await prisma.user.findMany({
|
||||||
where: { id: { in: [...new Set(userIds)] } },
|
where: { id: { in: [...new Set(userIds)] } },
|
||||||
select: { id: true, username: true },
|
select: { id: true, username: true },
|
||||||
})
|
})
|
||||||
: []
|
: [];
|
||||||
const userMap = new Map(users.map((u) => [u.id, u.username]))
|
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<AdminBreadcrumb
|
<AdminBreadcrumb items={[{ label: "Events", href: "/admin/events" }, { label: event.title }]} />
|
||||||
items={[{ label: 'Events', href: '/admin/events' }, { label: event.title }]}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
event.status === 'published'
|
event.status === "published"
|
||||||
? 'default'
|
? "default"
|
||||||
: event.status === 'cancelled'
|
: event.status === "cancelled"
|
||||||
? 'destructive'
|
? "destructive"
|
||||||
: 'secondary'
|
: "secondary"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{event.status}
|
{event.status}
|
||||||
@@ -89,9 +84,7 @@ export default async function EventDetailPage({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{event.registrations.length}</div>
|
<div className="text-2xl font-bold">{event.registrations.length}</div>
|
||||||
{event.maxPlayers && (
|
{event.maxPlayers && <p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>}
|
||||||
<p className="text-xs text-muted-foreground">Max: {event.maxPlayers}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -119,12 +112,10 @@ export default async function EventDetailPage({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{new Date(event.startsAt) > new Date() ? 'Upcoming' : 'Past'}
|
{new Date(event.startsAt) > new Date() ? "Upcoming" : "Past"}
|
||||||
</div>
|
</div>
|
||||||
{event.endsAt && (
|
{event.endsAt && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">Ends: {new Date(event.endsAt).toLocaleString()}</p>
|
||||||
Ends: {new Date(event.endsAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -144,7 +135,7 @@ export default async function EventDetailPage({
|
|||||||
maxPlayers: event.maxPlayers,
|
maxPlayers: event.maxPlayers,
|
||||||
isRecurring: event.isRecurring,
|
isRecurring: event.isRecurring,
|
||||||
recurrenceRule: event.recurrenceRule,
|
recurrenceRule: event.recurrenceRule,
|
||||||
status: event.status as 'draft' | 'published' | 'cancelled' | 'completed',
|
status: event.status as "draft" | "published" | "cancelled" | "completed",
|
||||||
image: event.image,
|
image: event.image,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -169,5 +160,5 @@ export default async function EventDetailPage({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { EventForm } from '../event-form'
|
import { EventForm } from "../event-form";
|
||||||
|
|
||||||
export default async function CreateEventPage() {
|
export default async function CreateEventPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
||||||
redirect('/admin/events')
|
redirect("/admin/events");
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTypes = await prisma.websiteEventType.findMany({
|
const eventTypes = await prisma.websiteEventType.findMany({
|
||||||
where: { isActive: 1 },
|
where: { isActive: 1 },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -23,5 +23,5 @@ export default async function CreateEventPage() {
|
|||||||
|
|
||||||
<EventForm eventTypes={eventTypes} />
|
<EventForm eventTypes={eventTypes} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,60 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from "react-hook-form";
|
||||||
import { createEvent, updateEvent } from '@/actions/events'
|
import { createEvent, updateEvent } from "@/actions/events";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { DateTimePicker } from '@/components/ui/date-time-picker'
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
SelectContent,
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
SelectItem,
|
import type { CreateEventInput } from "@/lib/validators/event";
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
import type { CreateEventInput } from '@/lib/validators/event'
|
|
||||||
|
|
||||||
interface EventType {
|
interface EventType {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
slug: string
|
slug: string;
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
eventTypes: EventType[]
|
eventTypes: EventType[];
|
||||||
defaultValues?: Partial<CreateEventInput> & { id?: number }
|
defaultValues?: Partial<CreateEventInput> & { id?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const isEditing = !!defaultValues?.id
|
const isEditing = !!defaultValues?.id;
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: '',
|
title: "",
|
||||||
description: '',
|
description: "",
|
||||||
typeId: eventTypes[0]?.id,
|
typeId: eventTypes[0]?.id,
|
||||||
status: 'draft',
|
status: "draft",
|
||||||
isRecurring: 0,
|
isRecurring: 0,
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
|
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
|
||||||
function onSubmit(data: any) {
|
function onSubmit(data: any) {
|
||||||
if (isEditing && defaultValues?.id) {
|
if (isEditing && defaultValues?.id) {
|
||||||
run(() => updateEvent({ id: defaultValues.id!, ...data }), {
|
run(() => updateEvent({ id: defaultValues.id!, ...data }), {
|
||||||
successMessage: 'Event updated!',
|
successMessage: "Event updated!",
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
run(() => createEvent(data), {
|
run(() => createEvent(data), {
|
||||||
successMessage: 'Event created!',
|
successMessage: "Event created!",
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
const data = result.data as Record<string, unknown> | undefined
|
const data = result.data as Record<string, unknown> | undefined;
|
||||||
if (data?.id) {
|
if (data?.id) {
|
||||||
window.location.href = `/admin/events/${data.id}`
|
window.location.href = `/admin/events/${data.id}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +62,13 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{isEditing ? 'Edit Event' : 'Event Details'}</CardTitle>
|
<CardTitle>{isEditing ? "Edit Event" : "Event Details"}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input id="title" {...form.register('title')} />
|
<Input id="title" {...form.register("title")} />
|
||||||
{form.formState.errors.title && (
|
{form.formState.errors.title && (
|
||||||
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p>
|
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -83,8 +77,8 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="typeId">Event Type</Label>
|
<Label htmlFor="typeId">Event Type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(form.watch('typeId'))}
|
value={String(form.watch("typeId"))}
|
||||||
onValueChange={(v) => form.setValue('typeId', Number(v))}
|
onValueChange={(v) => form.setValue("typeId", Number(v))}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select type" />
|
<SelectValue placeholder="Select type" />
|
||||||
@@ -102,54 +96,44 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea id="description" rows={4} {...form.register('description')} />
|
<Textarea id="description" rows={4} {...form.register("description")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Starts At</Label>
|
<Label>Starts At</Label>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={form.watch('startsAt')}
|
value={form.watch("startsAt")}
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
|
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
|
||||||
onChange={(d) => form.setValue('startsAt', d as any, { shouldDirty: true })}
|
onChange={(d) => form.setValue("startsAt", d as any, { shouldDirty: true })}
|
||||||
placeholder="Select start date"
|
placeholder="Select start date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Ends At</Label>
|
<Label>Ends At</Label>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={form.watch('endsAt')}
|
value={form.watch("endsAt")}
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
|
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
|
||||||
onChange={(d) => form.setValue('endsAt', d as any, { shouldDirty: true })}
|
onChange={(d) => form.setValue("endsAt", d as any, { shouldDirty: true })}
|
||||||
placeholder="Select end date"
|
placeholder="Select end date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxPlayers">Max Players</Label>
|
<Label htmlFor="maxPlayers">Max Players</Label>
|
||||||
<Input
|
<Input id="maxPlayers" type="number" {...form.register("maxPlayers")} placeholder="Unlimited" />
|
||||||
id="maxPlayers"
|
|
||||||
type="number"
|
|
||||||
{...form.register('maxPlayers')}
|
|
||||||
placeholder="Unlimited"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="roomId">Room ID</Label>
|
<Label htmlFor="roomId">Room ID</Label>
|
||||||
<Input
|
<Input id="roomId" type="number" {...form.register("roomId")} placeholder="Optional" />
|
||||||
id="roomId"
|
|
||||||
type="number"
|
|
||||||
{...form.register('roomId')}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="status">Status</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.watch('status')}
|
value={form.watch("status")}
|
||||||
onValueChange={(v) => form.setValue('status', v as CreateEventInput['status'])}
|
onValueChange={(v) => form.setValue("status", v as CreateEventInput["status"])}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -166,16 +150,16 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="image">Image URL</Label>
|
<Label htmlFor="image">Image URL</Label>
|
||||||
<Input id="image" {...form.register('image')} placeholder="https://..." />
|
<Input id="image" {...form.register("image")} placeholder="https://..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? 'Saving...' : isEditing ? 'Update Event' : 'Create Event'}
|
{isPending ? "Saving..." : isEditing ? "Update Event" : "Create Event"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Pencil } from 'lucide-react'
|
import { Pencil } from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { DataTable } from '@/components/admin/data-table'
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
typeName: string
|
typeName: string;
|
||||||
typeColor: string
|
typeColor: string;
|
||||||
startsAt: Date
|
startsAt: Date;
|
||||||
endsAt: Date | null
|
endsAt: Date | null;
|
||||||
status: string
|
status: string;
|
||||||
hostUserId: number
|
hostUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusVariants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
const statusVariants: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||||
published: 'default',
|
published: "default",
|
||||||
draft: 'secondary',
|
draft: "secondary",
|
||||||
cancelled: 'destructive',
|
cancelled: "destructive",
|
||||||
completed: 'outline',
|
completed: "outline",
|
||||||
}
|
};
|
||||||
|
|
||||||
function getColumns(): DataTableColumn<EventRow>[] {
|
function getColumns(): DataTableColumn<EventRow>[] {
|
||||||
return [
|
return [
|
||||||
{ key: 'id', label: 'ID', sortable: true },
|
{ key: "id", label: "ID", sortable: true },
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: "title",
|
||||||
label: 'Title',
|
label: "Title",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => <span className="font-medium">{String(value)}</span>,
|
render: (value) => <span className="font-medium">{String(value)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'typeName',
|
key: "typeName",
|
||||||
label: 'Type',
|
label: "Type",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Badge style={{ backgroundColor: row.typeColor, color: '#fff' }}>{row.typeName}</Badge>
|
<Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>{row.typeName}</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'startsAt',
|
key: "startsAt",
|
||||||
label: 'Starts',
|
label: "Starts",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => (
|
render: (value) => (
|
||||||
<span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span>
|
<span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: "status",
|
||||||
label: 'Status',
|
label: "Status",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => (
|
render: (value) => (
|
||||||
<Badge variant={statusVariants[String(value)] ?? 'secondary'}>{String(value)}</Badge>
|
<Badge variant={statusVariants[String(value)] ?? "secondary"}>{String(value)}</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: "actions",
|
||||||
label: 'Actions',
|
label: "Actions",
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" asChild>
|
||||||
<Link href={`/admin/events/${row.id}`}>
|
<Link href={`/admin/events/${row.id}`}>
|
||||||
@@ -69,13 +69,13 @@ function getColumns(): DataTableColumn<EventRow>[] {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventsTableProps {
|
interface EventsTableProps {
|
||||||
data: PaginatedResult<EventRow>
|
data: PaginatedResult<EventRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventsTable({ data }: EventsTableProps) {
|
export function EventsTable({ data }: EventsTableProps) {
|
||||||
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />
|
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />;
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { fetchAdminList } from '@/lib/admin-list'
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { EventsTable } from './events-table'
|
import { EventsTable } from "./events-table";
|
||||||
|
|
||||||
export default async function EventsPage({
|
export default async function EventsPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
searchParams: Promise<Record<string, string>>
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.EVENTS_VIEW,
|
permission: PERMS.EVENTS_VIEW,
|
||||||
model: 'websiteEvent',
|
model: "websiteEvent",
|
||||||
searchFields: ['title'],
|
searchFields: ["title"],
|
||||||
defaultSort: { startsAt: 'desc' },
|
defaultSort: { startsAt: "desc" },
|
||||||
include: { type: { select: { name: true, color: true } } },
|
include: { type: { select: { name: true, color: true } } },
|
||||||
mapRow: (e: {
|
mapRow: (e: {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
type: { name: string; color: string }
|
type: { name: string; color: string };
|
||||||
startsAt: Date
|
startsAt: Date;
|
||||||
endsAt: Date | null
|
endsAt: Date | null;
|
||||||
status: string
|
status: string;
|
||||||
hostUserId: number
|
hostUserId: number;
|
||||||
_count?: { registrations: number }
|
_count?: { registrations: number };
|
||||||
}) => ({
|
}) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
title: e.title,
|
title: e.title,
|
||||||
@@ -40,7 +40,7 @@ export default async function EventsPage({
|
|||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -64,5 +64,5 @@ export default async function EventsPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { createEventType, deleteEventType, updateEventType } from '@/actions/events'
|
import { createEventType, deleteEventType, updateEventType } from "@/actions/events";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
interface EventType {
|
interface EventType {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
slug: string
|
slug: string;
|
||||||
description: string
|
description: string;
|
||||||
color: string
|
color: string;
|
||||||
icon: string
|
icon: string;
|
||||||
isActive: number
|
isActive: number;
|
||||||
minRank: number
|
minRank: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventTypes: EventType[]
|
eventTypes: EventType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventTypesManager({ eventTypes }: Props) {
|
export function EventTypesManager({ eventTypes }: Props) {
|
||||||
const { run, isPending } = useServerAction()
|
const { run, isPending } = useServerAction();
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: "",
|
||||||
slug: '',
|
slug: "",
|
||||||
description: '',
|
description: "",
|
||||||
color: '#3b82f6',
|
color: "#3b82f6",
|
||||||
icon: 'calendar',
|
icon: "calendar",
|
||||||
minRank: 0,
|
minRank: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setForm({ name: '', slug: '', description: '', color: '#3b82f6', icon: 'calendar', minRank: 0 })
|
setForm({ name: "", slug: "", description: "", color: "#3b82f6", icon: "calendar", minRank: 0 });
|
||||||
setEditingId(null)
|
setEditingId(null);
|
||||||
setShowForm(false)
|
setShowForm(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(et: EventType) {
|
function handleEdit(et: EventType) {
|
||||||
@@ -52,30 +52,30 @@ export function EventTypesManager({ eventTypes }: Props) {
|
|||||||
color: et.color,
|
color: et.color,
|
||||||
icon: et.icon,
|
icon: et.icon,
|
||||||
minRank: et.minRank,
|
minRank: et.minRank,
|
||||||
})
|
});
|
||||||
setEditingId(et.id)
|
setEditingId(et.id);
|
||||||
setShowForm(true)
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
run(() => updateEventType({ id: editingId, ...form }), {
|
run(() => updateEventType({ id: editingId, ...form }), {
|
||||||
successMessage: 'Event type updated!',
|
successMessage: "Event type updated!",
|
||||||
onSuccess: resetForm,
|
onSuccess: resetForm,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
run(() => createEventType(form), {
|
run(() => createEventType(form), {
|
||||||
successMessage: 'Event type created!',
|
successMessage: "Event type created!",
|
||||||
onSuccess: resetForm,
|
onSuccess: resetForm,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(id: number) {
|
function handleDelete(id: number) {
|
||||||
run(() => deleteEventType({ id }), {
|
run(() => deleteEventType({ id }), {
|
||||||
successMessage: 'Event type deleted!',
|
successMessage: "Event type deleted!",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +90,7 @@ export function EventTypesManager({ eventTypes }: Props) {
|
|||||||
{showForm && (
|
{showForm && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{editingId ? 'Edit Event Type' : 'New Event Type'}</CardTitle>
|
<CardTitle>{editingId ? "Edit Event Type" : "New Event Type"}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -136,13 +136,9 @@ export function EventTypesManager({ eventTypes }: Props) {
|
|||||||
min={0}
|
min={0}
|
||||||
max={7}
|
max={7}
|
||||||
value={form.minRank}
|
value={form.minRank}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })}
|
||||||
setForm({ ...form, minRank: parseInt(e.target.value, 10) || 0 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+</p>
|
||||||
0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
@@ -150,7 +146,7 @@ export function EventTypesManager({ eventTypes }: Props) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
{isPending ? "Saving..." : editingId ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -174,8 +170,8 @@ export function EventTypesManager({ eventTypes }: Props) {
|
|||||||
<span className="font-medium">{et.name}</span>
|
<span className="font-medium">{et.name}</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">{et.slug}</span>
|
<span className="ml-2 text-xs text-muted-foreground">{et.slug}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={et.isActive ? 'default' : 'outline'}>
|
<Badge variant={et.isActive ? "default" : "outline"}>
|
||||||
{et.isActive ? 'Active' : 'Inactive'}
|
{et.isActive ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{et.minRank > 0 && (
|
{et.minRank > 0 && (
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
@@ -197,5 +193,5 @@ export function EventTypesManager({ eventTypes }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { EventTypesManager } from './event-types-manager'
|
import { EventTypesManager } from "./event-types-manager";
|
||||||
|
|
||||||
export default async function EventTypesPage() {
|
export default async function EventTypesPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
|
||||||
redirect('/admin/events')
|
redirect("/admin/events");
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTypes = await prisma.websiteEventType.findMany({
|
const eventTypes = await prisma.websiteEventType.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -22,5 +22,5 @@ export default async function EventTypesPage() {
|
|||||||
|
|
||||||
<EventTypesManager eventTypes={eventTypes} />
|
<EventTypesManager eventTypes={eventTypes} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,9 @@ export default async function AdminHelpQuestions() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={String(e.id)}>
|
<tr key={String(e.id)}>
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">{e.position}</td>
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
|
{e.position}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
|
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,62 +1,56 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { getBadgeData, updateBadge } from '@/actions/badges'
|
import { getBadgeData, updateBadge } from "@/actions/badges";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
import { Input } from "@/components/ui/input";
|
||||||
DialogContent,
|
import { Label } from "@/components/ui/label";
|
||||||
DialogFooter,
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { useServerAction } from '@/hooks/use-server-action'
|
|
||||||
|
|
||||||
interface BadgeEditorDialogProps {
|
interface BadgeEditorDialogProps {
|
||||||
code: string
|
code: string;
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Called after a successful save so the parent can refresh state */
|
/** Called after a successful save so the parent can refresh state */
|
||||||
onSaved?: () => void
|
onSaved?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) {
|
export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState("");
|
||||||
const [desc, setDesc] = useState('')
|
const [desc, setDesc] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const { run, isPending: saving } = useServerAction()
|
const { run, isPending: saving } = useServerAction();
|
||||||
|
|
||||||
// Fetch current values when dialog opens
|
// Fetch current values when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !code) return
|
if (!open || !code) return;
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
getBadgeData({ code })
|
getBadgeData({ code })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const data = result.data as { name: string; desc: string }
|
const data = result.data as { name: string; desc: string };
|
||||||
setName(data.name ?? '')
|
setName(data.name ?? "");
|
||||||
setDesc(data.desc ?? '')
|
setDesc(data.desc ?? "");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error('Failed to load badge data')
|
toast.error("Failed to load badge data");
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false));
|
||||||
}, [open, code])
|
}, [open, code]);
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
run(() => updateBadge({ code, name, desc }), {
|
run(() => updateBadge({ code, name, desc }), {
|
||||||
successMessage: `Badge ${code} updated`,
|
successMessage: `Badge ${code} updated`,
|
||||||
errorMessage: 'Save failed',
|
errorMessage: "Save failed",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onSaved?.()
|
onSaved?.();
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,5 +98,5 @@ export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEd
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -12,183 +12,179 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { importBadgeFromRemote } from '@/actions/import-badges'
|
import { importBadgeFromRemote } from "@/actions/import-badges";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
import { BadgeEditorDialog } from './badge-editor-dialog'
|
import { BadgeEditorDialog } from "./badge-editor-dialog";
|
||||||
|
|
||||||
interface BadgeItem {
|
interface BadgeItem {
|
||||||
id: number
|
id: number;
|
||||||
code: string
|
code: string;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
hotel: string
|
hotel: string;
|
||||||
url_habbo: string
|
url_habbo: string;
|
||||||
url_habboassets: string
|
url_habboassets: string;
|
||||||
alreadyImported: boolean
|
alreadyImported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export function ImportBadgesClient() {
|
export function ImportBadgesClient() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = useState('')
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
const [badges, setBadges] = useState<BadgeItem[]>([])
|
const [badges, setBadges] = useState<BadgeItem[]>([]);
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [importingCode, setImportingCode] = useState<string | null>(null)
|
const [importingCode, setImportingCode] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [allHotels, setAllHotels] = useState(true)
|
const [allHotels, setAllHotels] = useState(true);
|
||||||
const [editingCode, setEditingCode] = useState<string | null>(null)
|
const [editingCode, setEditingCode] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [batchImporting, setBatchImporting] = useState(false)
|
const [batchImporting, setBatchImporting] = useState(false);
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const fetchBadges = useCallback(async (search: string, off: number, all: boolean) => {
|
const fetchBadges = useCallback(async (search: string, off: number, all: boolean) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError('')
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ offset: String(off) })
|
const params = new URLSearchParams({ offset: String(off) });
|
||||||
if (search) params.set('search', search)
|
if (search) params.set("search", search);
|
||||||
if (all) params.set('allHotels', '1')
|
if (all) params.set("allHotels", "1");
|
||||||
const res = await fetch(`/api/admin/import/badges?${params}`)
|
const res = await fetch(`/api/admin/import/badges?${params}`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Failed to load')
|
setError(data.error || "Failed to load");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const list = data.badges || []
|
const list = data.badges || [];
|
||||||
setBadges(list)
|
setBadges(list);
|
||||||
setHasMore(list.length >= PAGE_SIZE)
|
setHasMore(list.length >= PAGE_SIZE);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Auto-load on mount and when allHotels changes
|
// Auto-load on mount and when allHotels changes
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOffset(0)
|
setOffset(0);
|
||||||
fetchBadges(activeSearch, 0, allHotels)
|
fetchBadges(activeSearch, 0, allHotels);
|
||||||
}, [fetchBadges, allHotels])
|
}, [fetchBadges, allHotels]);
|
||||||
|
|
||||||
// "/" keyboard shortcut to focus search
|
// "/" keyboard shortcut to focus search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
const tag = (e.target as HTMLElement)?.tagName
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
searchRef.current?.focus()
|
searchRef.current?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
function doSearch() {
|
function doSearch() {
|
||||||
setActiveSearch(searchTerm)
|
setActiveSearch(searchTerm);
|
||||||
setOffset(0)
|
setOffset(0);
|
||||||
fetchBadges(searchTerm, 0, allHotels)
|
fetchBadges(searchTerm, 0, allHotels);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(direction: 'prev' | 'next') {
|
function goPage(direction: "prev" | "next") {
|
||||||
const newOffset = direction === 'next' ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE)
|
const newOffset = direction === "next" ? offset + PAGE_SIZE : Math.max(0, offset - PAGE_SIZE);
|
||||||
setOffset(newOffset)
|
setOffset(newOffset);
|
||||||
fetchBadges(activeSearch, newOffset, allHotels)
|
fetchBadges(activeSearch, newOffset, allHotels);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport(badge: BadgeItem) {
|
async function doImport(badge: BadgeItem) {
|
||||||
setImportingCode(badge.code)
|
setImportingCode(badge.code);
|
||||||
try {
|
try {
|
||||||
const result = await importBadgeFromRemote({
|
const result = await importBadgeFromRemote({
|
||||||
code: badge.code,
|
code: badge.code,
|
||||||
name: badge.name,
|
name: badge.name,
|
||||||
description: badge.description,
|
description: badge.description,
|
||||||
})
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
toast.success(`${badge.code} imported`)
|
toast.success(`${badge.code} imported`);
|
||||||
setBadges((prev) =>
|
setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
|
||||||
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
|
|
||||||
)
|
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev);
|
||||||
n.delete(badge.code)
|
n.delete(badge.code);
|
||||||
return n
|
return n;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || `Failed to import ${badge.code}`)
|
toast.error(result.error || `Failed to import ${badge.code}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Network error importing ${badge.code}`)
|
toast.error(`Network error importing ${badge.code}`);
|
||||||
} finally {
|
} finally {
|
||||||
setImportingCode(null)
|
setImportingCode(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doBatchImport() {
|
async function doBatchImport() {
|
||||||
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported)
|
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported);
|
||||||
if (toImport.length === 0) return
|
if (toImport.length === 0) return;
|
||||||
setBatchImporting(true)
|
setBatchImporting(true);
|
||||||
let ok = 0,
|
let ok = 0,
|
||||||
fail = 0
|
fail = 0;
|
||||||
for (const badge of toImport) {
|
for (const badge of toImport) {
|
||||||
try {
|
try {
|
||||||
const result = await importBadgeFromRemote({
|
const result = await importBadgeFromRemote({
|
||||||
code: badge.code,
|
code: badge.code,
|
||||||
name: badge.name,
|
name: badge.name,
|
||||||
description: badge.description,
|
description: badge.description,
|
||||||
})
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
ok++
|
ok++;
|
||||||
setBadges((prev) =>
|
setBadges((prev) => prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)));
|
||||||
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
fail++
|
fail++;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
fail++
|
fail++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBatchImporting(false)
|
setBatchImporting(false);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (ok > 0) toast.success(`${ok} badge(s) imported`)
|
if (ok > 0) toast.success(`${ok} badge(s) imported`);
|
||||||
if (fail > 0) toast.error(`${fail} failed`)
|
if (fail > 0) toast.error(`${fail} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelect(code: string) {
|
function toggleSelect(code: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(code)) next.delete(code)
|
if (next.has(code)) next.delete(code);
|
||||||
else next.add(code)
|
else next.add(code);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges])
|
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges]);
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
if (selected.size === importableBadges.length) {
|
if (selected.size === importableBadges.length) {
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelected(new Set(importableBadges.map((b) => b.code)))
|
setSelected(new Set(importableBadges.map((b) => b.code)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges])
|
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges]);
|
||||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
@@ -226,10 +222,10 @@ export function ImportBadgesClient() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') doSearch()
|
if (e.key === "Enter") doSearch();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
searchRef.current?.blur()
|
searchRef.current?.blur();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8"
|
||||||
@@ -238,9 +234,9 @@ export function ImportBadgesClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
fetchBadges('', 0, allHotels)
|
fetchBadges("", 0, allHotels);
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -249,28 +245,24 @@ export function ImportBadgesClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={doSearch} size="sm" disabled={loading}>
|
<Button onClick={doSearch} size="sm" disabled={loading}>
|
||||||
{loading ? (
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={allHotels ? 'default' : 'outline'}
|
variant={allHotels ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAllHotels((v) => !v)}
|
onClick={() => setAllHotels((v) => !v)}
|
||||||
>
|
>
|
||||||
<Globe className="w-4 h-4 mr-1" />
|
<Globe className="w-4 h-4 mr-1" />
|
||||||
{allHotels ? 'All' : 'IT'}
|
{allHotels ? "All" : "IT"}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{allHotels
|
{allHotels
|
||||||
? 'Showing all hotels — click for Italian only'
|
? "Showing all hotels — click for Italian only"
|
||||||
: 'Showing Italian only — click for all hotels'}
|
: "Showing Italian only — click for all hotels"}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -287,7 +279,7 @@ export function ImportBadgesClient() {
|
|||||||
disabled={batchImporting}
|
disabled={batchImporting}
|
||||||
>
|
>
|
||||||
{selected.size === importableBadges.length
|
{selected.size === importableBadges.length
|
||||||
? 'Deselect All'
|
? "Deselect All"
|
||||||
: `Select All (${importableBadges.length})`}
|
: `Select All (${importableBadges.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
@@ -330,7 +322,7 @@ export function ImportBadgesClient() {
|
|||||||
{!loading && badges.length === 0 && !error && (
|
{!loading && badges.length === 0 && !error && (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p className="text-sm">No badges found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
|
<p className="text-sm">No badges found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||||
{!activeSearch && <p className="text-xs mt-1">Try searching by badge name or code</p>}
|
{!activeSearch && <p className="text-xs mt-1">Try searching by badge name or code</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -338,20 +330,17 @@ export function ImportBadgesClient() {
|
|||||||
{/* Badge grid */}
|
{/* Badge grid */}
|
||||||
{badges.length > 0 && (
|
{badges.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}
|
||||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
|
||||||
loading && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{badges.map((badge) => {
|
{badges.map((badge) => {
|
||||||
const isSelected = selected.has(badge.code)
|
const isSelected = selected.has(badge.code);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={badge.code}
|
key={badge.code}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all',
|
"rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all",
|
||||||
badge.alreadyImported && 'ring-1 ring-[var(--admin-success)]/30',
|
badge.alreadyImported && "ring-1 ring-[var(--admin-success)]/30",
|
||||||
isSelected && 'ring-2 ring-primary border-primary',
|
isSelected && "ring-2 ring-primary border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Checkbox for non-imported */}
|
{/* Checkbox for non-imported */}
|
||||||
@@ -360,10 +349,10 @@ export function ImportBadgesClient() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSelect(badge.code)}
|
onClick={() => toggleSelect(badge.code)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="w-3 h-3" />}
|
{isSelected && <Check className="w-3 h-3" />}
|
||||||
@@ -378,12 +367,12 @@ export function ImportBadgesClient() {
|
|||||||
className="image-pixelated drop-shadow-sm"
|
className="image-pixelated drop-shadow-sm"
|
||||||
style={{ maxHeight: 48, maxWidth: 48 }}
|
style={{ maxHeight: 48, maxWidth: 48 }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const img = e.target as HTMLImageElement
|
const img = e.target as HTMLImageElement;
|
||||||
if (!img.dataset.fallback) {
|
if (!img.dataset.fallback) {
|
||||||
img.dataset.fallback = '1'
|
img.dataset.fallback = "1";
|
||||||
img.src = badge.url_habboassets
|
img.src = badge.url_habboassets;
|
||||||
} else {
|
} else {
|
||||||
img.style.opacity = '0.3'
|
img.style.opacity = "0.3";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -402,9 +391,7 @@ export function ImportBadgesClient() {
|
|||||||
<TooltipContent side="bottom" className="max-w-64">
|
<TooltipContent side="bottom" className="max-w-64">
|
||||||
<p className="font-medium">{badge.name}</p>
|
<p className="font-medium">{badge.name}</p>
|
||||||
{badge.description && (
|
{badge.description && (
|
||||||
<p className="text-muted-foreground text-xs mt-1">
|
<p className="text-muted-foreground text-xs mt-1">{badge.description}</p>
|
||||||
{badge.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -452,7 +439,7 @@ export function ImportBadgesClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -466,7 +453,7 @@ export function ImportBadgesClient() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={offset <= 0 || loading}
|
disabled={offset <= 0 || loading}
|
||||||
onClick={() => goPage('prev')}
|
onClick={() => goPage("prev")}
|
||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -474,7 +461,7 @@ export function ImportBadgesClient() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!hasMore || loading}
|
disabled={!hasMore || loading}
|
||||||
onClick={() => goPage('next')}
|
onClick={() => goPage("next")}
|
||||||
>
|
>
|
||||||
Next →
|
Next →
|
||||||
</Button>
|
</Button>
|
||||||
@@ -484,13 +471,13 @@ export function ImportBadgesClient() {
|
|||||||
|
|
||||||
{/* Badge Editor Dialog */}
|
{/* Badge Editor Dialog */}
|
||||||
<BadgeEditorDialog
|
<BadgeEditorDialog
|
||||||
code={editingCode ?? ''}
|
code={editingCode ?? ""}
|
||||||
open={!!editingCode}
|
open={!!editingCode}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setEditingCode(null)
|
if (!open) setEditingCode(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function ImportBadgesLoading() {
|
export default function ImportBadgesLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -17,5 +17,5 @@ export default function ImportBadgesLoading() {
|
|||||||
<Skeleton className="h-10 w-24 mt-4" />
|
<Skeleton className="h-10 w-24 mt-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { ImportBadgesClient } from './import-badges-client'
|
import { ImportBadgesClient } from "./import-badges-client";
|
||||||
|
|
||||||
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
|
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ImportBadgesClient />
|
return <ImportBadgesClient />;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -12,46 +12,46 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CloneSource {
|
interface CloneSource {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
furnidataUrl: string
|
furnidataUrl: string;
|
||||||
nitroBaseUrl: string
|
nitroBaseUrl: string;
|
||||||
iconBaseUrl: string
|
iconBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FurniItem {
|
interface FurniItem {
|
||||||
classname: string
|
classname: string;
|
||||||
name: string
|
name: string;
|
||||||
description?: string
|
description?: string;
|
||||||
revision?: number
|
revision?: number;
|
||||||
category?: string
|
category?: string;
|
||||||
itemType: 's' | 'i'
|
itemType: "s" | "i";
|
||||||
xdim: number
|
xdim: number;
|
||||||
ydim: number
|
ydim: number;
|
||||||
present: boolean
|
present: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloneStats {
|
interface CloneStats {
|
||||||
total: number
|
total: number;
|
||||||
present: number
|
present: number;
|
||||||
clonable: number
|
clonable: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageMeta {
|
interface PageMeta {
|
||||||
page: number
|
page: number;
|
||||||
perPage: number
|
perPage: number;
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
|
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
|
||||||
@@ -63,63 +63,63 @@ async function runSseImport(
|
|||||||
onComplete: (succeeded: number, failed: number) => void,
|
onComplete: (succeeded: number, failed: number) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
});
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
toast.error('No response stream')
|
toast.error("No response stream");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
let buf = ''
|
let buf = "";
|
||||||
let succeeded = 0
|
let succeeded = 0;
|
||||||
let failed = 0
|
let failed = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done: streamDone } = await reader.read()
|
const { value, done: streamDone } = await reader.read();
|
||||||
if (streamDone) break
|
if (streamDone) break;
|
||||||
buf += decoder.decode(value, { stream: true })
|
buf += decoder.decode(value, { stream: true });
|
||||||
const parts = buf.split('\n\n')
|
const parts = buf.split("\n\n");
|
||||||
buf = parts.pop() ?? ''
|
buf = parts.pop() ?? "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (!part.startsWith('data: ')) continue
|
if (!part.startsWith("data: ")) continue;
|
||||||
const evt = JSON.parse(part.slice(6))
|
const evt = JSON.parse(part.slice(6));
|
||||||
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) {
|
if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
|
||||||
onDone(evt.classname)
|
onDone(evt.classname);
|
||||||
}
|
}
|
||||||
if (evt.type === 'batch_complete') {
|
if (evt.type === "batch_complete") {
|
||||||
succeeded = evt.succeeded
|
succeeded = evt.succeeded;
|
||||||
failed = evt.failed
|
failed = evt.failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onComplete(succeeded, failed)
|
onComplete(succeeded, failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source form ───────────────────────────────────────────────────────────────
|
// ── Source form ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SourceFormProps {
|
interface SourceFormProps {
|
||||||
initial?: Partial<CloneSource>
|
initial?: Partial<CloneSource>;
|
||||||
onSave: (src: Partial<CloneSource>) => Promise<void>
|
onSave: (src: Partial<CloneSource>) => Promise<void>;
|
||||||
onCancel: () => void
|
onCancel: () => void;
|
||||||
saving: boolean
|
saving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
||||||
const [name, setName] = useState(initial?.name ?? '')
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? '')
|
const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? "");
|
||||||
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? '')
|
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
|
||||||
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? '')
|
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl })
|
await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border p-4 bg-card">
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border p-4 bg-card">
|
||||||
<div className="text-sm font-medium">{initial?.id ? 'Edit Source' : 'Add Source'}</div>
|
<div className="text-sm font-medium">{initial?.id ? "Edit Source" : "Add Source"}</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Name (e.g. HotelX)"
|
placeholder="Name (e.g. HotelX)"
|
||||||
@@ -156,61 +156,61 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sources manager ───────────────────────────────────────────────────────────
|
// ── Sources manager ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SourcesManagerProps {
|
interface SourcesManagerProps {
|
||||||
sources: CloneSource[]
|
sources: CloneSource[];
|
||||||
activeSourceId: string | null
|
activeSourceId: string | null;
|
||||||
onSelect: (src: CloneSource) => void
|
onSelect: (src: CloneSource) => void;
|
||||||
onRefresh: () => Promise<void>
|
onRefresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) {
|
function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) {
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editSrc, setEditSrc] = useState<CloneSource | null>(null)
|
const [editSrc, setEditSrc] = useState<CloneSource | null>(null);
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSave(src: Partial<CloneSource>) {
|
async function handleSave(src: Partial<CloneSource>) {
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/import/clone', {
|
const res = await fetch("/api/admin/import/clone", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(src),
|
body: JSON.stringify(src),
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || 'Save failed')
|
toast.error(data.error || "Save failed");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
toast.success(src.id ? 'Source updated' : 'Source added')
|
toast.success(src.id ? "Source updated" : "Source added");
|
||||||
setShowForm(false)
|
setShowForm(false);
|
||||||
setEditSrc(null)
|
setEditSrc(null);
|
||||||
await onRefresh()
|
await onRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(src: CloneSource) {
|
async function handleDelete(src: CloneSource) {
|
||||||
setDeletingId(src.id)
|
setDeletingId(src.id);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, {
|
const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || 'Delete failed')
|
toast.error(data.error || "Delete failed");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`${src.name} removed`)
|
toast.success(`${src.name} removed`);
|
||||||
await onRefresh()
|
await onRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +223,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditSrc(null)
|
setEditSrc(null);
|
||||||
setShowForm(true)
|
setShowForm(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
@@ -246,17 +246,12 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
|||||||
{sources.map((src) => (
|
{sources.map((src) => (
|
||||||
<div key={src.id}>
|
<div key={src.id}>
|
||||||
{editSrc?.id === src.id ? (
|
{editSrc?.id === src.id ? (
|
||||||
<SourceForm
|
<SourceForm initial={src} onSave={handleSave} onCancel={() => setEditSrc(null)} saving={saving} />
|
||||||
initial={src}
|
|
||||||
onSave={handleSave}
|
|
||||||
onCancel={() => setEditSrc(null)}
|
|
||||||
saving={saving}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border',
|
"w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border",
|
||||||
activeSourceId === src.id && 'border-primary ring-1 ring-primary bg-primary/5',
|
activeSourceId === src.id && "border-primary ring-1 ring-primary bg-primary/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -277,8 +272,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
|||||||
className="h-6 w-6 p-0"
|
className="h-6 w-6 p-0"
|
||||||
title="Edit source"
|
title="Edit source"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditSrc(src)
|
setEditSrc(src);
|
||||||
setShowForm(false)
|
setShowForm(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
@@ -303,7 +298,7 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Furni icon with fallback ──────────────────────────────────────────────────
|
// ── Furni icon with fallback ──────────────────────────────────────────────────
|
||||||
@@ -313,20 +308,20 @@ function FurniIcon({
|
|||||||
iconBaseUrl,
|
iconBaseUrl,
|
||||||
classname,
|
classname,
|
||||||
}: {
|
}: {
|
||||||
sourceId: string
|
sourceId: string;
|
||||||
iconBaseUrl: string
|
iconBaseUrl: string;
|
||||||
classname: string
|
classname: string;
|
||||||
}) {
|
}) {
|
||||||
// 0 = source's standalone icon, 1 = icon extracted from the .nitro (our
|
// 0 = source's standalone icon, 1 = icon extracted from the .nitro (our
|
||||||
// endpoint), 2 = give up and show a placeholder.
|
// endpoint), 2 = give up and show a placeholder.
|
||||||
const [stage, setStage] = useState(0)
|
const [stage, setStage] = useState(0);
|
||||||
if (stage >= 2) {
|
if (stage >= 2) {
|
||||||
return <Package className="w-8 h-8 text-muted-foreground/50" />
|
return <Package className="w-8 h-8 text-muted-foreground/50" />;
|
||||||
}
|
}
|
||||||
const src =
|
const src =
|
||||||
stage === 0
|
stage === 0
|
||||||
? `${iconBaseUrl}/${classname}_icon.png`
|
? `${iconBaseUrl}/${classname}_icon.png`
|
||||||
: `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`
|
: `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`;
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset
|
// biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset
|
||||||
<img
|
<img
|
||||||
@@ -336,203 +331,197 @@ function FurniIcon({
|
|||||||
className="max-h-full max-w-full object-contain image-pixelated"
|
className="max-h-full max-w-full object-contain image-pixelated"
|
||||||
onError={() => setStage((s) => s + 1)}
|
onError={() => setStage((s) => s + 1)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Furni grid view ───────────────────────────────────────────────────────────
|
// ── Furni grid view ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FurniGridProps {
|
interface FurniGridProps {
|
||||||
source: CloneSource
|
source: CloneSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PER_PAGE = 50
|
const PER_PAGE = 50;
|
||||||
|
|
||||||
function FurniGrid({ source }: FurniGridProps) {
|
function FurniGrid({ source }: FurniGridProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = useState('')
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
const [items, setItems] = useState<FurniItem[]>([])
|
const [items, setItems] = useState<FurniItem[]>([]);
|
||||||
const [stats, setStats] = useState<CloneStats | null>(null)
|
const [stats, setStats] = useState<CloneStats | null>(null);
|
||||||
const [meta, setMeta] = useState<PageMeta | null>(null)
|
const [meta, setMeta] = useState<PageMeta | null>(null);
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
const [busyClassname, setBusyClassname] = useState<string | null>(null)
|
const [busyClassname, setBusyClassname] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<'all' | 'missing' | 'present'>('all')
|
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async (search: string, pageNum: number, filterVal: 'all' | 'missing' | 'present' = 'all') => {
|
async (search: string, pageNum: number, filterVal: "all" | "missing" | "present" = "all") => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError('')
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
source: source.id,
|
source: source.id,
|
||||||
page: String(pageNum),
|
page: String(pageNum),
|
||||||
perPage: String(PER_PAGE),
|
perPage: String(PER_PAGE),
|
||||||
})
|
});
|
||||||
if (search) params.set('search', search)
|
if (search) params.set("search", search);
|
||||||
if (filterVal !== 'all') params.set('filter', filterVal)
|
if (filterVal !== "all") params.set("filter", filterVal);
|
||||||
const res = await fetch(`/api/admin/import/clone?${params}`)
|
const res = await fetch(`/api/admin/import/clone?${params}`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Failed to load')
|
setError(data.error || "Failed to load");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setItems(data.item || [])
|
setItems(data.item || []);
|
||||||
setMeta(data.meta || null)
|
setMeta(data.meta || null);
|
||||||
setStats(data.stats || null)
|
setStats(data.stats || null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[source.id],
|
[source.id],
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1);
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setItems([])
|
setItems([]);
|
||||||
setStats(null)
|
setStats(null);
|
||||||
setMeta(null)
|
setMeta(null);
|
||||||
setFilter('all')
|
setFilter("all");
|
||||||
fetchItems('', 1, 'all')
|
fetchItems("", 1, "all");
|
||||||
}, [fetchItems])
|
}, [fetchItems]);
|
||||||
|
|
||||||
function doSearch() {
|
function doSearch() {
|
||||||
setActiveSearch(searchTerm)
|
setActiveSearch(searchTerm);
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems(searchTerm, 1, filter)
|
fetchItems(searchTerm, 1, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(dir: 'prev' | 'next') {
|
function goPage(dir: "prev" | "next") {
|
||||||
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
|
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
|
||||||
setPage(next)
|
setPage(next);
|
||||||
fetchItems(activeSearch, next, filter)
|
fetchItems(activeSearch, next, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeFilter(f: 'all' | 'missing' | 'present') {
|
function changeFilter(f: "all" | "missing" | "present") {
|
||||||
setFilter(f)
|
setFilter(f);
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems(activeSearch, 1, f)
|
fetchItems(activeSearch, 1, f);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importable = items.filter((it) => !it.present)
|
const importable = items.filter((it) => !it.present);
|
||||||
const total = meta?.total ?? 0
|
const total = meta?.total ?? 0;
|
||||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
|
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||||
|
|
||||||
function toggle(classname: string) {
|
function toggle(classname: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev);
|
||||||
if (n.has(classname)) n.delete(classname)
|
if (n.has(classname)) n.delete(classname);
|
||||||
else n.add(classname)
|
else n.add(classname);
|
||||||
return n
|
return n;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDone(classname: string) {
|
function markDone(classname: string) {
|
||||||
setItems((prev) =>
|
setItems((prev) => prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)));
|
||||||
prev.map((it) => (it.classname === classname ? { ...it, present: true } : it)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cloneViaSse(toClone: FurniItem[]) {
|
async function cloneViaSse(toClone: FurniItem[]) {
|
||||||
setBatchProgress({ done: 0, total: toClone.length })
|
setBatchProgress({ done: 0, total: toClone.length });
|
||||||
let done = 0
|
let done = 0;
|
||||||
await runSseImport(
|
await runSseImport(
|
||||||
'/api/admin/import/clone/batch',
|
"/api/admin/import/clone/batch",
|
||||||
{ sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2 },
|
{ sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2 },
|
||||||
(classname) => {
|
(classname) => {
|
||||||
done++
|
done++;
|
||||||
markDone(classname)
|
markDone(classname);
|
||||||
setBatchProgress({ done, total: toClone.length })
|
setBatchProgress({ done, total: toClone.length });
|
||||||
},
|
},
|
||||||
(succeeded, failed) => {
|
(succeeded, failed) => {
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (succeeded > 0) toast.success(`${succeeded} furni cloned`)
|
if (succeeded > 0) toast.success(`${succeeded} furni cloned`);
|
||||||
if (failed > 0) toast.error(`${failed} failed`)
|
if (failed > 0) toast.error(`${failed} failed`);
|
||||||
// Refresh stats after batch
|
// Refresh stats after batch
|
||||||
fetchItems(activeSearch, page, filter)
|
fetchItems(activeSearch, page, filter);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cloneOne(it: FurniItem) {
|
async function cloneOne(it: FurniItem) {
|
||||||
setBusyClassname(it.classname)
|
setBusyClassname(it.classname);
|
||||||
try {
|
try {
|
||||||
await cloneViaSse([it])
|
await cloneViaSse([it]);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyClassname(null)
|
setBusyClassname(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cloneSelected() {
|
async function cloneSelected() {
|
||||||
const toClone = items.filter((it) => selected.has(it.classname) && !it.present)
|
const toClone = items.filter((it) => selected.has(it.classname) && !it.present);
|
||||||
if (toClone.length === 0) return
|
if (toClone.length === 0) return;
|
||||||
await cloneViaSse(toClone)
|
await cloneViaSse(toClone);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone EVERY not-yet-present furni from the source (across all pages),
|
// Clone EVERY not-yet-present furni from the source (across all pages),
|
||||||
// in chunks of 400 to stay under the batch endpoint's 500 cap.
|
// in chunks of 400 to stay under the batch endpoint's 500 cap.
|
||||||
async function cloneAll() {
|
async function cloneAll() {
|
||||||
let names: string[] = []
|
let names: string[] = [];
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
|
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
|
||||||
)
|
);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || 'Failed to load clonable list')
|
toast.error(data.error || "Failed to load clonable list");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
names = data.classnames || []
|
names = data.classnames || [];
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Network error')
|
toast.error("Network error");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (names.length === 0) {
|
if (names.length === 0) {
|
||||||
toast.info('Nothing to clone — everything is already present')
|
toast.info("Nothing to clone — everything is already present");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (!window.confirm(`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`)) {
|
||||||
!window.confirm(
|
return;
|
||||||
`Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const CHUNK = 400
|
const CHUNK = 400;
|
||||||
setBatchProgress({ done: 0, total: names.length })
|
setBatchProgress({ done: 0, total: names.length });
|
||||||
let done = 0
|
let done = 0;
|
||||||
let okCount = 0
|
let okCount = 0;
|
||||||
let failCount = 0
|
let failCount = 0;
|
||||||
for (let i = 0; i < names.length; i += CHUNK) {
|
for (let i = 0; i < names.length; i += CHUNK) {
|
||||||
const chunk = names.slice(i, i + CHUNK)
|
const chunk = names.slice(i, i + CHUNK);
|
||||||
await runSseImport(
|
await runSseImport(
|
||||||
'/api/admin/import/clone/batch',
|
"/api/admin/import/clone/batch",
|
||||||
{ sourceId: source.id, classnames: chunk, concurrency: 2 },
|
{ sourceId: source.id, classnames: chunk, concurrency: 2 },
|
||||||
(classname) => {
|
(classname) => {
|
||||||
done++
|
done++;
|
||||||
markDone(classname)
|
markDone(classname);
|
||||||
setBatchProgress({ done, total: names.length })
|
setBatchProgress({ done, total: names.length });
|
||||||
},
|
},
|
||||||
(succeeded, failed) => {
|
(succeeded, failed) => {
|
||||||
okCount += succeeded
|
okCount += succeeded;
|
||||||
failCount += failed
|
failCount += failed;
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (okCount > 0) toast.success(`${okCount} furni cloned`)
|
if (okCount > 0) toast.success(`${okCount} furni cloned`);
|
||||||
if (failCount > 0) toast.error(`${failCount} failed`)
|
if (failCount > 0) toast.error(`${failCount} failed`);
|
||||||
fetchItems(activeSearch, page, filter)
|
fetchItems(activeSearch, page, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -567,12 +556,12 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') doSearch()
|
if (e.key === "Enter") doSearch();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems('', 1, filter)
|
fetchItems("", 1, filter);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8"
|
||||||
@@ -581,10 +570,10 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems('', 1, filter)
|
fetchItems("", 1, filter);
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -601,22 +590,22 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ key: 'all', label: 'All', count: stats?.total },
|
{ key: "all", label: "All", count: stats?.total },
|
||||||
{ key: 'missing', label: "Not present (don't have)", count: stats?.clonable },
|
{ key: "missing", label: "Not present (don't have)", count: stats?.clonable },
|
||||||
{ key: 'present', label: 'Present', count: stats?.present },
|
{ key: "present", label: "Present", count: stats?.present },
|
||||||
] as const
|
] as const
|
||||||
).map((f) => (
|
).map((f) => (
|
||||||
<Button
|
<Button
|
||||||
key={f.key}
|
key={f.key}
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={filter === f.key ? 'default' : 'outline'}
|
variant={filter === f.key ? "default" : "outline"}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
disabled={loading || !!batchProgress}
|
disabled={loading || !!batchProgress}
|
||||||
onClick={() => changeFilter(f.key)}
|
onClick={() => changeFilter(f.key)}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
{typeof f.count === 'number' ? ` (${f.count})` : ''}
|
{typeof f.count === "number" ? ` (${f.count})` : ""}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -632,15 +621,11 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
disabled={!!batchProgress}
|
disabled={!!batchProgress}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelected((prev) =>
|
setSelected((prev) =>
|
||||||
prev.size === importable.length
|
prev.size === importable.length ? new Set() : new Set(importable.map((it) => it.classname)),
|
||||||
? new Set()
|
|
||||||
: new Set(importable.map((it) => it.classname)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selected.size === importable.length
|
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
|
||||||
? 'Deselect page'
|
|
||||||
: `Select page (${importable.length})`}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
@@ -689,27 +674,22 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
{!loading && items.length === 0 && !error && (
|
{!loading && items.length === 0 && !error && (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<CloudDownload className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<CloudDownload className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
|
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div
|
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
|
||||||
loading && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{items.map((it) => {
|
{items.map((it) => {
|
||||||
const isSelected = selected.has(it.classname)
|
const isSelected = selected.has(it.classname);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={it.classname}
|
key={it.classname}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
"group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||||
it.present && 'ring-1 ring-[var(--admin-success)]/30',
|
it.present && "ring-1 ring-[var(--admin-success)]/30",
|
||||||
isSelected && 'ring-2 ring-primary border-primary',
|
isSelected && "ring-2 ring-primary border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!it.present && (
|
{!it.present && (
|
||||||
@@ -717,10 +697,10 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(it.classname)}
|
onClick={() => toggle(it.classname)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="w-3 h-3" />}
|
{isSelected && <Check className="w-3 h-3" />}
|
||||||
@@ -729,23 +709,19 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
{/* Type badge */}
|
{/* Type badge */}
|
||||||
<div className="absolute top-1.5 right-1.5 z-10">
|
<div className="absolute top-1.5 right-1.5 z-10">
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80">
|
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80">
|
||||||
{it.itemType === 's' ? 'floor' : 'wall'}
|
{it.itemType === "s" ? "floor" : "wall"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-center h-28 bg-accent/20">
|
<div className="relative flex items-center justify-center h-28 bg-accent/20">
|
||||||
<FurniIcon
|
<FurniIcon sourceId={source.id} iconBaseUrl={source.iconBaseUrl} classname={it.classname} />
|
||||||
sourceId={source.id}
|
|
||||||
iconBaseUrl={source.iconBaseUrl}
|
|
||||||
classname={it.classname}
|
|
||||||
/>
|
|
||||||
{/* Hover detail tooltip */}
|
{/* Hover detail tooltip */}
|
||||||
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
<div className="bg-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
|
<div className="bg-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
|
||||||
{it.description ? <p className="truncate">{it.description}</p> : null}
|
{it.description ? <p className="truncate">{it.description}</p> : null}
|
||||||
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
|
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
|
||||||
{it.revision ? <span>Rev: {it.revision}</span> : null}
|
{it.revision ? <span>Rev: {it.revision}</span> : null}
|
||||||
<span>{it.itemType === 's' ? 'Floor' : 'Wall'}</span>
|
<span>{it.itemType === "s" ? "Floor" : "Wall"}</span>
|
||||||
<span>
|
<span>
|
||||||
{it.xdim}×{it.ydim}
|
{it.xdim}×{it.ydim}
|
||||||
</span>
|
</span>
|
||||||
@@ -762,7 +738,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
{it.classname}
|
{it.classname}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground truncate">
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
{it.itemType === 's' ? 'Floor' : 'Wall'} · {it.category || 'other'}
|
{it.itemType === "s" ? "Floor" : "Wall"} · {it.category || "other"}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
{it.present ? (
|
{it.present ? (
|
||||||
@@ -789,7 +765,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -805,7 +781,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page <= 1 || loading}
|
disabled={page <= 1 || loading}
|
||||||
onClick={() => goPage('prev')}
|
onClick={() => goPage("prev")}
|
||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -813,7 +789,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page >= lastPage || loading}
|
disabled={page >= lastPage || loading}
|
||||||
onClick={() => goPage('next')}
|
onClick={() => goPage("next")}
|
||||||
>
|
>
|
||||||
Next →
|
Next →
|
||||||
</Button>
|
</Button>
|
||||||
@@ -821,33 +797,33 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Root component ────────────────────────────────────────────────────────────
|
// ── Root component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ImportCloneClient() {
|
export function ImportCloneClient() {
|
||||||
const [sources, setSources] = useState<CloneSource[]>([])
|
const [sources, setSources] = useState<CloneSource[]>([]);
|
||||||
const [activeSource, setActiveSource] = useState<CloneSource | null>(null)
|
const [activeSource, setActiveSource] = useState<CloneSource | null>(null);
|
||||||
const [loadingSources, setLoadingSources] = useState(true)
|
const [loadingSources, setLoadingSources] = useState(true);
|
||||||
|
|
||||||
const fetchSources = useCallback(async () => {
|
const fetchSources = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/import/clone?action=sources')
|
const res = await fetch("/api/admin/import/clone?action=sources");
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSources(data.sources || [])
|
setSources(data.sources || []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSources(false)
|
setLoadingSources(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSources()
|
fetchSources();
|
||||||
}, [fetchSources])
|
}, [fetchSources]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -883,5 +859,5 @@ export function ImportCloneClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { ImportCloneClient } from './import-clone-client'
|
import { ImportCloneClient } from "./import-clone-client";
|
||||||
|
|
||||||
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) {
|
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
return <ImportCloneClient />
|
return <ImportCloneClient />;
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,35 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import { AlertCircle, Check, Download, Loader2, Package, Search, Shirt, Trash2, X } from "lucide-react";
|
||||||
AlertCircle,
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
Check,
|
import { toast } from "sonner";
|
||||||
Download,
|
import { Badge } from "@/components/ui/badge";
|
||||||
Loader2,
|
import { Button } from "@/components/ui/button";
|
||||||
Package,
|
import { Input } from "@/components/ui/input";
|
||||||
Search,
|
import { cn } from "@/lib/utils";
|
||||||
Shirt,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
// ── Shared types ─────────────────────────────────────────────────────────────
|
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FigureItem {
|
interface FigureItem {
|
||||||
lib: string
|
lib: string;
|
||||||
revision: number
|
revision: number;
|
||||||
nitroExists: boolean
|
nitroExists: boolean;
|
||||||
previewType?: string
|
previewType?: string;
|
||||||
previewId?: number
|
previewId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClothingSetItem {
|
interface ClothingSetItem {
|
||||||
setType: string
|
setType: string;
|
||||||
setId: number
|
setId: number;
|
||||||
gender: string
|
gender: string;
|
||||||
club: number
|
club: number;
|
||||||
sellable: boolean
|
sellable: boolean;
|
||||||
present: boolean
|
present: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'libs' | 'sets'
|
type Mode = "libs" | "sets";
|
||||||
|
|
||||||
const PER_PAGE = 100
|
const PER_PAGE = 100;
|
||||||
|
|
||||||
// ── Shared SSE reader ─────────────────────────────────────────────────────────
|
// ── Shared SSE reader ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,38 +40,38 @@ async function runSseImport(
|
|||||||
onComplete: (succeeded: number, failed: number) => void,
|
onComplete: (succeeded: number, failed: number) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
});
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
toast.error('No response stream')
|
toast.error("No response stream");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
let buf = ''
|
let buf = "";
|
||||||
let succeeded = 0
|
let succeeded = 0;
|
||||||
let failed = 0
|
let failed = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done: streamDone } = await reader.read()
|
const { value, done: streamDone } = await reader.read();
|
||||||
if (streamDone) break
|
if (streamDone) break;
|
||||||
buf += decoder.decode(value, { stream: true })
|
buf += decoder.decode(value, { stream: true });
|
||||||
const parts = buf.split('\n\n')
|
const parts = buf.split("\n\n");
|
||||||
buf = parts.pop() ?? ''
|
buf = parts.pop() ?? "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (!part.startsWith('data: ')) continue
|
if (!part.startsWith("data: ")) continue;
|
||||||
const evt = JSON.parse(part.slice(6))
|
const evt = JSON.parse(part.slice(6));
|
||||||
if (evt.type === 'item_progress' && (evt.status === 'done' || evt.status === 'failed')) {
|
if (evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed")) {
|
||||||
onDone(evt.classname)
|
onDone(evt.classname);
|
||||||
}
|
}
|
||||||
if (evt.type === 'batch_complete') {
|
if (evt.type === "batch_complete") {
|
||||||
succeeded = evt.succeeded
|
succeeded = evt.succeeded;
|
||||||
failed = evt.failed
|
failed = evt.failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onComplete(succeeded, failed)
|
onComplete(succeeded, failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
|
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
|
||||||
@@ -89,30 +79,30 @@ async function runSseImport(
|
|||||||
// A default dressed avatar; the previewed part overrides/adds its slot so the
|
// A default dressed avatar; the previewed part overrides/adds its slot so the
|
||||||
// item is shown on a complete figure.
|
// item is shown on a complete figure.
|
||||||
const BASE_FIGURE: Record<string, string> = {
|
const BASE_FIGURE: Record<string, string> = {
|
||||||
hd: 'hd-180-1',
|
hd: "hd-180-1",
|
||||||
hr: 'hr-828-45',
|
hr: "hr-828-45",
|
||||||
ch: 'ch-665-92',
|
ch: "ch-665-92",
|
||||||
lg: 'lg-700-82',
|
lg: "lg-700-82",
|
||||||
sh: 'sh-705-62',
|
sh: "sh-705-62",
|
||||||
}
|
};
|
||||||
|
|
||||||
// Head-worn slots → render head-only (zoomed) so the item fills the frame;
|
// Head-worn slots → render head-only (zoomed) so the item fills the frame;
|
||||||
// everything else renders the full body.
|
// everything else renders the full body.
|
||||||
const HEAD_TYPES = new Set(['hd', 'hr', 'ha', 'he', 'ea', 'fa', 'fc'])
|
const HEAD_TYPES = new Set(["hd", "hr", "ha", "he", "ea", "fa", "fc"]);
|
||||||
|
|
||||||
function buildPreviewFigure(type: string, id: number): string {
|
function buildPreviewFigure(type: string, id: number): string {
|
||||||
const parts: Record<string, string> = { ...BASE_FIGURE }
|
const parts: Record<string, string> = { ...BASE_FIGURE };
|
||||||
parts[type] = `${type}-${id}` // default color
|
parts[type] = `${type}-${id}`; // default color
|
||||||
return Object.values(parts).join('.')
|
return Object.values(parts).join(".");
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarPreview({ type, id }: { type?: string; id?: number }) {
|
function AvatarPreview({ type, id }: { type?: string; id?: number }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false);
|
||||||
if (failed || !type || id === undefined) {
|
if (failed || !type || id === undefined) {
|
||||||
return <Shirt className="w-8 h-8 text-muted-foreground/60" />
|
return <Shirt className="w-8 h-8 text-muted-foreground/60" />;
|
||||||
}
|
}
|
||||||
const figure = buildPreviewFigure(type, id)
|
const figure = buildPreviewFigure(type, id);
|
||||||
const headOnly = HEAD_TYPES.has(type) ? '&headonly=1' : ''
|
const headOnly = HEAD_TYPES.has(type) ? "&headonly=1" : "";
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset
|
// biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset
|
||||||
<img
|
<img
|
||||||
@@ -121,129 +111,129 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) {
|
|||||||
className="max-h-full object-contain image-pixelated"
|
className="max-h-full object-contain image-pixelated"
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Librerie (v1) view ────────────────────────────────────────────────────────
|
// ── Librerie (v1) view ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function LibsView() {
|
function LibsView() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = useState('')
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
const [items, setItems] = useState<FigureItem[]>([])
|
const [items, setItems] = useState<FigureItem[]>([]);
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
const [busyLib, setBusyLib] = useState<string | null>(null)
|
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError('')
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(pageNum) })
|
const params = new URLSearchParams({ page: String(pageNum) });
|
||||||
if (search) params.set('search', search)
|
if (search) params.set("search", search);
|
||||||
const res = await fetch(`/api/admin/import/clothing?${params}`)
|
const res = await fetch(`/api/admin/import/clothing?${params}`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Failed to load')
|
setError(data.error || "Failed to load");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setItems(data.item || [])
|
setItems(data.item || []);
|
||||||
setTotal(data.meta?.total || 0)
|
setTotal(data.meta?.total || 0);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchItems('', 1)
|
fetchItems("", 1);
|
||||||
}, [fetchItems])
|
}, [fetchItems]);
|
||||||
|
|
||||||
function doSearch() {
|
function doSearch() {
|
||||||
setActiveSearch(searchTerm)
|
setActiveSearch(searchTerm);
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems(searchTerm, 1)
|
fetchItems(searchTerm, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(dir: 'prev' | 'next') {
|
function goPage(dir: "prev" | "next") {
|
||||||
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
|
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
|
||||||
setPage(next)
|
setPage(next);
|
||||||
fetchItems(activeSearch, next)
|
fetchItems(activeSearch, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items])
|
const importable = useMemo(() => items.filter((p) => !p.nitroExists), [items]);
|
||||||
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items])
|
const importedCount = useMemo(() => items.filter((p) => p.nitroExists).length, [items]);
|
||||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
|
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||||
|
|
||||||
function toggle(lib: string) {
|
function toggle(lib: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev);
|
||||||
if (n.has(lib)) n.delete(lib)
|
if (n.has(lib)) n.delete(lib);
|
||||||
else n.add(lib)
|
else n.add(lib);
|
||||||
return n
|
return n;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDone(lib: string) {
|
function markDone(lib: string) {
|
||||||
setItems((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)))
|
setItems((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importViaSse(toImport: FigureItem[]) {
|
async function importViaSse(toImport: FigureItem[]) {
|
||||||
setBatchProgress({ done: 0, total: toImport.length })
|
setBatchProgress({ done: 0, total: toImport.length });
|
||||||
let done = 0
|
let done = 0;
|
||||||
await runSseImport(
|
await runSseImport(
|
||||||
'/api/admin/import/clothing/batch',
|
"/api/admin/import/clothing/batch",
|
||||||
{ items: toImport, concurrency: 3 },
|
{ items: toImport, concurrency: 3 },
|
||||||
(label) => {
|
(label) => {
|
||||||
done++
|
done++;
|
||||||
markDone(label)
|
markDone(label);
|
||||||
setBatchProgress({ done, total: toImport.length })
|
setBatchProgress({ done, total: toImport.length });
|
||||||
},
|
},
|
||||||
(succeeded, failed) => {
|
(succeeded, failed) => {
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (succeeded > 0) toast.success(`${succeeded} libraries imported`)
|
if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
|
||||||
if (failed > 0) toast.error(`${failed} failed`)
|
if (failed > 0) toast.error(`${failed} failed`);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importOne(p: FigureItem) {
|
async function importOne(p: FigureItem) {
|
||||||
setBusyLib(p.lib)
|
setBusyLib(p.lib);
|
||||||
try {
|
try {
|
||||||
await importViaSse([p])
|
await importViaSse([p]);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyLib(null)
|
setBusyLib(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importSelected() {
|
async function importSelected() {
|
||||||
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists)
|
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
|
||||||
if (toImport.length === 0) return
|
if (toImport.length === 0) return;
|
||||||
await importViaSse(toImport)
|
await importViaSse(toImport);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(p: FigureItem) {
|
async function remove(p: FigureItem) {
|
||||||
setBusyLib(p.lib)
|
setBusyLib(p.lib);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, {
|
const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success(`${p.lib} removed`)
|
toast.success(`${p.lib} removed`);
|
||||||
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)))
|
setItems((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Delete failed')
|
toast.error(data.error || "Delete failed");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusyLib(null)
|
setBusyLib(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,12 +267,12 @@ function LibsView() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') doSearch()
|
if (e.key === "Enter") doSearch();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems('', 1)
|
fetchItems("", 1);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8"
|
||||||
@@ -291,10 +281,10 @@ function LibsView() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems('', 1)
|
fetchItems("", 1);
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -320,9 +310,7 @@ function LibsView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selected.size === importable.length
|
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
|
||||||
? 'Deselect page'
|
|
||||||
: `Select page (${importable.length})`}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||||
@@ -352,28 +340,21 @@ function LibsView() {
|
|||||||
{!loading && items.length === 0 && !error && (
|
{!loading && items.length === 0 && !error && (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">No libraries found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||||
No libraries found{activeSearch ? ` for "${activeSearch}"` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div
|
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
|
||||||
loading && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{items.map((p) => {
|
{items.map((p) => {
|
||||||
const isSelected = selected.has(p.lib)
|
const isSelected = selected.has(p.lib);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.lib}
|
key={p.lib}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||||
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
|
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
|
||||||
isSelected && 'ring-2 ring-primary border-primary',
|
isSelected && "ring-2 ring-primary border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!p.nitroExists && (
|
{!p.nitroExists && (
|
||||||
@@ -381,10 +362,10 @@ function LibsView() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(p.lib)}
|
onClick={() => toggle(p.lib)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="w-3 h-3" />}
|
{isSelected && <Check className="w-3 h-3" />}
|
||||||
@@ -434,7 +415,7 @@ function LibsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -449,7 +430,7 @@ function LibsView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page <= 1 || loading}
|
disabled={page <= 1 || loading}
|
||||||
onClick={() => goPage('prev')}
|
onClick={() => goPage("prev")}
|
||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -457,7 +438,7 @@ function LibsView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page >= lastPage || loading}
|
disabled={page >= lastPage || loading}
|
||||||
onClick={() => goPage('next')}
|
onClick={() => goPage("next")}
|
||||||
>
|
>
|
||||||
Next →
|
Next →
|
||||||
</Button>
|
</Button>
|
||||||
@@ -465,120 +446,120 @@ function LibsView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
|
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function SetsView() {
|
function SetsView() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = useState('')
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
const [items, setItems] = useState<ClothingSetItem[]>([])
|
const [items, setItems] = useState<ClothingSetItem[]>([]);
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||||
|
|
||||||
function itemKey(it: ClothingSetItem): string {
|
function itemKey(it: ClothingSetItem): string {
|
||||||
return `${it.setType}-${it.setId}`
|
return `${it.setType}-${it.setId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError('')
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(pageNum) })
|
const params = new URLSearchParams({ page: String(pageNum) });
|
||||||
if (search) params.set('search', search)
|
if (search) params.set("search", search);
|
||||||
const res = await fetch(`/api/admin/import/clothing/sets?${params}`)
|
const res = await fetch(`/api/admin/import/clothing/sets?${params}`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Failed to load')
|
setError(data.error || "Failed to load");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setItems(data.item || [])
|
setItems(data.item || []);
|
||||||
setTotal(data.meta?.total || 0)
|
setTotal(data.meta?.total || 0);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchItems('', 1)
|
fetchItems("", 1);
|
||||||
}, [fetchItems])
|
}, [fetchItems]);
|
||||||
|
|
||||||
function doSearch() {
|
function doSearch() {
|
||||||
setActiveSearch(searchTerm)
|
setActiveSearch(searchTerm);
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems(searchTerm, 1)
|
fetchItems(searchTerm, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(dir: 'prev' | 'next') {
|
function goPage(dir: "prev" | "next") {
|
||||||
const next = dir === 'next' ? page + 1 : Math.max(1, page - 1)
|
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
|
||||||
setPage(next)
|
setPage(next);
|
||||||
fetchItems(activeSearch, next)
|
fetchItems(activeSearch, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importable = useMemo(() => items.filter((it) => !it.present), [items])
|
const importable = useMemo(() => items.filter((it) => !it.present), [items]);
|
||||||
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items])
|
const presentCount = useMemo(() => items.filter((it) => it.present).length, [items]);
|
||||||
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1)
|
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||||
|
|
||||||
function toggle(key: string) {
|
function toggle(key: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev);
|
||||||
if (n.has(key)) n.delete(key)
|
if (n.has(key)) n.delete(key);
|
||||||
else n.add(key)
|
else n.add(key);
|
||||||
return n
|
return n;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDone(label: string) {
|
function markDone(label: string) {
|
||||||
setItems((prev) => prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)))
|
setItems((prev) => prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importViaSse(toImport: ClothingSetItem[]) {
|
async function importViaSse(toImport: ClothingSetItem[]) {
|
||||||
setBatchProgress({ done: 0, total: toImport.length })
|
setBatchProgress({ done: 0, total: toImport.length });
|
||||||
let done = 0
|
let done = 0;
|
||||||
await runSseImport(
|
await runSseImport(
|
||||||
'/api/admin/import/clothing/sets/batch',
|
"/api/admin/import/clothing/sets/batch",
|
||||||
{
|
{
|
||||||
items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })),
|
items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })),
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
},
|
},
|
||||||
(label) => {
|
(label) => {
|
||||||
done++
|
done++;
|
||||||
markDone(label)
|
markDone(label);
|
||||||
setBatchProgress({ done, total: toImport.length })
|
setBatchProgress({ done, total: toImport.length });
|
||||||
},
|
},
|
||||||
(succeeded, failed) => {
|
(succeeded, failed) => {
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (succeeded > 0) toast.success(`${succeeded} sets imported`)
|
if (succeeded > 0) toast.success(`${succeeded} sets imported`);
|
||||||
if (failed > 0) toast.error(`${failed} failed`)
|
if (failed > 0) toast.error(`${failed} failed`);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importOne(it: ClothingSetItem) {
|
async function importOne(it: ClothingSetItem) {
|
||||||
const key = itemKey(it)
|
const key = itemKey(it);
|
||||||
setBusyKey(key)
|
setBusyKey(key);
|
||||||
try {
|
try {
|
||||||
await importViaSse([it])
|
await importViaSse([it]);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importSelected() {
|
async function importSelected() {
|
||||||
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present)
|
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present);
|
||||||
if (toImport.length === 0) return
|
if (toImport.length === 0) return;
|
||||||
await importViaSse(toImport)
|
await importViaSse(toImport);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -611,12 +592,12 @@ function SetsView() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') doSearch()
|
if (e.key === "Enter") doSearch();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems('', 1)
|
fetchItems("", 1);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8"
|
||||||
@@ -625,10 +606,10 @@ function SetsView() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
setActiveSearch('')
|
setActiveSearch("");
|
||||||
setPage(1)
|
setPage(1);
|
||||||
fetchItems('', 1)
|
fetchItems("", 1);
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -650,15 +631,11 @@ function SetsView() {
|
|||||||
disabled={!!batchProgress}
|
disabled={!!batchProgress}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelected((prev) =>
|
setSelected((prev) =>
|
||||||
prev.size === importable.length
|
prev.size === importable.length ? new Set() : new Set(importable.map((it) => itemKey(it))),
|
||||||
? new Set()
|
|
||||||
: new Set(importable.map((it) => itemKey(it))),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selected.size === importable.length
|
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
|
||||||
? 'Deselect page'
|
|
||||||
: `Select page (${importable.length})`}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||||
@@ -688,27 +665,22 @@ function SetsView() {
|
|||||||
{!loading && items.length === 0 && !error && (
|
{!loading && items.length === 0 && !error && (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p className="text-sm">No sets found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
|
<p className="text-sm">No sets found{activeSearch ? ` for "${activeSearch}"` : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div
|
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
|
||||||
loading && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{items.map((it) => {
|
{items.map((it) => {
|
||||||
const key = itemKey(it)
|
const key = itemKey(it);
|
||||||
const isSelected = selected.has(key)
|
const isSelected = selected.has(key);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||||
it.present && 'ring-1 ring-[var(--admin-success)]/30',
|
it.present && "ring-1 ring-[var(--admin-success)]/30",
|
||||||
isSelected && 'ring-2 ring-primary border-primary',
|
isSelected && "ring-2 ring-primary border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!it.present && (
|
{!it.present && (
|
||||||
@@ -716,10 +688,10 @@ function SetsView() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(key)}
|
onClick={() => toggle(key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="w-3 h-3" />}
|
{isSelected && <Check className="w-3 h-3" />}
|
||||||
@@ -772,7 +744,7 @@ function SetsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -787,7 +759,7 @@ function SetsView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page <= 1 || loading}
|
disabled={page <= 1 || loading}
|
||||||
onClick={() => goPage('prev')}
|
onClick={() => goPage("prev")}
|
||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -795,7 +767,7 @@ function SetsView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page >= lastPage || loading}
|
disabled={page >= lastPage || loading}
|
||||||
onClick={() => goPage('next')}
|
onClick={() => goPage("next")}
|
||||||
>
|
>
|
||||||
Next →
|
Next →
|
||||||
</Button>
|
</Button>
|
||||||
@@ -803,36 +775,36 @@ function SetsView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Root component with mode toggle ──────────────────────────────────────────
|
// ── Root component with mode toggle ──────────────────────────────────────────
|
||||||
|
|
||||||
export function ImportClothingClient() {
|
export function ImportClothingClient() {
|
||||||
const [mode, setMode] = useState<Mode>('libs')
|
const [mode, setMode] = useState<Mode>("libs");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-1 rounded-lg border p-1 w-fit">
|
<div className="flex gap-1 rounded-lg border p-1 w-fit">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={mode === 'libs' ? 'default' : 'ghost'}
|
variant={mode === "libs" ? "default" : "ghost"}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setMode('libs')}
|
onClick={() => setMode("libs")}
|
||||||
>
|
>
|
||||||
Librerie
|
Librerie
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={mode === 'sets' ? 'default' : 'ghost'}
|
variant={mode === "sets" ? "default" : "ghost"}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setMode('sets')}
|
onClick={() => setMode("sets")}
|
||||||
>
|
>
|
||||||
Vestiti (set)
|
Vestiti (set)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'libs' ? <LibsView /> : <SetsView />}
|
{mode === "libs" ? <LibsView /> : <SetsView />}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,5 @@ export default function Loading() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { ImportClothingClient } from './import-clothing-client'
|
import { ImportClothingClient } from "./import-clothing-client";
|
||||||
|
|
||||||
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) {
|
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
return <ImportClothingClient />
|
return <ImportClothingClient />;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
|
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
|
||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -12,165 +12,160 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface EffectItem {
|
interface EffectItem {
|
||||||
id: string
|
id: string;
|
||||||
lib: string
|
lib: string;
|
||||||
type: string
|
type: string;
|
||||||
revision: number
|
revision: number;
|
||||||
present: boolean
|
present: boolean;
|
||||||
nitroExists: boolean
|
nitroExists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filter = 'all' | 'fx' | 'dance'
|
type Filter = "all" | "fx" | "dance";
|
||||||
|
|
||||||
export function ImportEffectsClient() {
|
export function ImportEffectsClient() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [effects, setEffects] = useState<EffectItem[]>([])
|
const [effects, setEffects] = useState<EffectItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
const [busyLib, setBusyLib] = useState<string | null>(null)
|
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchEffects = useCallback(async (search: string) => {
|
const fetchEffects = useCallback(async (search: string) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError('')
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams();
|
||||||
if (search) params.set('search', search)
|
if (search) params.set("search", search);
|
||||||
const res = await fetch(`/api/admin/import/effects?${params}`)
|
const res = await fetch(`/api/admin/import/effects?${params}`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Failed to load')
|
setError(data.error || "Failed to load");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setEffects(data.effects || [])
|
setEffects(data.effects || []);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEffects('')
|
fetchEffects("");
|
||||||
}, [fetchEffects])
|
}, [fetchEffects]);
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() => effects.filter((e) => filter === 'all' || e.type === filter),
|
() => effects.filter((e) => filter === "all" || e.type === filter),
|
||||||
[effects, filter],
|
[effects, filter],
|
||||||
)
|
);
|
||||||
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible])
|
const importable = useMemo(() => visible.filter((e) => !e.present || !e.nitroExists), [visible]);
|
||||||
const importedCount = useMemo(
|
const importedCount = useMemo(() => visible.filter((e) => e.present && e.nitroExists).length, [visible]);
|
||||||
() => visible.filter((e) => e.present && e.nitroExists).length,
|
|
||||||
[visible],
|
|
||||||
)
|
|
||||||
|
|
||||||
function toggle(lib: string) {
|
function toggle(lib: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev);
|
||||||
if (n.has(lib)) n.delete(lib)
|
if (n.has(lib)) n.delete(lib);
|
||||||
else n.add(lib)
|
else n.add(lib);
|
||||||
return n
|
return n;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDone(lib: string) {
|
function markDone(lib: string) {
|
||||||
setEffects((prev) =>
|
setEffects((prev) => prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)));
|
||||||
prev.map((e) => (e.lib === lib ? { ...e, present: true, nitroExists: true } : e)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importViaSse(items: EffectItem[]) {
|
async function importViaSse(items: EffectItem[]) {
|
||||||
setBatchProgress({ done: 0, total: items.length })
|
setBatchProgress({ done: 0, total: items.length });
|
||||||
const res = await fetch('/api/admin/import/effects/batch', {
|
const res = await fetch("/api/admin/import/effects/batch", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items, concurrency: 3 }),
|
body: JSON.stringify({ items, concurrency: 3 }),
|
||||||
})
|
});
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
toast.error('No response stream')
|
toast.error("No response stream");
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
let buf = ''
|
let buf = "";
|
||||||
let done = 0
|
let done = 0;
|
||||||
let succeeded = 0
|
let succeeded = 0;
|
||||||
let failed = 0
|
let failed = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done: streamDone } = await reader.read()
|
const { value, done: streamDone } = await reader.read();
|
||||||
if (streamDone) break
|
if (streamDone) break;
|
||||||
buf += decoder.decode(value, { stream: true })
|
buf += decoder.decode(value, { stream: true });
|
||||||
const parts = buf.split('\n\n')
|
const parts = buf.split("\n\n");
|
||||||
buf = parts.pop() ?? ''
|
buf = parts.pop() ?? "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (!part.startsWith('data: ')) continue
|
if (!part.startsWith("data: ")) continue;
|
||||||
const evt = JSON.parse(part.slice(6))
|
const evt = JSON.parse(part.slice(6));
|
||||||
if (evt.type === 'item_progress' && evt.status === 'done') {
|
if (evt.type === "item_progress" && evt.status === "done") {
|
||||||
done++
|
done++;
|
||||||
succeeded++
|
succeeded++;
|
||||||
markDone(evt.classname)
|
markDone(evt.classname);
|
||||||
setBatchProgress({ done, total: items.length })
|
setBatchProgress({ done, total: items.length });
|
||||||
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
|
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||||
done++
|
done++;
|
||||||
failed++
|
failed++;
|
||||||
setBatchProgress({ done, total: items.length })
|
setBatchProgress({ done, total: items.length });
|
||||||
} else if (evt.type === 'batch_complete') {
|
} else if (evt.type === "batch_complete") {
|
||||||
succeeded = evt.succeeded
|
succeeded = evt.succeeded;
|
||||||
failed = evt.failed
|
failed = evt.failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`)
|
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
|
||||||
if (failed > 0) toast.error(`${failed} failed`)
|
if (failed > 0) toast.error(`${failed} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importOne(e: EffectItem) {
|
async function importOne(e: EffectItem) {
|
||||||
setBusyLib(e.lib)
|
setBusyLib(e.lib);
|
||||||
try {
|
try {
|
||||||
await importViaSse([e])
|
await importViaSse([e]);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyLib(null)
|
setBusyLib(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importSelected() {
|
async function importSelected() {
|
||||||
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists))
|
const items = visible.filter((e) => selected.has(e.lib) && (!e.present || !e.nitroExists));
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return;
|
||||||
await importViaSse(items)
|
await importViaSse(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(e: EffectItem) {
|
async function remove(e: EffectItem) {
|
||||||
setBusyLib(e.lib)
|
setBusyLib(e.lib);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, {
|
const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success(`${e.lib} removed`)
|
toast.success(`${e.lib} removed`);
|
||||||
setEffects((prev) =>
|
setEffects((prev) =>
|
||||||
prev.map((x) => (x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x)),
|
prev.map((x) => (x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x)),
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Delete failed')
|
toast.error(data.error || "Delete failed");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusyLib(null)
|
setBusyLib(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +201,10 @@ export function ImportEffectsClient() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') fetchEffects(searchTerm)
|
if (e.key === "Enter") fetchEffects(searchTerm);
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
fetchEffects('')
|
fetchEffects("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8"
|
||||||
@@ -218,8 +213,8 @@ export function ImportEffectsClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
fetchEffects('')
|
fetchEffects("");
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -230,11 +225,11 @@ export function ImportEffectsClient() {
|
|||||||
<Button onClick={() => fetchEffects(searchTerm)} size="sm" disabled={loading}>
|
<Button onClick={() => fetchEffects(searchTerm)} size="sm" disabled={loading}>
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
{(['all', 'fx', 'dance'] as Filter[]).map((f) => (
|
{(["all", "fx", "dance"] as Filter[]).map((f) => (
|
||||||
<Button
|
<Button
|
||||||
key={f}
|
key={f}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={filter === f ? 'default' : 'outline'}
|
variant={filter === f ? "default" : "outline"}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
>
|
>
|
||||||
{f.toUpperCase()}
|
{f.toUpperCase()}
|
||||||
@@ -256,9 +251,7 @@ export function ImportEffectsClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selected.size === importable.length
|
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
|
||||||
? 'Deselect All'
|
|
||||||
: `Select All (${importable.length})`}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||||
@@ -296,22 +289,17 @@ export function ImportEffectsClient() {
|
|||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{visible.length > 0 && (
|
{visible.length > 0 && (
|
||||||
<div
|
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
|
||||||
loading && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{visible.map((e) => {
|
{visible.map((e) => {
|
||||||
const imported = e.present && e.nitroExists
|
const imported = e.present && e.nitroExists;
|
||||||
const isSelected = selected.has(e.lib)
|
const isSelected = selected.has(e.lib);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={e.lib}
|
key={e.lib}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||||
imported && 'ring-1 ring-[var(--admin-success)]/30',
|
imported && "ring-1 ring-[var(--admin-success)]/30",
|
||||||
isSelected && 'ring-2 ring-primary border-primary',
|
isSelected && "ring-2 ring-primary border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!imported && (
|
{!imported && (
|
||||||
@@ -319,10 +307,10 @@ export function ImportEffectsClient() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(e.lib)}
|
onClick={() => toggle(e.lib)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="w-3 h-3" />}
|
{isSelected && <Check className="w-3 h-3" />}
|
||||||
@@ -330,13 +318,13 @@ export function ImportEffectsClient() {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center justify-center h-20 gap-1',
|
"flex flex-col items-center justify-center h-20 gap-1",
|
||||||
e.type === 'dance'
|
e.type === "dance"
|
||||||
? 'bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10'
|
? "bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10"
|
||||||
: 'bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10',
|
: "bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{e.type === 'dance' ? (
|
{e.type === "dance" ? (
|
||||||
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
|
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
|
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
|
||||||
@@ -385,10 +373,10 @@ export function ImportEffectsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -10,5 +10,5 @@ export default function Loading() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// src/app/[locale]/admin/import/effects/page.tsx
|
// src/app/[locale]/admin/import/effects/page.tsx
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { ImportEffectsClient } from './import-effects-client'
|
import { ImportEffectsClient } from "./import-effects-client";
|
||||||
|
|
||||||
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) {
|
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
return <ImportEffectsClient />
|
return <ImportEffectsClient />;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function ImportFurniLoading() {
|
export default function ImportFurniLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -17,5 +17,5 @@ export default function ImportFurniLoading() {
|
|||||||
<Skeleton className="h-10 w-24 mt-4" />
|
<Skeleton className="h-10 w-24 mt-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -14,71 +14,65 @@ import {
|
|||||||
Ruler,
|
Ruler,
|
||||||
Save,
|
Save,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
import { Input } from "@/components/ui/input";
|
||||||
DialogContent,
|
import { Label } from "@/components/ui/label";
|
||||||
DialogFooter,
|
import { Switch } from "@/components/ui/switch";
|
||||||
DialogHeader,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
DialogTitle,
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
|
|
||||||
interface NitroEditorDialogProps {
|
interface NitroEditorDialogProps {
|
||||||
classname: string
|
classname: string;
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||||
function updateNested(obj: any, path: string, value: unknown): any {
|
function updateNested(obj: any, path: string, value: unknown): any {
|
||||||
const clone = JSON.parse(JSON.stringify(obj))
|
const clone = JSON.parse(JSON.stringify(obj));
|
||||||
const keys = path.split('.')
|
const keys = path.split(".");
|
||||||
let current = clone
|
let current = clone;
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
if (!current[keys[i]]) current[keys[i]] = {}
|
if (!current[keys[i]]) current[keys[i]] = {};
|
||||||
current = current[keys[i]]
|
current = current[keys[i]];
|
||||||
}
|
}
|
||||||
current[keys[keys.length - 1]] = value
|
current[keys[keys.length - 1]] = value;
|
||||||
return clone
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||||
function getNested(obj: any, path: string, fallback?: unknown): any {
|
function getNested(obj: any, path: string, fallback?: unknown): any {
|
||||||
const keys = path.split('.')
|
const keys = path.split(".");
|
||||||
let current = obj
|
let current = obj;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (current == null || typeof current !== 'object') return fallback
|
if (current == null || typeof current !== "object") return fallback;
|
||||||
current = current[key]
|
current = current[key];
|
||||||
}
|
}
|
||||||
return current ?? fallback
|
return current ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7]
|
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7];
|
||||||
const DIR_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
|
||||||
function hexToColor(hex: string): string {
|
function hexToColor(hex: string): string {
|
||||||
return `#${hex.padStart(6, '0')}`
|
return `#${hex.padStart(6, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyClassname({ value }: { value: string }) {
|
function CopyClassname({ value }: { value: string }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
|
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(value)
|
navigator.clipboard.writeText(value);
|
||||||
setCopied(true)
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500)
|
setTimeout(() => setCopied(false), 1500);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{value}</span>
|
<span className="truncate">{value}</span>
|
||||||
@@ -88,178 +82,177 @@ function CopyClassname({ value }: { value: string }) {
|
|||||||
<Copy className="w-3 h-3 shrink-0" />
|
<Copy className="w-3 h-3 shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
|
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||||
const [metadata, setMetadata] = useState<any>(null)
|
const [metadata, setMetadata] = useState<any>(null);
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||||
const [originalMetadata, setOriginalMetadata] = useState<any>(null)
|
const [originalMetadata, setOriginalMetadata] = useState<any>(null);
|
||||||
const [jsonText, setJsonText] = useState('')
|
const [jsonText, setJsonText] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<string>('visual')
|
const [activeTab, setActiveTab] = useState<string>("visual");
|
||||||
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false })
|
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false });
|
||||||
const [originalFlags, setOriginalFlags] = useState({
|
const [originalFlags, setOriginalFlags] = useState({
|
||||||
canstandon: false,
|
canstandon: false,
|
||||||
cansiton: false,
|
cansiton: false,
|
||||||
canlayon: false,
|
canlayon: false,
|
||||||
})
|
});
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null)
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchMetadata = useCallback(async () => {
|
const fetchMetadata = useCallback(async () => {
|
||||||
if (!classname) return
|
if (!classname) return;
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setJsonError(null)
|
setJsonError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
|
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
|
||||||
)
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}));
|
||||||
toast.error(err.error || 'Failed to load .nitro metadata')
|
toast.error(err.error || "Failed to load .nitro metadata");
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setMetadata(data.metadata)
|
setMetadata(data.metadata);
|
||||||
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)))
|
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)));
|
||||||
setJsonText(JSON.stringify(data.metadata, null, 2))
|
setJsonText(JSON.stringify(data.metadata, null, 2));
|
||||||
setFlags(data.flags)
|
setFlags(data.flags);
|
||||||
setOriginalFlags({ ...data.flags })
|
setOriginalFlags({ ...data.flags });
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Network error loading .nitro metadata')
|
toast.error("Network error loading .nitro metadata");
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [classname, onOpenChange])
|
}, [classname, onOpenChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && classname) {
|
if (open && classname) {
|
||||||
setActiveTab('visual')
|
setActiveTab("visual");
|
||||||
fetchMetadata()
|
fetchMetadata();
|
||||||
}
|
}
|
||||||
}, [open, classname, fetchMetadata])
|
}, [open, classname, fetchMetadata]);
|
||||||
|
|
||||||
const handleTabChange = (newTab: string) => {
|
const handleTabChange = (newTab: string) => {
|
||||||
if (activeTab === 'json' && newTab === 'visual') {
|
if (activeTab === "json" && newTab === "visual") {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(jsonText)
|
const parsed = JSON.parse(jsonText);
|
||||||
setMetadata(parsed)
|
setMetadata(parsed);
|
||||||
setJsonError(null)
|
setJsonError(null);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Invalid JSON — fix it before switching to Visual tab')
|
toast.error("Invalid JSON — fix it before switching to Visual tab");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} else if (activeTab === 'visual' && newTab === 'json') {
|
} else if (activeTab === "visual" && newTab === "json") {
|
||||||
setJsonText(JSON.stringify(metadata, null, 2))
|
setJsonText(JSON.stringify(metadata, null, 2));
|
||||||
}
|
}
|
||||||
setActiveTab(newTab)
|
setActiveTab(newTab);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (!originalMetadata) return
|
if (!originalMetadata) return;
|
||||||
setMetadata(JSON.parse(JSON.stringify(originalMetadata)))
|
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
|
||||||
setFlags({ ...originalFlags })
|
setFlags({ ...originalFlags });
|
||||||
setJsonText(JSON.stringify(originalMetadata, null, 2))
|
setJsonText(JSON.stringify(originalMetadata, null, 2));
|
||||||
toast.info('Reset to original values')
|
toast.info("Reset to original values");
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
let metadataToSave = metadata
|
let metadataToSave = metadata;
|
||||||
|
|
||||||
if (activeTab === 'json') {
|
if (activeTab === "json") {
|
||||||
try {
|
try {
|
||||||
metadataToSave = JSON.parse(jsonText)
|
metadataToSave = JSON.parse(jsonText);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Invalid JSON')
|
toast.error("Invalid JSON");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/import/furni/nitro-editor', {
|
const res = await fetch("/api/admin/import/furni/nitro-editor", {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
classname,
|
classname,
|
||||||
metadata: metadataToSave,
|
metadata: metadataToSave,
|
||||||
...flags,
|
...flags,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}));
|
||||||
toast.error(err.error || 'Save failed')
|
toast.error(err.error || "Save failed");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Nitro metadata salvato')
|
toast.success("Nitro metadata salvato");
|
||||||
setMetadata(metadataToSave)
|
setMetadata(metadataToSave);
|
||||||
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)))
|
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)));
|
||||||
setOriginalFlags({ ...flags })
|
setOriginalFlags({ ...flags });
|
||||||
setJsonText(JSON.stringify(metadataToSave, null, 2))
|
setJsonText(JSON.stringify(metadataToSave, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Network error')
|
toast.error("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// ── Visual tab helpers ──
|
// ── Visual tab helpers ──
|
||||||
|
|
||||||
const dims = metadata
|
const dims = metadata
|
||||||
? getNested(metadata, 'logic.model.dimensions', { x: 1, y: 1, z: 0 })
|
? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 })
|
||||||
: { x: 1, y: 1, z: 0 }
|
: { x: 1, y: 1, z: 0 };
|
||||||
const directions: number[] = metadata ? getNested(metadata, 'logic.model.directions', [0]) : [0]
|
const directions: number[] = metadata ? getNested(metadata, "logic.model.directions", [0]) : [0];
|
||||||
|
|
||||||
const setDim = (axis: 'x' | 'y' | 'z', value: string) => {
|
const setDim = (axis: "x" | "y" | "z", value: string) => {
|
||||||
const num = axis === 'z' ? parseFloat(value) || 0 : parseInt(value, 10) || 0
|
const num = axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0;
|
||||||
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num))
|
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num));
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleDirection = (dir: number) => {
|
const toggleDirection = (dir: number) => {
|
||||||
const current: number[] = getNested(metadata, 'logic.model.directions', [0])
|
const current: number[] = getNested(metadata, "logic.model.directions", [0]);
|
||||||
const next = current.includes(dir)
|
const next = current.includes(dir)
|
||||||
? current.filter((d: number) => d !== dir)
|
? current.filter((d: number) => d !== dir)
|
||||||
: [...current, dir].sort((a: number, b: number) => a - b)
|
: [...current, dir].sort((a: number, b: number) => a - b);
|
||||||
if (next.length === 0) return
|
if (next.length === 0) return;
|
||||||
setMetadata((prev: unknown) => updateNested(prev, 'logic.model.directions', next))
|
setMetadata((prev: unknown) => updateNested(prev, "logic.model.directions", next));
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get visualization with size=64 for layer/color editing
|
// Get visualization with size=64 for layer/color editing
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||||
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64)
|
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64);
|
||||||
const viz64Index =
|
const viz64Index = metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1;
|
||||||
metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1
|
|
||||||
|
|
||||||
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0
|
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0;
|
||||||
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0
|
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0;
|
||||||
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0
|
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0;
|
||||||
|
|
||||||
const handleJsonTextChange = (value: string) => {
|
const handleJsonTextChange = (value: string) => {
|
||||||
setJsonText(value)
|
setJsonText(value);
|
||||||
try {
|
try {
|
||||||
JSON.parse(value)
|
JSON.parse(value);
|
||||||
setJsonError(null)
|
setJsonError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setJsonError((e as Error).message)
|
setJsonError((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Check if there are unsaved changes
|
// Check if there are unsaved changes
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
metadata &&
|
metadata &&
|
||||||
originalMetadata &&
|
originalMetadata &&
|
||||||
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
|
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
|
||||||
JSON.stringify(flags) !== JSON.stringify(originalFlags))
|
JSON.stringify(flags) !== JSON.stringify(originalFlags));
|
||||||
|
|
||||||
// Furni preview URL
|
// Furni preview URL
|
||||||
const starIdx = classname.indexOf('*')
|
const starIdx = classname.indexOf("*");
|
||||||
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
|
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -271,18 +264,18 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<div
|
<div
|
||||||
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
|
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: 'url(/images/furni_floor.png)',
|
backgroundImage: "url(/images/furni_floor.png)",
|
||||||
backgroundRepeat: 'repeat',
|
backgroundRepeat: "repeat",
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
|
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
|
||||||
alt={classname}
|
alt={classname}
|
||||||
className="max-w-[60px] max-h-[60px] drop-shadow-md"
|
className="max-w-[60px] max-h-[60px] drop-shadow-md"
|
||||||
style={{ imageRendering: 'pixelated' }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
;(e.target as HTMLImageElement).style.opacity = '0'
|
(e.target as HTMLImageElement).style.opacity = "0";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,7 +311,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
)}
|
)}
|
||||||
<Badge variant="outline" className="text-[10px] gap-1">
|
<Badge variant="outline" className="text-[10px] gap-1">
|
||||||
<Eye className="w-2.5 h-2.5" />
|
<Eye className="w-2.5 h-2.5" />
|
||||||
{metadata.visualizationType || '?'}
|
{metadata.visualizationType || "?"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -331,11 +324,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : metadata ? (
|
) : metadata ? (
|
||||||
<Tabs
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
|
||||||
value={activeTab}
|
|
||||||
onValueChange={handleTabChange}
|
|
||||||
className="flex-1 flex flex-col min-h-0"
|
|
||||||
>
|
|
||||||
<div className="px-6 pt-3 pb-0">
|
<div className="px-6 pt-3 pb-0">
|
||||||
<TabsList className="w-fit">
|
<TabsList className="w-fit">
|
||||||
<TabsTrigger value="visual" className="gap-1.5">
|
<TabsTrigger value="visual" className="gap-1.5">
|
||||||
@@ -343,7 +332,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
Visual
|
Visual
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="json" className="gap-1.5">
|
<TabsTrigger value="json" className="gap-1.5">
|
||||||
{'{ }'}
|
{"{ }"}
|
||||||
JSON
|
JSON
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -369,7 +358,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={dims.x}
|
value={dims.x}
|
||||||
onChange={(e) => setDim('x', e.target.value)}
|
onChange={(e) => setDim("x", e.target.value)}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +371,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={dims.y}
|
value={dims.y}
|
||||||
onChange={(e) => setDim('y', e.target.value)}
|
onChange={(e) => setDim("y", e.target.value)}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +385,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
step={0.01}
|
step={0.01}
|
||||||
min={0}
|
min={0}
|
||||||
value={dims.z}
|
value={dims.z}
|
||||||
onChange={(e) => setDim('z', e.target.value)}
|
onChange={(e) => setDim("z", e.target.value)}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,9 +429,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Compass className="w-4 h-4 text-muted-foreground" />
|
<Compass className="w-4 h-4 text-muted-foreground" />
|
||||||
<h3 className="text-sm font-medium">Direzioni</h3>
|
<h3 className="text-sm font-medium">Direzioni</h3>
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
<span className="text-xs text-muted-foreground ml-auto">{directions.length} attive</span>
|
||||||
{directions.length} attive
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="relative w-48 h-48">
|
<div className="relative w-48 h-48">
|
||||||
@@ -451,19 +438,19 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<div className="absolute inset-4 rounded-full border border-muted/50" />
|
<div className="absolute inset-4 rounded-full border border-muted/50" />
|
||||||
{/* Direction buttons arranged in circle */}
|
{/* Direction buttons arranged in circle */}
|
||||||
{ALL_DIRECTIONS.map((dir) => {
|
{ALL_DIRECTIONS.map((dir) => {
|
||||||
const angle = (dir * 45 - 90) * (Math.PI / 180)
|
const angle = (dir * 45 - 90) * (Math.PI / 180);
|
||||||
const r = 80
|
const r = 80;
|
||||||
const x = 96 + r * Math.cos(angle) - 18
|
const x = 96 + r * Math.cos(angle) - 18;
|
||||||
const y = 96 + r * Math.sin(angle) - 18
|
const y = 96 + r * Math.sin(angle) - 18;
|
||||||
const isActive = directions.includes(dir)
|
const isActive = directions.includes(dir);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={dir}
|
key={dir}
|
||||||
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
|
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary text-primary-foreground border-primary shadow-md scale-110'
|
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
|
||||||
: 'bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105'
|
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
|
||||||
}`}
|
}`}
|
||||||
style={{ left: x, top: y }}
|
style={{ left: x, top: y }}
|
||||||
onClick={() => toggleDirection(dir)}
|
onClick={() => toggleDirection(dir)}
|
||||||
@@ -471,13 +458,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<span className="text-[9px] leading-none">{DIR_LABELS[dir]}</span>
|
<span className="text-[9px] leading-none">{DIR_LABELS[dir]}</span>
|
||||||
<span className="text-[8px] leading-none opacity-60">{dir}</span>
|
<span className="text-[8px] leading-none opacity-60">{dir}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
{/* Center label */}
|
{/* Center label */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-xs text-muted-foreground font-medium">
|
<span className="text-xs text-muted-foreground font-medium">{directions.length}/8</span>
|
||||||
{directions.length}/8
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,10 +481,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="viz-type"
|
id="viz-type"
|
||||||
value={metadata.visualizationType || ''}
|
value={metadata.visualizationType || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setMetadata((prev: unknown) =>
|
setMetadata((prev: unknown) =>
|
||||||
updateNested(prev, 'visualizationType', e.target.value),
|
updateNested(prev, "visualizationType", e.target.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="mt-1 font-mono text-sm"
|
className="mt-1 font-mono text-sm"
|
||||||
@@ -511,11 +496,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="logic-type"
|
id="logic-type"
|
||||||
value={metadata.logicType || ''}
|
value={metadata.logicType || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setMetadata((prev: unknown) =>
|
setMetadata((prev: unknown) => updateNested(prev, "logicType", e.target.value))
|
||||||
updateNested(prev, 'logicType', e.target.value),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="mt-1 font-mono text-sm"
|
className="mt-1 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -542,12 +525,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<span>Ink</span>
|
<span>Ink</span>
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
|
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
|
||||||
const layer = layerData as Record<string, unknown>
|
const layer = layerData as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={layerId}
|
key={layerId}
|
||||||
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
|
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
|
||||||
idx % 2 === 0 ? 'bg-card' : 'bg-muted/20'
|
idx % 2 === 0 ? "bg-card" : "bg-muted/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs">
|
<span className="font-mono text-xs">
|
||||||
@@ -566,7 +549,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
`visualizations.${viz64Index}.layers.${layerId}.z`,
|
`visualizations.${viz64Index}.layers.${layerId}.z`,
|
||||||
parseInt(e.target.value, 10) || 0,
|
parseInt(e.target.value, 10) || 0,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -582,12 +565,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
|
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
|
||||||
parseInt(e.target.value, 10) || 0,
|
parseInt(e.target.value, 10) || 0,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
className="h-7 text-xs font-mono"
|
className="h-7 text-xs font-mono"
|
||||||
value={(layer.ink as string) ?? ''}
|
value={(layer.ink as string) ?? ""}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setMetadata((prev: unknown) =>
|
setMetadata((prev: unknown) =>
|
||||||
@@ -596,11 +579,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
`visualizations.${viz64Index}.layers.${layerId}.ink`,
|
`visualizations.${viz64Index}.layers.${layerId}.ink`,
|
||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -623,22 +606,20 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<span className="text-xs font-medium">Variante #{colorId}</span>
|
<span className="text-xs font-medium">Variante #{colorId}</span>
|
||||||
{/* Color preview dots */}
|
{/* Color preview dots */}
|
||||||
<div className="flex gap-1 ml-auto">
|
<div className="flex gap-1 ml-auto">
|
||||||
{Object.values(colorLayers as Record<string, { color: number }>).map(
|
{Object.values(colorLayers as Record<string, { color: number }>).map((lc, i) => (
|
||||||
(lc, i) => (
|
<div
|
||||||
<div
|
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
key={i}
|
||||||
key={i}
|
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
style={{
|
||||||
style={{
|
backgroundColor: hexToColor(
|
||||||
backgroundColor: hexToColor(
|
typeof lc.color === "number"
|
||||||
typeof lc.color === 'number'
|
? lc.color.toString(16).padStart(6, "0")
|
||||||
? lc.color.toString(16).padStart(6, '0')
|
: "000000",
|
||||||
: '000000',
|
),
|
||||||
),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
))}
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
@@ -652,25 +633,20 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<Input
|
<Input
|
||||||
className="h-7 text-xs font-mono pl-8"
|
className="h-7 text-xs font-mono pl-8"
|
||||||
value={
|
value={
|
||||||
typeof layerColor.color === 'number'
|
typeof layerColor.color === "number"
|
||||||
? layerColor.color
|
? layerColor.color.toString(16).padStart(6, "0").toUpperCase()
|
||||||
.toString(16)
|
|
||||||
.padStart(6, '0')
|
|
||||||
.toUpperCase()
|
|
||||||
: String(layerColor.color)
|
: String(layerColor.color)
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const hex = e.target.value
|
const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
|
||||||
.replace(/[^0-9a-fA-F]/g, '')
|
const num = parseInt(hex, 16) || 0;
|
||||||
.slice(0, 6)
|
|
||||||
const num = parseInt(hex, 16) || 0
|
|
||||||
setMetadata((prev: unknown) =>
|
setMetadata((prev: unknown) =>
|
||||||
updateNested(
|
updateNested(
|
||||||
prev,
|
prev,
|
||||||
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
|
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
|
||||||
num,
|
num,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Color swatch inside input */}
|
{/* Color swatch inside input */}
|
||||||
@@ -678,9 +654,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
|
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hexToColor(
|
backgroundColor: hexToColor(
|
||||||
typeof layerColor.color === 'number'
|
typeof layerColor.color === "number"
|
||||||
? layerColor.color.toString(16).padStart(6, '0')
|
? layerColor.color.toString(16).padStart(6, "0")
|
||||||
: '000000',
|
: "000000",
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -718,10 +694,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
|
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Badge
|
<Badge variant="secondary" className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]">
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
|
|
||||||
>
|
|
||||||
Modifiche non salvate
|
Modifiche non salvate
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -739,11 +712,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
Annulla
|
Annulla
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSave} disabled={saving || loading || !metadata} className="gap-1.5">
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || loading || !metadata}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
Salva
|
Salva
|
||||||
</Button>
|
</Button>
|
||||||
@@ -751,5 +720,5 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { ImportFurniClient } from './import-furni-client'
|
import { ImportFurniClient } from "./import-furni-client";
|
||||||
|
|
||||||
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
|
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ImportFurniClient />
|
return <ImportFurniClient />;
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,22 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import { AlertCircle, Check, Download, Loader2, Package, PawPrint, Search, Trash2, X } from "lucide-react";
|
||||||
AlertCircle,
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
Check,
|
import { toast } from "sonner";
|
||||||
Download,
|
import { Badge } from "@/components/ui/badge";
|
||||||
Loader2,
|
import { Button } from "@/components/ui/button";
|
||||||
Package,
|
import { Input } from "@/components/ui/input";
|
||||||
PawPrint,
|
import { cn } from "@/lib/utils";
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface PetItem {
|
interface PetItem {
|
||||||
lib: string
|
lib: string;
|
||||||
nitroExists: boolean
|
nitroExists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
|
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
|
||||||
function PetIcon({ lib }: { lib: string }) {
|
function PetIcon({ lib }: { lib: string }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false);
|
||||||
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />
|
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
|
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
|
||||||
<img
|
<img
|
||||||
@@ -36,135 +26,135 @@ function PetIcon({ lib }: { lib: string }) {
|
|||||||
style={{ maxHeight: 56, maxWidth: 56 }}
|
style={{ maxHeight: 56, maxWidth: 56 }}
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportPetsClient() {
|
export function ImportPetsClient() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [pets, setPets] = useState<PetItem[]>([])
|
const [pets, setPets] = useState<PetItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
const [busyLib, setBusyLib] = useState<string | null>(null)
|
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchPets = useCallback(async (search: string) => {
|
const fetchPets = useCallback(async (search: string) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError('')
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams();
|
||||||
if (search) params.set('search', search)
|
if (search) params.set("search", search);
|
||||||
const res = await fetch(`/api/admin/import/pets?${params}`)
|
const res = await fetch(`/api/admin/import/pets?${params}`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Failed to load')
|
setError(data.error || "Failed to load");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setPets(data.pets || [])
|
setPets(data.pets || []);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPets('')
|
fetchPets("");
|
||||||
}, [fetchPets])
|
}, [fetchPets]);
|
||||||
|
|
||||||
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets])
|
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
|
||||||
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets])
|
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]);
|
||||||
|
|
||||||
function toggle(lib: string) {
|
function toggle(lib: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev);
|
||||||
if (n.has(lib)) n.delete(lib)
|
if (n.has(lib)) n.delete(lib);
|
||||||
else n.add(lib)
|
else n.add(lib);
|
||||||
return n
|
return n;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDone(lib: string) {
|
function markDone(lib: string) {
|
||||||
setPets((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)))
|
setPets((prev) => prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importViaSse(items: PetItem[]) {
|
async function importViaSse(items: PetItem[]) {
|
||||||
setBatchProgress({ done: 0, total: items.length })
|
setBatchProgress({ done: 0, total: items.length });
|
||||||
const res = await fetch('/api/admin/import/pets/batch', {
|
const res = await fetch("/api/admin/import/pets/batch", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items, concurrency: 3 }),
|
body: JSON.stringify({ items, concurrency: 3 }),
|
||||||
})
|
});
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
toast.error('No response stream')
|
toast.error("No response stream");
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
let buf = ''
|
let buf = "";
|
||||||
let done = 0
|
let done = 0;
|
||||||
let succeeded = 0
|
let succeeded = 0;
|
||||||
let failed = 0
|
let failed = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done: streamDone } = await reader.read()
|
const { value, done: streamDone } = await reader.read();
|
||||||
if (streamDone) break
|
if (streamDone) break;
|
||||||
buf += decoder.decode(value, { stream: true })
|
buf += decoder.decode(value, { stream: true });
|
||||||
const parts = buf.split('\n\n')
|
const parts = buf.split("\n\n");
|
||||||
buf = parts.pop() ?? ''
|
buf = parts.pop() ?? "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (!part.startsWith('data: ')) continue
|
if (!part.startsWith("data: ")) continue;
|
||||||
const evt = JSON.parse(part.slice(6))
|
const evt = JSON.parse(part.slice(6));
|
||||||
if (evt.type === 'item_progress' && evt.status === 'done') {
|
if (evt.type === "item_progress" && evt.status === "done") {
|
||||||
done++
|
done++;
|
||||||
markDone(evt.classname)
|
markDone(evt.classname);
|
||||||
setBatchProgress({ done, total: items.length })
|
setBatchProgress({ done, total: items.length });
|
||||||
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
|
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||||
done++
|
done++;
|
||||||
setBatchProgress({ done, total: items.length })
|
setBatchProgress({ done, total: items.length });
|
||||||
} else if (evt.type === 'batch_complete') {
|
} else if (evt.type === "batch_complete") {
|
||||||
succeeded = evt.succeeded
|
succeeded = evt.succeeded;
|
||||||
failed = evt.failed
|
failed = evt.failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBatchProgress(null)
|
setBatchProgress(null);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`)
|
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
|
||||||
if (failed > 0) toast.error(`${failed} failed`)
|
if (failed > 0) toast.error(`${failed} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importOne(p: PetItem) {
|
async function importOne(p: PetItem) {
|
||||||
setBusyLib(p.lib)
|
setBusyLib(p.lib);
|
||||||
try {
|
try {
|
||||||
await importViaSse([p])
|
await importViaSse([p]);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyLib(null)
|
setBusyLib(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importSelected() {
|
async function importSelected() {
|
||||||
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists)
|
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return;
|
||||||
await importViaSse(items)
|
await importViaSse(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(p: PetItem) {
|
async function remove(p: PetItem) {
|
||||||
setBusyLib(p.lib)
|
setBusyLib(p.lib);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, {
|
const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success(`${p.lib} removed`)
|
toast.success(`${p.lib} removed`);
|
||||||
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)))
|
setPets((prev) => prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Delete failed')
|
toast.error(data.error || "Delete failed");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusyLib(null)
|
setBusyLib(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,10 +188,10 @@ export function ImportPetsClient() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') fetchPets(searchTerm)
|
if (e.key === "Enter") fetchPets(searchTerm);
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
fetchPets('')
|
fetchPets("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8"
|
||||||
@@ -210,8 +200,8 @@ export function ImportPetsClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchTerm('')
|
setSearchTerm("");
|
||||||
fetchPets('')
|
fetchPets("");
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -237,9 +227,7 @@ export function ImportPetsClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selected.size === importable.length
|
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
|
||||||
? 'Deselect All'
|
|
||||||
: `Select All (${importable.length})`}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
|
||||||
@@ -274,21 +262,16 @@ export function ImportPetsClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{pets.length > 0 && (
|
{pets.length > 0 && (
|
||||||
<div
|
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3',
|
|
||||||
loading && 'opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{pets.map((p) => {
|
{pets.map((p) => {
|
||||||
const isSelected = selected.has(p.lib)
|
const isSelected = selected.has(p.lib);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.lib}
|
key={p.lib}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
|
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
|
||||||
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
|
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
|
||||||
isSelected && 'ring-2 ring-primary border-primary',
|
isSelected && "ring-2 ring-primary border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!p.nitroExists && (
|
{!p.nitroExists && (
|
||||||
@@ -296,10 +279,10 @@ export function ImportPetsClient() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(p.lib)}
|
onClick={() => toggle(p.lib)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
|
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: 'bg-background/80 border-muted-foreground/30 hover:border-muted-foreground',
|
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <Check className="w-3 h-3" />}
|
{isSelected && <Check className="w-3 h-3" />}
|
||||||
@@ -347,10 +330,10 @@ export function ImportPetsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,5 @@ export default function Loading() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { ImportPetsClient } from './import-pets-client'
|
import { ImportPetsClient } from "./import-pets-client";
|
||||||
|
|
||||||
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) {
|
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
return <ImportPetsClient />
|
return <ImportPetsClient />;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { RepairClient } from './repair-client'
|
import { RepairClient } from "./repair-client";
|
||||||
|
|
||||||
export default async function RepairImportedPage() {
|
export default async function RepairImportedPage() {
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RepairClient />
|
return <RepairClient />;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -10,13 +10,13 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Wrench,
|
Wrench,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { deleteImportedFurni } from '@/actions/import-furni'
|
import { deleteImportedFurni } from "@/actions/import-furni";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,245 +24,238 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
|
|
||||||
type MissingKind = 'nitro' | 'icon' | 'both'
|
type MissingKind = "nitro" | "icon" | "both";
|
||||||
|
|
||||||
interface MissingItem {
|
interface MissingItem {
|
||||||
id: number
|
id: number;
|
||||||
classname: string
|
classname: string;
|
||||||
baseClassname: string
|
baseClassname: string;
|
||||||
revision: number
|
revision: number;
|
||||||
type: 's' | 'i'
|
type: "s" | "i";
|
||||||
publicName: string
|
publicName: string;
|
||||||
hasSwf: boolean
|
hasSwf: boolean;
|
||||||
hasIcon: boolean
|
hasIcon: boolean;
|
||||||
hasNitro: boolean
|
hasNitro: boolean;
|
||||||
missing: MissingKind
|
missing: MissingKind;
|
||||||
unrepairable: boolean
|
unrepairable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditSummary {
|
interface AuditSummary {
|
||||||
total: number
|
total: number;
|
||||||
missingBoth: number
|
missingBoth: number;
|
||||||
missingNitro: number
|
missingNitro: number;
|
||||||
missingIcon: number
|
missingIcon: number;
|
||||||
unrepairable: number
|
unrepairable: number;
|
||||||
items: MissingItem[]
|
items: MissingItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemStatus = 'idle' | 'working' | 'done' | 'partial' | 'failed'
|
type ItemStatus = "idle" | "working" | "done" | "partial" | "failed";
|
||||||
|
|
||||||
interface ItemProgress {
|
interface ItemProgress {
|
||||||
status: ItemStatus
|
status: ItemStatus;
|
||||||
message?: string
|
message?: string;
|
||||||
nitroRepaired?: boolean
|
nitroRepaired?: boolean;
|
||||||
iconRepaired?: boolean
|
iconRepaired?: boolean;
|
||||||
unrepairable?: boolean
|
unrepairable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RepairSummary {
|
interface RepairSummary {
|
||||||
succeeded: number
|
succeeded: number;
|
||||||
partial: number
|
partial: number;
|
||||||
failed: number
|
failed: number;
|
||||||
unrepairable: number
|
unrepairable: number;
|
||||||
duration: number
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = {
|
const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = {
|
||||||
all: () => true,
|
all: () => true,
|
||||||
both: (m) => m.missing === 'both',
|
both: (m) => m.missing === "both",
|
||||||
nitro: (m) => m.missing === 'nitro',
|
nitro: (m) => m.missing === "nitro",
|
||||||
icon: (m) => m.missing === 'icon',
|
icon: (m) => m.missing === "icon",
|
||||||
unrepairable: (m) => m.unrepairable,
|
unrepairable: (m) => m.unrepairable,
|
||||||
}
|
};
|
||||||
|
|
||||||
export function RepairClient() {
|
export function RepairClient() {
|
||||||
const [summary, setSummary] = useState<AuditSummary | null>(null)
|
const [summary, setSummary] = useState<AuditSummary | null>(null);
|
||||||
const [auditing, setAuditing] = useState(false)
|
const [auditing, setAuditing] = useState(false);
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false);
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState("");
|
||||||
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>('all')
|
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>("all");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map())
|
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map());
|
||||||
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null)
|
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null);
|
||||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false)
|
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
||||||
|
|
||||||
// ── Audit ──────────────────────────────────────────────────────────
|
// ── Audit ──────────────────────────────────────────────────────────
|
||||||
const runAudit = useCallback(async () => {
|
const runAudit = useCallback(async () => {
|
||||||
setAuditing(true)
|
setAuditing(true);
|
||||||
setLastSummary(null)
|
setLastSummary(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/import/repair/audit', { method: 'POST' })
|
const res = await fetch("/api/admin/import/repair/audit", { method: "POST" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error('Audit failed')
|
toast.error("Audit failed");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setSummary(data)
|
setSummary(data);
|
||||||
setSelected(new Set())
|
setSelected(new Set());
|
||||||
setProgress(new Map())
|
setProgress(new Map());
|
||||||
toast.success(
|
toast.success(
|
||||||
`Audit: ${data.items.length} item${data.items.length === 1 ? '' : 's'} need repair (${data.total} scanned)`,
|
`Audit: ${data.items.length} item${data.items.length === 1 ? "" : "s"} need repair (${data.total} scanned)`,
|
||||||
)
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Audit network error')
|
toast.error("Audit network error");
|
||||||
} finally {
|
} finally {
|
||||||
setAuditing(false)
|
setAuditing(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ── Filters ────────────────────────────────────────────────────────
|
// ── Filters ────────────────────────────────────────────────────────
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!summary) return []
|
if (!summary) return [];
|
||||||
const s = search.trim().toLowerCase()
|
const s = search.trim().toLowerCase();
|
||||||
return summary.items.filter((it) => {
|
return summary.items.filter((it) => {
|
||||||
if (!TAB_FILTERS[tab](it)) return false
|
if (!TAB_FILTERS[tab](it)) return false;
|
||||||
if (!s) return true
|
if (!s) return true;
|
||||||
return (
|
return (
|
||||||
it.classname.toLowerCase().includes(s) ||
|
it.classname.toLowerCase().includes(s) ||
|
||||||
it.publicName.toLowerCase().includes(s) ||
|
it.publicName.toLowerCase().includes(s) ||
|
||||||
String(it.id).includes(s)
|
String(it.id).includes(s)
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
}, [summary, search, tab])
|
}, [summary, search, tab]);
|
||||||
|
|
||||||
// ── Selection ──────────────────────────────────────────────────────
|
// ── Selection ──────────────────────────────────────────────────────
|
||||||
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname))
|
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname));
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
for (const i of filtered) {
|
for (const i of filtered) {
|
||||||
next.delete(i.classname)
|
next.delete(i.classname);
|
||||||
}
|
}
|
||||||
return next
|
return next;
|
||||||
}
|
}
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
for (const i of filtered) {
|
for (const i of filtered) {
|
||||||
next.add(i.classname)
|
next.add(i.classname);
|
||||||
}
|
}
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
const toggleOne = (cn: string) => {
|
const toggleOne = (cn: string) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(cn)) next.delete(cn)
|
if (next.has(cn)) next.delete(cn);
|
||||||
else next.add(cn)
|
else next.add(cn);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// ── Repair ─────────────────────────────────────────────────────────
|
// ── Repair ─────────────────────────────────────────────────────────
|
||||||
const runRepair = useCallback(async (classnames: string[]) => {
|
const runRepair = useCallback(async (classnames: string[]) => {
|
||||||
if (classnames.length === 0) return
|
if (classnames.length === 0) return;
|
||||||
setRepairing(true)
|
setRepairing(true);
|
||||||
setLastSummary(null)
|
setLastSummary(null);
|
||||||
|
|
||||||
// Init progress for these items
|
// Init progress for these items
|
||||||
setProgress((prev) => {
|
setProgress((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
for (const cn of classnames) {
|
for (const cn of classnames) {
|
||||||
next.set(cn, { status: 'working' })
|
next.set(cn, { status: "working" });
|
||||||
}
|
}
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/import/repair', {
|
const res = await fetch("/api/admin/import/repair", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ classnames, concurrency: 6 }),
|
body: JSON.stringify({ classnames, concurrency: 6 }),
|
||||||
})
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
toast.error('Failed to start repair')
|
toast.error("Failed to start repair");
|
||||||
setRepairing(false)
|
setRepairing(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
let buffer = ''
|
let buffer = "";
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read();
|
||||||
if (done) break
|
if (done) break;
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split('\n')
|
const lines = buffer.split("\n");
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data: ')) continue
|
if (!line.startsWith("data: ")) continue;
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(line.slice(6))
|
const event = JSON.parse(line.slice(6));
|
||||||
if (event.type === 'repair_progress') {
|
if (event.type === "repair_progress") {
|
||||||
setProgress((prev) => {
|
setProgress((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
next.set(event.classname, {
|
next.set(event.classname, {
|
||||||
status: event.status as ItemStatus,
|
status: event.status as ItemStatus,
|
||||||
message: event.error || (event.warnings?.[0] ?? undefined),
|
message: event.error || (event.warnings?.[0] ?? undefined),
|
||||||
nitroRepaired: event.nitroRepaired,
|
nitroRepaired: event.nitroRepaired,
|
||||||
iconRepaired: event.iconRepaired,
|
iconRepaired: event.iconRepaired,
|
||||||
unrepairable: event.unrepairable,
|
unrepairable: event.unrepairable,
|
||||||
})
|
});
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
// Promote newly-discovered unrepairable flags onto the audit list
|
// Promote newly-discovered unrepairable flags onto the audit list
|
||||||
if (event.unrepairable) {
|
if (event.unrepairable) {
|
||||||
setSummary((prev) => {
|
setSummary((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev;
|
||||||
const item = prev.items.find((i) => i.classname === event.classname)
|
const item = prev.items.find((i) => i.classname === event.classname);
|
||||||
if (!item || item.unrepairable) return prev
|
if (!item || item.unrepairable) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
unrepairable: prev.unrepairable + 1,
|
unrepairable: prev.unrepairable + 1,
|
||||||
items: prev.items.map((i) =>
|
items: prev.items.map((i) =>
|
||||||
i.classname === event.classname ? { ...i, unrepairable: true } : i,
|
i.classname === event.classname ? { ...i, unrepairable: true } : i,
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
// Remove repaired items from the audit list on "done"
|
// Remove repaired items from the audit list on "done"
|
||||||
if (event.status === 'done') {
|
if (event.status === "done") {
|
||||||
setSummary((prev) => {
|
setSummary((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev;
|
||||||
const items = prev.items.filter((i) => i.classname !== event.classname)
|
const items = prev.items.filter((i) => i.classname !== event.classname);
|
||||||
const diff = prev.items.length - items.length
|
const diff = prev.items.length - items.length;
|
||||||
if (diff === 0) return prev
|
if (diff === 0) return prev;
|
||||||
const removed = prev.items.find((i) => i.classname === event.classname)
|
const removed = prev.items.find((i) => i.classname === event.classname);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
items,
|
items,
|
||||||
missingBoth: prev.missingBoth - (removed?.missing === 'both' ? 1 : 0),
|
missingBoth: prev.missingBoth - (removed?.missing === "both" ? 1 : 0),
|
||||||
missingNitro: prev.missingNitro - (removed?.missing === 'nitro' ? 1 : 0),
|
missingNitro: prev.missingNitro - (removed?.missing === "nitro" ? 1 : 0),
|
||||||
missingIcon: prev.missingIcon - (removed?.missing === 'icon' ? 1 : 0),
|
missingIcon: prev.missingIcon - (removed?.missing === "icon" ? 1 : 0),
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
next.delete(event.classname)
|
next.delete(event.classname);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} else if (event.type === 'repair_complete') {
|
} else if (event.type === "repair_complete") {
|
||||||
setLastSummary({
|
setLastSummary({
|
||||||
succeeded: event.succeeded,
|
succeeded: event.succeeded,
|
||||||
partial: event.partial,
|
partial: event.partial,
|
||||||
failed: event.failed,
|
failed: event.failed,
|
||||||
unrepairable: event.unrepairable ?? 0,
|
unrepairable: event.unrepairable ?? 0,
|
||||||
duration: event.duration,
|
duration: event.duration,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* skip malformed */
|
/* skip malformed */
|
||||||
@@ -270,72 +263,72 @@ export function RepairClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Connection lost during repair')
|
toast.error("Connection lost during repair");
|
||||||
} finally {
|
} finally {
|
||||||
setRepairing(false)
|
setRepairing(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ── Delete unrepairable items ──────────────────────────────────────
|
// ── Delete unrepairable items ──────────────────────────────────────
|
||||||
const deleteSelected = useCallback(async () => {
|
const deleteSelected = useCallback(async () => {
|
||||||
const toDelete = Array.from(selected)
|
const toDelete = Array.from(selected);
|
||||||
if (toDelete.length === 0) return
|
if (toDelete.length === 0) return;
|
||||||
setConfirmDeleteAll(false)
|
setConfirmDeleteAll(false);
|
||||||
|
|
||||||
let ok = 0
|
let ok = 0;
|
||||||
let fail = 0
|
let fail = 0;
|
||||||
for (const cn of toDelete) {
|
for (const cn of toDelete) {
|
||||||
setProgress((prev) => {
|
setProgress((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
next.set(cn, { status: 'working', message: 'Deleting…' })
|
next.set(cn, { status: "working", message: "Deleting…" });
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
try {
|
try {
|
||||||
const result = await deleteImportedFurni({ classname: cn })
|
const result = await deleteImportedFurni({ classname: cn });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
ok++
|
ok++;
|
||||||
setSummary((prev) => {
|
setSummary((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev;
|
||||||
const removed = prev.items.find((i) => i.classname === cn)
|
const removed = prev.items.find((i) => i.classname === cn);
|
||||||
if (!removed) return prev
|
if (!removed) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
total: prev.total - 1,
|
total: prev.total - 1,
|
||||||
items: prev.items.filter((i) => i.classname !== cn),
|
items: prev.items.filter((i) => i.classname !== cn),
|
||||||
missingBoth: prev.missingBoth - (removed.missing === 'both' ? 1 : 0),
|
missingBoth: prev.missingBoth - (removed.missing === "both" ? 1 : 0),
|
||||||
missingNitro: prev.missingNitro - (removed.missing === 'nitro' ? 1 : 0),
|
missingNitro: prev.missingNitro - (removed.missing === "nitro" ? 1 : 0),
|
||||||
missingIcon: prev.missingIcon - (removed.missing === 'icon' ? 1 : 0),
|
missingIcon: prev.missingIcon - (removed.missing === "icon" ? 1 : 0),
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
next.delete(cn)
|
next.delete(cn);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
fail++
|
fail++;
|
||||||
setProgress((prev) => {
|
setProgress((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
next.set(cn, { status: 'failed', message: 'Delete failed' })
|
next.set(cn, { status: "failed", message: "Delete failed" });
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
fail++
|
fail++;
|
||||||
setProgress((prev) => {
|
setProgress((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
next.set(cn, { status: 'failed', message: 'Delete error' })
|
next.set(cn, { status: "failed", message: "Delete error" });
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ''}`)
|
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`);
|
||||||
}, [selected])
|
}, [selected]);
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const selectedArray = Array.from(selected)
|
const selectedArray = Array.from(selected);
|
||||||
const selectedCount = selectedArray.length
|
const selectedCount = selectedArray.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -344,9 +337,8 @@ export function RepairClient() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and
|
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and icons for
|
||||||
icons for items already imported. Items with no public asset on habbo.com can be deleted
|
items already imported. Items with no public asset on habbo.com can be deleted from here.
|
||||||
from here.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -357,7 +349,7 @@ export function RepairClient() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? 'Re-scan' : 'Scan Assets'}
|
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? "Re-scan" : "Scan Assets"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -368,12 +360,10 @@ export function RepairClient() {
|
|||||||
{!summary && !auditing && (
|
{!summary && !auditing && (
|
||||||
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
||||||
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
|
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
|
||||||
<p className="font-medium">
|
<p className="font-medium">Click “Scan Assets” to audit imported furniture.</p>
|
||||||
Click “Scan Assets” to audit imported furniture.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k
|
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k items takes
|
||||||
items takes a few seconds.
|
a few seconds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -388,12 +378,7 @@ export function RepairClient() {
|
|||||||
tone="danger"
|
tone="danger"
|
||||||
sub="no .nitro · no icon"
|
sub="no .nitro · no icon"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard label="Missing .nitro" value={summary.missingNitro} tone="warn" sub="shows black cube" />
|
||||||
label="Missing .nitro"
|
|
||||||
value={summary.missingNitro}
|
|
||||||
tone="warn"
|
|
||||||
sub="shows black cube"
|
|
||||||
/>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Missing icon"
|
label="Missing icon"
|
||||||
value={summary.missingIcon}
|
value={summary.missingIcon}
|
||||||
@@ -437,9 +422,7 @@ export function RepairClient() {
|
|||||||
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
||||||
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
|
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
|
||||||
<p className="font-medium">All imported items have their assets on disk.</p>
|
<p className="font-medium">All imported items have their assets on disk.</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{summary.total} items scanned, nothing to repair.</p>
|
||||||
{summary.total} items scanned, nothing to repair.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -461,16 +444,15 @@ export function RepairClient() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Skip unrepairable items — no point paying the network cost for them
|
// Skip unrepairable items — no point paying the network cost for them
|
||||||
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname)
|
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname);
|
||||||
const unrep =
|
const unrep = summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? [];
|
||||||
summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? []
|
const unrepSet = new Set(unrep);
|
||||||
const unrepSet = new Set(unrep)
|
const targets = pool.filter((cn) => !unrepSet.has(cn));
|
||||||
const targets = pool.filter((cn) => !unrepSet.has(cn))
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
toast.info('Nothing to repair — selected items are all flagged unrepairable.')
|
toast.info("Nothing to repair — selected items are all flagged unrepairable.");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
runRepair(targets)
|
runRepair(targets);
|
||||||
}}
|
}}
|
||||||
disabled={repairing || filtered.length === 0}
|
disabled={repairing || filtered.length === 0}
|
||||||
>
|
>
|
||||||
@@ -502,9 +484,9 @@ export function RepairClient() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Auto-select every unrepairable item, then open the same confirm dialog
|
// Auto-select every unrepairable item, then open the same confirm dialog
|
||||||
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname)
|
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname);
|
||||||
setSelected(new Set(unrep))
|
setSelected(new Set(unrep));
|
||||||
setConfirmDeleteAll(true)
|
setConfirmDeleteAll(true);
|
||||||
}}
|
}}
|
||||||
disabled={repairing}
|
disabled={repairing}
|
||||||
>
|
>
|
||||||
@@ -529,11 +511,7 @@ export function RepairClient() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40px]">
|
<TableHead className="w-[40px]">
|
||||||
<Checkbox
|
<Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={toggleAll}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[80px]">ID</TableHead>
|
<TableHead className="w-[80px]">ID</TableHead>
|
||||||
<TableHead>Classname</TableHead>
|
<TableHead>Classname</TableHead>
|
||||||
@@ -576,13 +554,12 @@ export function RepairClient() {
|
|||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Delete {selectedCount} imported item{selectedCount === 1 ? '' : 's'}?
|
Delete {selectedCount} imported item{selectedCount === 1 ? "" : "s"}?
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the
|
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the entry in{" "}
|
||||||
entry in <code>FurnitureData.json</code>, and any remaining asset files on disk. Use
|
<code>FurnitureData.json</code>, and any remaining asset files on disk. Use this for items that
|
||||||
this for items that have no public asset on habbo.com and therefore cannot be
|
have no public asset on habbo.com and therefore cannot be repaired. This cannot be undone.
|
||||||
repaired. This cannot be undone.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -596,7 +573,7 @@ export function RepairClient() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Small cell helpers ────────────────────────────────────────────────
|
// ── Small cell helpers ────────────────────────────────────────────────
|
||||||
@@ -607,24 +584,24 @@ function StatCard({
|
|||||||
tone,
|
tone,
|
||||||
sub,
|
sub,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
value: number
|
value: number;
|
||||||
tone: 'default' | 'danger' | 'warn' | 'info'
|
tone: "default" | "danger" | "warn" | "info";
|
||||||
sub?: string
|
sub?: string;
|
||||||
}) {
|
}) {
|
||||||
const tones = {
|
const tones = {
|
||||||
default: 'border-border',
|
default: "border-border",
|
||||||
danger: 'border-destructive/40 bg-destructive/5',
|
danger: "border-destructive/40 bg-destructive/5",
|
||||||
warn: 'border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]',
|
warn: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
|
||||||
info: 'border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5',
|
info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5",
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md border p-4 ${tones[tone]}`}>
|
<div className={`rounded-md border p-4 ${tones[tone]}`}>
|
||||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
|
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
|
||||||
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
|
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileBadge({ present }: { present: boolean }) {
|
function FileBadge({ present }: { present: boolean }) {
|
||||||
@@ -639,49 +616,49 @@ function FileBadge({ present }: { present: boolean }) {
|
|||||||
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
|
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
|
||||||
<XCircle className="mr-1 h-3 w-3" /> Missing
|
<XCircle className="mr-1 h-3 w-3" /> Missing
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusCell({ progress }: { progress?: ItemProgress }) {
|
function StatusCell({ progress }: { progress?: ItemProgress }) {
|
||||||
if (!progress || progress.status === 'idle')
|
if (!progress || progress.status === "idle")
|
||||||
return <span className="text-muted-foreground text-xs">—</span>
|
return <span className="text-muted-foreground text-xs">—</span>;
|
||||||
if (progress.status === 'working') {
|
if (progress.status === "working") {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
{progress.message || 'Working…'}
|
{progress.message || "Working…"}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (progress.status === 'done') {
|
if (progress.status === "done") {
|
||||||
const parts = [
|
const parts = [
|
||||||
progress.nitroRepaired ? '.nitro ✓' : null,
|
progress.nitroRepaired ? ".nitro ✓" : null,
|
||||||
progress.iconRepaired ? 'icon ✓' : null,
|
progress.iconRepaired ? "icon ✓" : null,
|
||||||
].filter(Boolean)
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
|
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
{parts.length > 0 ? parts.join(' · ') : 'Repaired'}
|
{parts.length > 0 ? parts.join(" · ") : "Repaired"}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (progress.status === 'partial') {
|
if (progress.status === "partial") {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
|
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
|
||||||
title={progress.message}
|
title={progress.message}
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3 w-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
Partial · {progress.message || 'check logs'}
|
Partial · {progress.message || "check logs"}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}>
|
<span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}>
|
||||||
<XCircle className="h-3 w-3" />
|
<XCircle className="h-3 w-3" />
|
||||||
{progress.message || 'Failed'}
|
{progress.message || "Failed"}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemRow({
|
function ItemRow({
|
||||||
@@ -692,39 +669,32 @@ function ItemRow({
|
|||||||
onRepair,
|
onRepair,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
item: MissingItem
|
item: MissingItem;
|
||||||
selected: boolean
|
selected: boolean;
|
||||||
onToggle: () => void
|
onToggle: () => void;
|
||||||
progress?: ItemProgress
|
progress?: ItemProgress;
|
||||||
onRepair: () => void
|
onRepair: () => void;
|
||||||
disabled: boolean
|
disabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isUnrepairable = item.unrepairable || progress?.unrepairable
|
const isUnrepairable = item.unrepairable || progress?.unrepairable;
|
||||||
return (
|
return (
|
||||||
<TableRow className={isUnrepairable ? 'bg-destructive/5' : undefined}>
|
<TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox checked={selected} onCheckedChange={onToggle} aria-label={`Select ${item.classname}`} />
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={onToggle}
|
|
||||||
aria-label={`Select ${item.classname}`}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-sm">{item.classname}</span>
|
<span className="font-mono text-sm">{item.classname}</span>
|
||||||
{isUnrepairable && (
|
{isUnrepairable && (
|
||||||
<Badge
|
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
|
||||||
variant="secondary"
|
|
||||||
className="border-destructive/40 bg-destructive/10 text-destructive"
|
|
||||||
>
|
|
||||||
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
|
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{item.publicName}</div>
|
<div className="text-xs text-muted-foreground">{item.publicName}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{item.revision || '—'}</TableCell>
|
<TableCell className="text-xs">{item.revision || "—"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FileBadge present={item.hasNitro} />
|
<FileBadge present={item.hasNitro} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -742,10 +712,10 @@ function ItemRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onRepair}
|
onClick={onRepair}
|
||||||
disabled={disabled || progress?.status === 'working' || isUnrepairable}
|
disabled={disabled || progress?.status === "working" || isUnrepairable}
|
||||||
title={isUnrepairable ? 'habbo.com does not have this asset' : 'Retry download + convert'}
|
title={isUnrepairable ? "habbo.com does not have this asset" : "Retry download + convert"}
|
||||||
>
|
>
|
||||||
{progress?.status === 'working' ? (
|
{progress?.status === "working" ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Wrench className="h-3 w-3" />
|
<Wrench className="h-3 w-3" />
|
||||||
@@ -753,5 +723,5 @@ function ItemRow({
|
|||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,9 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
|
|||||||
{initial}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">{staff.username}</div>
|
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
|
||||||
|
{staff.username}
|
||||||
|
</div>
|
||||||
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
||||||
{t("rankLabel", { rank: staff.rank })}
|
{t("rankLabel", { rank: staff.rank })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { DataTable } from '@/components/admin/data-table'
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
interface AuditRow {
|
interface AuditRow {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
action: string
|
action: string;
|
||||||
target: string
|
target: string;
|
||||||
targetId: number | null
|
targetId: number | null;
|
||||||
diff: string | null
|
diff: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffCell({ diff }: { diff: string | null }) {
|
function DiffCell({ diff }: { diff: string | null }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
if (!diff) return <span className="text-muted-foreground">-</span>
|
if (!diff) return <span className="text-muted-foreground">-</span>;
|
||||||
|
|
||||||
let parsed: Record<string, { from: unknown; to: unknown }>
|
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(diff)
|
parsed = JSON.parse(diff);
|
||||||
} catch {
|
} catch {
|
||||||
return <span className="text-muted-foreground">-</span>
|
return <span className="text-muted-foreground">-</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(parsed)
|
const keys = Object.keys(parsed);
|
||||||
if (keys.length === 0) return <span className="text-muted-foreground">-</span>
|
if (keys.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
|
|
||||||
const summary =
|
const summary =
|
||||||
keys.length === 1
|
keys.length === 1
|
||||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? '∅')} → ${String(parsed[keys[0]].to ?? '∅')}`
|
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||||
: `${keys.length} changes`
|
: `${keys.length} changes`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -50,53 +50,53 @@ function DiffCell({ diff }: { diff: string | null }) {
|
|||||||
{keys.map((k) => (
|
{keys.map((k) => (
|
||||||
<div key={k} className="flex gap-1">
|
<div key={k} className="flex gap-1">
|
||||||
<span className="font-medium">{k}:</span>
|
<span className="font-medium">{k}:</span>
|
||||||
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? '∅')}</span>
|
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? "∅")}</span>
|
||||||
<span>→</span>
|
<span>→</span>
|
||||||
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? '∅')}</span>
|
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? "∅")}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<AuditRow>[] = [
|
const columns: DataTableColumn<AuditRow>[] = [
|
||||||
{ key: 'id', label: 'ID', sortable: true },
|
{ key: "id", label: "ID", sortable: true },
|
||||||
{ key: 'username', label: 'User', sortable: true },
|
{ key: "username", label: "User", sortable: true },
|
||||||
{
|
{
|
||||||
key: 'action',
|
key: "action",
|
||||||
label: 'Action',
|
label: "Action",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
const v = String(value)
|
const v = String(value);
|
||||||
let variant: 'default' | 'secondary' | 'destructive' = 'secondary'
|
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||||
if (v.includes('create') || v.includes('add')) variant = 'default'
|
if (v.includes("create") || v.includes("add")) variant = "default";
|
||||||
if (v.includes('delete') || v.includes('remove') || v.includes('ban')) variant = 'destructive'
|
if (v.includes("delete") || v.includes("remove") || v.includes("ban")) variant = "destructive";
|
||||||
return <Badge variant={variant}>{v}</Badge>
|
return <Badge variant={variant}>{v}</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'target', label: 'Target', sortable: true },
|
{ key: "target", label: "Target", sortable: true },
|
||||||
{
|
{
|
||||||
key: 'targetId',
|
key: "targetId",
|
||||||
label: 'Target ID',
|
label: "Target ID",
|
||||||
render: (value) => <span>{value != null ? String(value) : '-'}</span>,
|
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'diff',
|
key: "diff",
|
||||||
label: 'Details',
|
label: "Details",
|
||||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'createdAt',
|
key: "createdAt",
|
||||||
label: 'Date',
|
label: "Date",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => <span>{String(value) || 'N/A'}</span>,
|
render: (value) => <span>{String(value) || "N/A"}</span>,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
interface AuditTableProps {
|
interface AuditTableProps {
|
||||||
data: PaginatedResult<AuditRow>
|
data: PaginatedResult<AuditRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuditTable({ data }: AuditTableProps) {
|
export function AuditTable({ data }: AuditTableProps) {
|
||||||
@@ -107,5 +107,5 @@ export function AuditTable({ data }: AuditTableProps) {
|
|||||||
searchPlaceholder="Search by action or target..."
|
searchPlaceholder="Search by action or target..."
|
||||||
exportUrl="/api/admin/export?type=audit"
|
exportUrl="/api/admin/export?type=audit"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function AuditLogsLoading() {
|
export default function AuditLogsLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -24,5 +24,5 @@ export default function AuditLogsLoading() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from "next/navigation";
|
||||||
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { getAuditLogs } from '@/lib/services/audit'
|
import { getAuditLogs } from "@/lib/services/audit";
|
||||||
import { AuditTable } from './audit-table'
|
import { AuditTable } from "./audit-table";
|
||||||
|
|
||||||
export default async function AuditLogsPage({
|
export default async function AuditLogsPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
searchParams: Promise<Record<string, string>>
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
await paramsPromise
|
await paramsPromise;
|
||||||
const { session, permissions } = await getAdminContext()
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
||||||
redirect('/admin')
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = await searchParams
|
const params = await searchParams;
|
||||||
const sp = new URLSearchParams(params)
|
const sp = new URLSearchParams(params);
|
||||||
const { search, perPage, page } = parseListParams(sp)
|
const { search, perPage, page } = parseListParams(sp);
|
||||||
|
|
||||||
const result = await getAuditLogs({ search, page, perPage })
|
const result = await getAuditLogs({ search, page, perPage });
|
||||||
|
|
||||||
const pagination = calcPagination(result.total, result.page, result.perPage)
|
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||||
|
|
||||||
const rows = result.rows.map((r) => ({
|
const rows = result.rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -33,7 +33,7 @@ export default async function AuditLogsPage({
|
|||||||
targetId: r.targetId,
|
targetId: r.targetId,
|
||||||
diff: r.diff,
|
diff: r.diff,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -44,5 +44,5 @@ export default async function AuditLogsPage({
|
|||||||
|
|
||||||
<AuditTable data={{ rows, ...pagination }} />
|
<AuditTable data={{ rows, ...pagination }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,36 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { DataTable } from '@/components/admin/data-table'
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
import { formatTimestamp } from '@/lib/admin-helpers'
|
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
interface ChatLogRow {
|
interface ChatLogRow {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
message: string
|
message: string;
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
roomId: number
|
roomId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<ChatLogRow>[] = [
|
const columns: DataTableColumn<ChatLogRow>[] = [
|
||||||
{ key: 'id', label: 'ID', sortable: true },
|
{ key: "id", label: "ID", sortable: true },
|
||||||
{ key: 'username', label: 'User' },
|
{ key: "username", label: "User" },
|
||||||
{
|
{
|
||||||
key: 'message',
|
key: "message",
|
||||||
label: 'Message',
|
label: "Message",
|
||||||
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
|
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: "timestamp",
|
||||||
label: 'Time',
|
label: "Time",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||||
},
|
},
|
||||||
{ key: 'roomId', label: 'Room ID', sortable: true },
|
{ key: "roomId", label: "Room ID", sortable: true },
|
||||||
]
|
];
|
||||||
|
|
||||||
interface ChatTableProps {
|
interface ChatTableProps {
|
||||||
data: PaginatedResult<ChatLogRow>
|
data: PaginatedResult<ChatLogRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatTable({ data }: ChatTableProps) {
|
export function ChatTable({ data }: ChatTableProps) {
|
||||||
@@ -41,5 +41,5 @@ export function ChatTable({ data }: ChatTableProps) {
|
|||||||
searchPlaceholder="Search by username or message..."
|
searchPlaceholder="Search by username or message..."
|
||||||
exportUrl="/api/admin/export?type=chatlogs"
|
exportUrl="/api/admin/export?type=chatlogs"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function ChatLogsLoading() {
|
export default function ChatLogsLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -24,5 +24,5 @@ export default function ChatLogsLoading() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { fetchAdminList } from '@/lib/admin-list'
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { ChatTable } from './chat-table'
|
import { ChatTable } from "./chat-table";
|
||||||
|
|
||||||
export default async function ChatLogsPage({
|
export default async function ChatLogsPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
searchParams: Promise<Record<string, string>>
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.LOGS_VIEW,
|
permission: PERMS.LOGS_VIEW,
|
||||||
model: 'chatLog',
|
model: "chatLog",
|
||||||
searchFields: ['message', 'user.username'],
|
searchFields: ["message", "user.username"],
|
||||||
defaultPerPage: 50,
|
defaultPerPage: 50,
|
||||||
include: { user: { select: { username: true } } },
|
include: { user: { select: { username: true } } },
|
||||||
mapRow: (l) => ({
|
mapRow: (l) => ({
|
||||||
@@ -26,7 +26,7 @@ export default async function ChatLogsPage({
|
|||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,5 +45,5 @@ export default async function ChatLogsPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { DataTable } from '@/components/admin/data-table'
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
import { formatTimestamp } from '@/lib/admin-helpers'
|
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
interface CommandLogRow {
|
interface CommandLogRow {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
command: string
|
command: string;
|
||||||
params: string
|
params: string;
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<CommandLogRow>[] = [
|
const columns: DataTableColumn<CommandLogRow>[] = [
|
||||||
{ key: 'id', label: 'ID', sortable: true },
|
{ key: "id", label: "ID", sortable: true },
|
||||||
{ key: 'username', label: 'User' },
|
{ key: "username", label: "User" },
|
||||||
{ key: 'command', label: 'Command', sortable: true },
|
{ key: "command", label: "Command", sortable: true },
|
||||||
{
|
{
|
||||||
key: 'params',
|
key: "params",
|
||||||
label: 'Params',
|
label: "Params",
|
||||||
render: (value) => (
|
render: (value) => (
|
||||||
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || '-'}</span>
|
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || "-"}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: "timestamp",
|
||||||
label: 'Time',
|
label: "Time",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
interface CommandsTableProps {
|
interface CommandsTableProps {
|
||||||
data: PaginatedResult<CommandLogRow>
|
data: PaginatedResult<CommandLogRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandsTable({ data }: CommandsTableProps) {
|
export function CommandsTable({ data }: CommandsTableProps) {
|
||||||
@@ -43,5 +43,5 @@ export function CommandsTable({ data }: CommandsTableProps) {
|
|||||||
searchPlaceholder="Search by username, command, or params..."
|
searchPlaceholder="Search by username, command, or params..."
|
||||||
exportUrl="/api/admin/export?type=commands"
|
exportUrl="/api/admin/export?type=commands"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export default function CommandLogsLoading() {
|
export default function CommandLogsLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -24,5 +24,5 @@ export default function CommandLogsLoading() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { fetchAdminList } from '@/lib/admin-list'
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
import { PERMS } from '@/lib/permissions'
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { CommandsTable } from './commands-table'
|
import { CommandsTable } from "./commands-table";
|
||||||
|
|
||||||
export default async function CommandLogsPage({
|
export default async function CommandLogsPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
searchParams: Promise<Record<string, string>>
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.LOGS_VIEW,
|
permission: PERMS.LOGS_VIEW,
|
||||||
model: 'commandLog',
|
model: "commandLog",
|
||||||
searchFields: ['command', 'params', 'user.username'],
|
searchFields: ["command", "params", "user.username"],
|
||||||
defaultPerPage: 50,
|
defaultPerPage: 50,
|
||||||
include: { user: { select: { username: true } } },
|
include: { user: { select: { username: true } } },
|
||||||
mapRow: (l) => ({
|
mapRow: (l) => ({
|
||||||
@@ -26,7 +26,7 @@ export default async function CommandLogsPage({
|
|||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,5 +45,5 @@ export default async function CommandLogsPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,13 @@ function fromDate(d: Date | null | undefined): string {
|
|||||||
export default async function AdminStaffActivities({
|
export default async function AdminStaffActivities({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string; authorization?: string }>;
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
page?: string;
|
||||||
|
staffId?: string;
|
||||||
|
action?: string;
|
||||||
|
authorization?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.admin.logs");
|
const t = await getTranslations("pages.admin.logs");
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user