feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
1 parent c9e35cf602
commit 2b8f73a91d
457 files changed
+589 -55936

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ describe("Content advertisement legacy wrappers", () => {
"ad.change",
{ action: "create", image: "https://example.com/ad.png" },
);
expect(redirect).toHaveBeenCalledWith("/admin/ads");
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
});
it("returns early when image is empty", async () => {
+5 -5
View File
@@ -31,10 +31,10 @@ export async function createAd(formData: FormData): Promise<void> {
action: "createAd",
error: result.error.messageKey,
});
revalidatePath("/admin/ads");
revalidatePath("/ase/content/media/ads");
return;
}
redirect("/admin/ads");
redirect("/ase/content/media/ads");
}
export async function updateAd(formData: FormData): Promise<void> {
@@ -57,10 +57,10 @@ export async function updateAd(formData: FormData): Promise<void> {
id: Number(raw),
error: result.error.messageKey,
});
revalidatePath(`/admin/ads/${raw}`);
revalidatePath(`/ase/content/media/ads/${raw}`);
return;
}
redirect("/admin/ads");
redirect("/ase/content/media/ads");
}
const deleteAdInput = z.object({
@@ -82,7 +82,7 @@ export const deleteAd = adminAction(
{ action: "delete", id: ctx.data.id.toString() },
);
if (!result.ok) throw new ActionError("Advertisement not found");
revalidatePath("/admin/ads");
revalidatePath("/ase/content/media/ads");
return actionOk();
},
);
+3 -1
View File
@@ -37,7 +37,9 @@ describe("sendHotelAlert", () => {
fakeForm({ message: "Hello!" }) as unknown as FormData,
);
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/system/operations/alerts",
);
});
it("returns early when message is empty", async () => {
+2 -2
View File
@@ -52,7 +52,7 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
// admin page. The emulator writes its own alert_logs row on receipt.
}
revalidatePath("/admin/alerts");
revalidatePath("/ase/system/operations/alerts");
}
/** Mark every unread ops alert as read. */
@@ -67,5 +67,5 @@ export async function markAllAlertsRead(): Promise<void> {
} catch {
/* ignore */
}
revalidatePath("/admin/alerts");
revalidatePath("/ase/system/operations/alerts");
}
+1 -1
View File
@@ -22,5 +22,5 @@ export async function dismissApplication(formData: FormData): Promise<void> {
{ applicationId, decision: "dismiss" },
);
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
revalidatePath("/admin/applications");
revalidatePath("/ase/people/staff/applications");
}
+9 -7
View File
@@ -39,10 +39,10 @@ export async function createArticle(formData: FormData): Promise<void> {
);
if (!result.ok) {
redirect(
"/admin/articles/new?error=Database error while creating article. Please try again.",
"/ase/content/editorial/articles/new?error=Database error while creating article. Please try again.",
);
}
redirect("/admin/articles");
redirect("/ase/content/editorial/articles");
}
export async function updateArticle(formData: FormData): Promise<void> {
@@ -53,9 +53,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
"article.change",
{ action: "update", id, ...articleInput(formData) },
);
if (!result.ok) redirect("/admin/articles?error=Update failed");
revalidatePath(`/admin/articles/${id}`);
redirect("/admin/articles");
if (!result.ok)
redirect("/ase/content/editorial/articles?error=Update failed");
revalidatePath(`/ase/content/editorial/articles/${id}`);
redirect("/ase/content/editorial/articles");
}
export async function deleteArticle(formData: FormData): Promise<void> {
@@ -66,6 +67,7 @@ export async function deleteArticle(formData: FormData): Promise<void> {
"article.change",
{ action: "delete", id },
);
if (!result.ok) redirect("/admin/articles?error=Delete failed");
redirect("/admin/articles");
if (!result.ok)
redirect("/ase/content/editorial/articles?error=Delete failed");
redirect("/ase/content/editorial/articles");
}
-20
View File
@@ -1,20 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
export async function uploadBadge(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const code = String(formData.get("code") ?? "");
try {
await executeLegacyEconomyMutation(staff, "badge.upload", {
code,
file: formData.get("file"),
});
} catch {
redirect("/admin/badges?error=Badge%20upload%20failed");
}
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
}
-15
View File
@@ -1,15 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
export async function giveBadge(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "badge.give", {
userId: formData.get("userId"),
badge: formData.get("code"),
});
revalidatePath("/admin/badges");
}
-25
View File
@@ -1,25 +0,0 @@
"use server";
import {
createBanner as createBannerAction,
deleteBanner as deleteBannerAction,
updateBanner as updateBannerAction,
} from "./banners";
export async function createBanner(
input: Parameters<typeof createBannerAction>[0],
) {
return createBannerAction(input);
}
export async function updateBanner(
input: Parameters<typeof updateBannerAction>[0],
) {
return updateBannerAction(input);
}
export async function deleteBanner(
input: Parameters<typeof deleteBannerAction>[0],
) {
return deleteBannerAction(input);
}
+2 -2
View File
@@ -47,7 +47,7 @@ describe("legacy admin ban wrappers", () => {
"ban.create",
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
});
it("returns early when userId is invalid", async () => {
@@ -62,6 +62,6 @@ describe("legacy admin ban wrappers", () => {
"ban.lift",
{ id: 42 },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
});
});
+2 -2
View File
@@ -37,7 +37,7 @@ export async function createBan(formData: FormData): Promise<void> {
},
);
if (!result.ok) throw new Error("Could not create ban");
revalidatePath("/admin/bans");
revalidatePath("/ase/people/moderation/bans");
}
export async function liftBan(formData: FormData): Promise<void> {
@@ -51,5 +51,5 @@ export async function liftBan(formData: FormData): Promise<void> {
);
if (!result.ok) throw new Error("Could not lift ban");
}
revalidatePath("/admin/bans");
revalidatePath("/ase/people/moderation/bans");
}
-71
View File
@@ -1,71 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
function templateInput(formData: FormData) {
return {
name: String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
subject: String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
body: String(formData.get("body") ?? "").normalize("NFC"),
variables: String(formData.get("variables") ?? "")
.normalize("NFC")
.trim(),
isActive: formData.get("isActive") != null,
};
}
export async function createEmailTemplate(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const input = templateInput(formData);
if (!input.name || !input.subject || !input.body) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"email-template.change",
{ action: "create", ...input },
);
if (!result.ok) throw new Error("Email template creation failed");
revalidatePath("/admin/email-templates");
}
export async function updateEmailTemplate(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").normalize("NFC");
if (!/^\d+$/u.test(id)) return;
const input = templateInput(formData);
if (!input.subject || !input.body) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"email-template.change",
{ action: "update", id, ...input },
);
if (!result.ok) throw new Error("Email template update failed");
revalidatePath("/admin/email-templates");
}
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!/^[1-9]\d*$/u.test(id)) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"email-template.change",
{ action: "delete", id },
);
if (!result.ok) throw new Error("Email template deletion failed");
revalidatePath("/admin/email-templates");
}
-79
View File
@@ -1,79 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
// Both tables are emulator-owned; we only ever read/update existing rows or add new
// keys via upsert. We never migrate or drop them.
function mutationContext(staff: {
id: number;
rank: number;
username: string;
}) {
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
return {
capability: createHousekeepingCapabilityContext(staff, {
isSuperAdmin: false,
has: matches,
hasAny: (...slugs: string[]) => slugs.some(matches),
hasAll: (...slugs: string[]) => slugs.every(matches),
}),
correlationId: createCorrelationId(),
};
}
async function requireMutation(
staff: { id: number; rank: number; username: string },
operation:
| "configuration.emulator-setting.update"
| "configuration.emulator-text.update",
input: { key: string; value: string },
): Promise<void> {
const result = await systemMutationService.execute(
mutationContext(staff),
operation,
input,
);
if (!result.ok) throw new Error(result.error.messageKey);
}
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "")
.normalize("NFC")
.slice(0, 512);
if (!key) return;
await requireMutation(staff, "configuration.emulator-setting.update", {
key,
value,
});
revalidatePath("/admin/emulator");
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "")
.normalize("NFC")
.slice(0, 4096);
if (!key) return;
await requireMutation(staff, "configuration.emulator-text.update", {
key,
value,
});
revalidatePath("/admin/emulator");
}
+2 -2
View File
@@ -33,12 +33,12 @@ beforeEach(() => {
});
describe("disbandGuild legacy wrapper", () => {
it("keeps rate-limited ACL, service input, and /admin revalidation", async () => {
it("keeps rate-limited ACL, service input, and ASE revalidation", async () => {
await disbandGuild(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
guildId: 9,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/community/guilds");
});
it("keeps invalid IDs as a no-op", async () => {
await disbandGuild(form({ id: "0" }));
+1 -1
View File
@@ -24,5 +24,5 @@ export async function disbandGuild(formData: FormData): Promise<void> {
if (result.error.code === "NOT_FOUND") return;
throw new Error("Could not disband guild");
}
revalidatePath("/admin/guilds");
revalidatePath("/ase/people/community/guilds");
}
+4 -6
View File
@@ -37,10 +37,8 @@ const helpCenterTicketIdSchema = z.object({
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
const id = String(ticketId);
revalidatePath("/admin/help-tickets");
revalidatePath(`/admin/help-tickets/${id}`);
revalidatePath("/mod/help-tickets");
revalidatePath(`/mod/help-tickets/${id}`);
revalidatePath("/ase/people/support/help-tickets");
revalidatePath(`/ase/people/support/help-tickets/${id}`);
revalidatePath("/help/tickets");
revalidatePath(`/help/tickets/${id}`);
}
@@ -76,10 +74,10 @@ export const liftBanFromHelpTicket = adminAction(
}
revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/admin/bans");
revalidatePath("/ase/people/moderation/bans");
const removed = Number(result.data.output?.removed ?? 0);
const userId = Number(result.data.output?.userId);
revalidatePath(`/admin/users/show/${userId}`);
revalidatePath(`/ase/people/users/${userId}`);
return actionOk({ removed, userId });
},
);
+3 -3
View File
@@ -45,7 +45,7 @@ describe("Content help legacy wrappers", () => {
"help-question.change",
expect.objectContaining({ action: "create", name: "FAQ" }),
);
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
});
it("delegates update and redirects", async () => {
await updateHelpQuestion(
@@ -56,7 +56,7 @@ describe("Content help legacy wrappers", () => {
"help-question.change",
expect.objectContaining({ action: "update", id: "42" }),
);
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
});
it("delegates delete and redirects", async () => {
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
@@ -65,6 +65,6 @@ describe("Content help legacy wrappers", () => {
"help-question.change",
{ action: "delete", id: "42" },
);
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
});
});
+7 -7
View File
@@ -39,13 +39,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
{ action: "create", ...input },
);
if (!result.ok) {
revalidatePath("/admin/help-questions");
revalidatePath("/ase/content/help/questions");
redirect(
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
"/ase/content/help/questions/new?error=Unique name collision or database error. Please try again.",
);
}
revalidatePath("/admin/help-questions");
redirect("/admin/help-questions");
revalidatePath("/ase/content/help/questions");
redirect("/ase/content/help/questions");
}
export async function updateHelpQuestion(formData: FormData): Promise<void> {
@@ -60,10 +60,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
{ action: "update", id, ...input },
);
if (!result.ok) {
revalidatePath(`/admin/help-questions/${id}`);
revalidatePath(`/ase/content/help/questions/${id}`);
return;
}
redirect("/admin/help-questions");
redirect("/ase/content/help/questions");
}
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
@@ -75,5 +75,5 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
"help-question.change",
{ action: "delete", id },
);
redirect("/admin/help-questions");
redirect("/ase/content/help/questions");
}
-26
View File
@@ -1,26 +0,0 @@
"use server";
import { asc } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function exportPermissions(): Promise<string> {
await requirePermission(PERMS.SETTINGS_VIEW);
const perms = await db
.select({
permission: WebsiteHousekeepingPermissions.permission,
minRank: WebsiteHousekeepingPermissions.minRank,
description: WebsiteHousekeepingPermissions.description,
groupName: WebsiteHousekeepingPermissions.groupName,
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
})
.from(WebsiteHousekeepingPermissions)
.orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
);
return JSON.stringify(perms, null, 2);
}
+1 -1
View File
@@ -43,7 +43,7 @@ async function run(
input,
);
if (!result.ok) throw new Error("Could not update IP rules");
revalidatePath("/admin/ip");
revalidatePath("/ase/people/moderation/ip");
}
export async function addWhitelist(formData: FormData): Promise<void> {
+3 -1
View File
@@ -96,7 +96,9 @@ describe("saveMaintenance", () => {
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/ase/system/operations/maintenance",
);
});
it("disables maintenance mode", async () => {
+1 -1
View File
@@ -60,5 +60,5 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
},
);
if (!result.ok) throw new Error(result.error.messageKey);
revalidatePath("/admin/maintenance");
revalidatePath("/ase/system/operations/maintenance");
}
-17
View File
@@ -1,17 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
/** Cancel an active marketplace listing (state 1 to 0). */
export async function cancelMarketplaceListing(
formData: FormData,
): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
await executeLegacyEconomyMutation(staff, "marketplace.cancel", {
id: formData.get("id"),
});
revalidatePath("/admin/marketplace");
}
+1 -1
View File
@@ -60,6 +60,6 @@ describe("Content media legacy wrappers", () => {
filename: "photo.png",
});
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/library");
});
});
+3 -3
View File
@@ -39,7 +39,7 @@ export async function uploadMedia(
);
if (!result.ok) throw new Error("Media upload failed");
revalidatePath("/api/media");
revalidatePath("/admin/media");
revalidatePath("/ase/content/media/library");
return { ok: true };
}
@@ -51,7 +51,7 @@ export async function deleteMedia(name: string): Promise<void> {
{ filename: name },
);
revalidatePath("/api/media");
revalidatePath("/admin/media");
revalidatePath("/ase/content/media/library");
}
export async function uploadMediaAndReturn(
@@ -67,7 +67,7 @@ export async function uploadMediaAndReturn(
);
if (!result.ok) return "";
revalidatePath("/api/media");
revalidatePath("/admin/media");
revalidatePath("/ase/content/media/library");
return typeof result.data.output?.url === "string"
? result.data.output.url
: "";
-38
View File
@@ -1,38 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { PERMS } from "@/lib/permissions";
const schema = z.object({
groupOrder: z.array(z.string()),
hiddenGroups: z.array(z.string()),
hiddenItems: z.array(z.string()),
itemOrder: z.record(z.string(), z.array(z.string())),
});
export const saveAdminNavConfig = adminAction(
{
permission: PERMS.SETTINGS_EDIT,
schema,
rateLimitKey: "admin-nav-config-save",
rateLimitMax: 30,
},
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"navigation.update",
ctx.data,
);
if (!result.ok) throw new Error("Navigation update failed");
revalidatePath("/admin", "layout");
revalidatePath("/admin/menu");
return actionOk({ saved: true });
},
);
+1 -1
View File
@@ -39,7 +39,7 @@ describe("deletePhoto", () => {
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
id: 42,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/photos");
expect(revalidatePath).toHaveBeenCalledWith("/photos");
});
it("returns early when id is not positive", async () => {
+1 -1
View File
@@ -18,6 +18,6 @@ export async function deletePhoto(formData: FormData): Promise<void> {
"photo.delete",
{ id },
);
revalidatePath("/admin/photos");
revalidatePath("/ase/content/media/photos");
revalidatePath("/photos");
}
+4 -4
View File
@@ -19,8 +19,8 @@ export async function createApiKey(formData: FormData): Promise<void> {
allowedIps: text(formData, "allowedIps") || undefined,
rateLimit: Number(text(formData, "rateLimit") || 300),
});
revalidatePath("/admin/radio/api-keys");
redirect("/admin/radio/api-keys?created=1");
revalidatePath("/ase/hotel/radio/api-keys");
redirect("/ase/hotel/radio/api-keys?created=1");
}
export async function toggleApiKey(formData: FormData): Promise<void> {
@@ -28,7 +28,7 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
id: text(formData, "id"),
});
revalidatePath("/admin/radio/api-keys");
revalidatePath("/ase/hotel/radio/api-keys");
}
export async function deleteApiKey(formData: FormData): Promise<void> {
@@ -36,5 +36,5 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
id: text(formData, "id"),
});
revalidatePath("/admin/radio/api-keys");
revalidatePath("/ase/hotel/radio/api-keys");
}
+3 -3
View File
@@ -27,7 +27,7 @@ export async function createTrack(formData: FormData): Promise<void> {
sortOrder: Number(text(formData, "sortOrder") || 0),
isActive: enabled(formData, "isActive"),
});
revalidatePath("/admin/radio/autodj");
revalidatePath("/ase/hotel/radio/autodj");
}
export async function toggleTrack(formData: FormData): Promise<void> {
@@ -36,7 +36,7 @@ export async function toggleTrack(formData: FormData): Promise<void> {
id: text(formData, "id"),
isActive: enabled(formData, "isActive"),
});
revalidatePath("/admin/radio/autodj");
revalidatePath("/ase/hotel/radio/autodj");
}
export async function deleteTrack(formData: FormData): Promise<void> {
@@ -44,5 +44,5 @@ export async function deleteTrack(formData: FormData): Promise<void> {
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
id: text(formData, "id"),
});
revalidatePath("/admin/radio/autodj");
revalidatePath("/ase/hotel/radio/autodj");
}
+8 -8
View File
@@ -28,7 +28,7 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
comment: text(formData, "comment") || undefined,
});
siteSettings.reload();
revalidatePath("/admin/radio/settings");
revalidatePath("/ase/hotel/radio/settings");
}
export async function saveRadioSettings(formData: FormData): Promise<void> {
@@ -42,7 +42,7 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
entries,
});
siteSettings.reload();
revalidatePath("/admin/radio/settings");
revalidatePath("/ase/hotel/radio/settings");
}
function bannerInput(formData: FormData) {
@@ -62,7 +62,7 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
"radio.banner.create",
bannerInput(formData),
);
revalidatePath("/admin/radio/banners");
revalidatePath("/ase/hotel/radio/banners");
}
export async function updateRadioBanner(formData: FormData): Promise<void> {
@@ -71,7 +71,7 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
id: text(formData, "id"),
...bannerInput(formData),
});
revalidatePath("/admin/radio/banners");
revalidatePath("/ase/hotel/radio/banners");
}
export async function deleteRadioBanner(formData: FormData): Promise<void> {
@@ -79,7 +79,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
id: text(formData, "id"),
});
revalidatePath("/admin/radio/banners");
revalidatePath("/ase/hotel/radio/banners");
}
function rankInput(formData: FormData) {
@@ -98,7 +98,7 @@ export async function createRadioRank(formData: FormData): Promise<void> {
"radio.rank.create",
rankInput(formData),
);
revalidatePath("/admin/radio/ranks");
revalidatePath("/ase/hotel/radio/ranks");
}
export async function updateRadioRank(formData: FormData): Promise<void> {
@@ -107,7 +107,7 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
id: text(formData, "id"),
...rankInput(formData),
});
revalidatePath("/admin/radio/ranks");
revalidatePath("/ase/hotel/radio/ranks");
}
export async function deleteRadioRank(formData: FormData): Promise<void> {
@@ -115,5 +115,5 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
id: text(formData, "id"),
});
revalidatePath("/admin/radio/ranks");
revalidatePath("/ase/hotel/radio/ranks");
}
+1 -1
View File
@@ -10,5 +10,5 @@ export async function deleteShout(formData: FormData): Promise<void> {
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
id: String(formData.get("id") ?? "").trim(),
});
revalidatePath("/admin/radio/moderation");
revalidatePath("/ase/hotel/radio/moderation");
}
+2 -2
View File
@@ -35,6 +35,6 @@ export async function savePoints(formData: FormData): Promise<void> {
),
});
siteSettings.reload();
revalidatePath("/admin/radio/points");
redirect("/admin/radio/points?saved=1");
revalidatePath("/ase/hotel/radio/points");
redirect("/ase/hotel/radio/points?saved=1");
}
-46
View File
@@ -1,46 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
return Object.fromEntries(formData.entries());
}
export async function createCategory(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
await executeLegacyEconomyMutation(staff, "rare-category.change", {
action: "create",
...formInput(formData),
});
revalidatePath("/admin/rare-values");
}
export async function deleteCategory(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
await executeLegacyEconomyMutation(staff, "rare-category.change", {
action: "delete",
...formInput(formData),
});
revalidatePath("/admin/rare-values");
}
export async function createValue(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
await executeLegacyEconomyMutation(staff, "rare-value.change", {
action: "create",
...formInput(formData),
});
revalidatePath("/admin/rare-values");
}
export async function deleteValue(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
await executeLegacyEconomyMutation(staff, "rare-value.change", {
action: "delete",
...formInput(formData),
});
revalidatePath("/admin/rare-values");
}
-153
View File
@@ -1,153 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import {
type SystemMutationContext,
type SystemMutationOperation,
systemMutationService,
} from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { actionOk, adminAction } from "@/lib/foundation/action";
import type { AdminActionContext } from "@/lib/foundation/types";
import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions";
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value);
}
return value;
}
function actionMutationContext(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
): SystemMutationContext {
return {
capability: createHousekeepingCapabilityContext(
{
id: Number(ctx.session.user.id),
rank: Number(ctx.session.user.rank),
username: ctx.session.user.username,
},
ctx.permissions,
),
correlationId: String(ctx.requestId),
};
}
function checkedMutationContext(staff: {
id: number;
rank: number;
username: string;
}): SystemMutationContext {
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
return {
capability: createHousekeepingCapabilityContext(staff, {
isSuperAdmin: false,
has: matches,
hasAny: (...slugs) => slugs.some(matches),
hasAll: (...slugs) => slugs.every(matches),
}),
correlationId: createCorrelationId(),
};
}
async function runMutation(
context: SystemMutationContext,
operation: SystemMutationOperation,
input: unknown,
): Promise<unknown> {
const result = await systemMutationService.execute(context, operation, input);
if (!result.ok) throw new Error(result.error.messageKey);
return result.data;
}
const saveManagedSchema = z.object({
settings: z.record(z.string(), z.string()),
});
export const saveManagedSettings = adminAction(
{
permission: PERMS.SETTINGS_EDIT,
schema: saveManagedSchema,
rateLimitKey: "admin-settings-save",
rateLimitMax: 30,
},
async (ctx) => {
const entries = Object.entries(ctx.data.settings)
.filter(([key]) => managedKeySet.has(key))
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
const mutation = (await runMutation(
actionMutationContext(ctx),
"configuration.settings.save",
{ settings: Object.fromEntries(entries) },
)) as { saved: number };
revalidatePath("/admin/settings");
revalidatePath("/admin/catalog");
return actionOk({ saved: mutation.saved });
},
);
export async function updateSetting(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
if (!key) return;
await runMutation(
checkedMutationContext(staff),
"configuration.setting.update",
{ key, value },
);
revalidatePath("/admin/settings");
}
export async function createSetting(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
const comment = String(formData.get("comment") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!key) return;
await runMutation(
checkedMutationContext(staff),
"configuration.setting.create",
{ key, value, comment },
);
revalidatePath("/admin/settings");
}
export async function deleteSetting(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
if (!key) return;
await runMutation(
checkedMutationContext(staff),
"configuration.setting.delete",
{ key },
);
revalidatePath("/admin/settings");
}
-63
View File
@@ -1,63 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
return Object.fromEntries(formData.entries());
}
export async function createShopArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
try {
await executeLegacyEconomyMutation(staff, "shop-article.change", {
action: "create",
...formInput(formData),
});
} catch (error) {
logServerError("admin.shop_create_failed", error, { staffId: staff.id });
return;
}
redirect("/admin/shop");
}
export async function updateShopArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
const input = formInput(formData);
try {
await executeLegacyEconomyMutation(staff, "shop-article.change", {
action: "update",
...input,
});
} catch (error) {
logServerError("admin.shop_update_failed", error, {
staffId: staff.id,
articleId: String(input.id ?? ""),
});
return;
}
revalidatePath(`/admin/shop/${String(input.id ?? "")}`);
redirect("/admin/shop");
}
export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
const input = formInput(formData);
try {
await executeLegacyEconomyMutation(staff, "shop-article.change", {
action: "delete",
...input,
});
} catch (error) {
logServerError("admin.shop_delete_failed", error, {
staffId: staff.id,
articleId: String(input.id ?? ""),
});
return;
}
redirect("/admin/shop");
}
+2 -2
View File
@@ -40,7 +40,7 @@ describe("Content tag legacy wrappers", () => {
name: "News",
backgroundColor: "#ff0000",
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
});
it("uses the legacy default color", async () => {
await createTag(form({ name: "Test" }));
@@ -64,7 +64,7 @@ describe("Content tag legacy wrappers", () => {
correlationId: "legacy",
});
await createTag(form({ name: "News" }));
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
});
it("delegates update", async () => {
await updateTag(
+3 -3
View File
@@ -30,7 +30,7 @@ export async function createTag(formData: FormData): Promise<void> {
"tag.change",
{ action: "create", ...input },
);
revalidatePath("/admin/tags");
revalidatePath("/ase/content/editorial/tags");
}
export async function updateTag(formData: FormData): Promise<void> {
@@ -44,7 +44,7 @@ export async function updateTag(formData: FormData): Promise<void> {
"tag.change",
{ action: "update", id, ...input },
);
revalidatePath("/admin/tags");
revalidatePath("/ase/content/editorial/tags");
}
export async function deleteTag(formData: FormData): Promise<void> {
@@ -56,5 +56,5 @@ export async function deleteTag(formData: FormData): Promise<void> {
"tag.change",
{ action: "delete", id },
);
revalidatePath("/admin/tags");
revalidatePath("/ase/content/editorial/tags");
}
+2 -2
View File
@@ -33,7 +33,7 @@ export async function createTeam(formData: FormData): Promise<void> {
},
);
if (!result.ok) throw new Error("Could not create team");
revalidatePath("/admin/teams");
revalidatePath("/ase/people/staff/teams");
}
export async function deleteTeam(formData: FormData): Promise<void> {
@@ -49,5 +49,5 @@ export async function deleteTeam(formData: FormData): Promise<void> {
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not delete team");
}
revalidatePath("/admin/teams");
revalidatePath("/ase/people/staff/teams");
}
-116
View File
@@ -1,116 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
function formValues(formData: FormData): Record<string, string> {
return Object.fromEntries(
Array.from(formData.entries(), ([key, value]) => [
key,
typeof value === "string" ? value : value.name,
]),
);
}
async function executeTheme(
operation:
| "theme.update"
| "theme.apply-preset"
| "theme.custom-change"
| "theme.apply-custom",
input: Record<string, unknown>,
) {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
return contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
export async function saveTheme(formData: FormData): Promise<void> {
const result = await executeTheme("theme.update", {
values: formValues(formData),
});
if (result.ok) revalidatePath("/", "layout");
redirect("/admin/theme?saved=1");
}
export async function applyPreset(formData: FormData): Promise<void> {
const name = String(formData.get("preset") ?? "").normalize("NFC");
const result = await executeTheme("theme.apply-preset", { preset: name });
if (result.ok) revalidatePath("/", "layout");
redirect(
result.ok
? `/admin/theme?preset=${encodeURIComponent(name)}`
: "/admin/theme",
);
}
export async function saveCustomTheme(formData: FormData): Promise<void> {
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim();
if (!name) redirect("/admin/theme");
const result = await executeTheme("theme.custom-change", {
action: "create",
name,
});
if (result.ok) revalidatePath("/admin/theme");
redirect("/admin/theme?savedTheme=1");
}
export async function applyCustomTheme(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!id) redirect("/admin/theme");
const result = await executeTheme("theme.apply-custom", { id });
if (result.ok) revalidatePath("/", "layout");
const name =
result.ok && typeof result.data.output?.name === "string"
? result.data.output.name
: "";
redirect(
result.ok
? `/admin/theme?theme=${encodeURIComponent(name)}`
: "/admin/theme",
);
}
export async function renameCustomTheme(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim();
if (!id || !name) redirect("/admin/theme");
const result = await executeTheme("theme.custom-change", {
action: "rename",
id,
name,
});
if (result.ok) revalidatePath("/admin/theme");
redirect("/admin/theme?renamed=1");
}
export async function deleteCustomTheme(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!id) redirect("/admin/theme");
const result = await executeTheme("theme.custom-change", {
action: "delete",
id,
});
if (result.ok) revalidatePath("/admin/theme");
redirect("/admin/theme?deletedTheme=1");
}
-52
View File
@@ -1,52 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { logServerError } from "@/lib/server-log";
export async function createVoucher(input: {
code: string;
amount: number;
maxUses: number;
expiresAt?: string;
}): Promise<ActionResult<{ id: string }>> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
try {
const snapshot = await executeLegacyEconomyMutation(
staff,
"voucher.change",
{ action: "create", ...input },
);
revalidatePath("/admin/vouchers");
return actionOk({ id: String(snapshot.output?.id) });
} catch (error) {
logServerError("admin.voucher_create_failed", error);
return actionError("Could not create voucher (code may already exist)");
}
}
export async function deleteVoucher(input: {
id: string;
}): Promise<ActionResult> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
try {
await executeLegacyEconomyMutation(staff, "voucher.change", {
action: "delete",
id: input.id,
});
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
logServerError("admin.voucher_delete_failed", error, {
voucherId: input.id,
});
return actionError("Could not delete voucher");
}
}
+4 -4
View File
@@ -37,7 +37,7 @@ beforeEach(() => {
});
});
it("preserves VPN payload, /admin revalidation, and redirect", async () => {
it("preserves VPN payload, ASE revalidation, and redirect", async () => {
await saveVpn(
form({
vpn_block_enabled: "1",
@@ -51,8 +51,8 @@ it("preserves VPN payload, /admin revalidation, and redirect", async () => {
apiKey: "abc123",
blockMessage: "",
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn");
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/vpn");
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
});
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
@@ -66,5 +66,5 @@ it("preserves fail-soft redirect without claiming a saved revalidation", async (
});
await saveVpn(form({ vpn_provider: "none" }));
expect(revalidatePath).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
});
+2 -2
View File
@@ -35,7 +35,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
.slice(0, 255),
},
);
if (result.ok) revalidatePath("/admin/vpn");
if (result.ok) revalidatePath("/ase/people/moderation/vpn");
// Preserve fail-soft legacy navigation even when persistence is unavailable.
redirect("/admin/vpn?saved=1");
redirect("/ase/people/moderation/vpn?saved=1");
}
+2 -2
View File
@@ -31,7 +31,7 @@ export async function addWord(input: {
);
if (!result.ok)
return actionError("Could not add word (it may already exist)");
revalidatePath("/admin/wordfilter");
revalidatePath("/ase/people/moderation/word-filter");
return actionOk({ id: String(result.data.after?.id ?? "") });
}
@@ -48,6 +48,6 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
if (!result.ok && result.error.code !== "NOT_FOUND") {
return actionError("Could not remove word");
}
revalidatePath("/admin/wordfilter");
revalidatePath("/ase/people/moderation/word-filter");
return actionOk();
}
-66
View File
@@ -1,66 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
function boxInput(formData: FormData) {
const position = Number(formData.get("position"));
return {
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
icon: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
content: String(formData.get("content") ?? "").normalize("NFC"),
position:
Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0,
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
};
}
function refreshBoxes(): void {
revalidatePath("/admin/writeable-boxes");
revalidatePath("/", "layout");
}
async function executeBox(
formData: FormData,
action: "create" | "update" | "delete" | "toggle",
): Promise<boolean> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (action !== "create" && !/^\d+$/u.test(id)) return false;
const input = boxInput(formData);
if ((action === "create" || action === "update") && !input.title)
return false;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"writeable-box.change",
{ action, ...(id ? { id } : {}), ...input, next: formData.get("next") },
);
return result.ok;
}
export async function createBox(formData: FormData): Promise<void> {
if (await executeBox(formData, "create")) refreshBoxes();
}
export async function updateBox(formData: FormData): Promise<void> {
if (await executeBox(formData, "update")) refreshBoxes();
}
export async function deleteBox(formData: FormData): Promise<void> {
if (await executeBox(formData, "delete")) refreshBoxes();
}
export async function toggleBox(formData: FormData): Promise<void> {
if (await executeBox(formData, "toggle")) refreshBoxes();
}
-54
View File
@@ -1,54 +0,0 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function getBadgeData({ code }: { code: string }) {
await requirePermission(PERMS.CATALOG_EDIT);
const [badge] = await db
.select({
badgeName: WebsiteBadges.badgeName,
badgeDescription: WebsiteBadges.badgeDescription,
})
.from(WebsiteBadges)
.where(eq(WebsiteBadges.badgeKey, code))
.limit(1);
if (!badge) return { ok: false as const, data: null };
return {
ok: true as const,
data: { name: badge.badgeName, desc: badge.badgeDescription },
};
}
export async function updateBadge({
code,
name,
desc,
}: {
code: string;
name: string;
desc: string;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const now = new Date();
await db
.insert(WebsiteBadges)
.values({
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: now,
updatedAt: now,
})
.onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: desc,
updatedAt: now,
},
});
revalidatePath("/admin/import/badges");
}
-71
View File
@@ -1,71 +0,0 @@
"use server";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const bannerSchema = z.object({
title: z.string().min(1).max(255),
subtitle: z.string().max(500).optional().default(""),
image: z.string().max(500),
link: z.string().max(500).optional().default(""),
color: z.string().max(20).optional().default(""),
isActive: z.coerce.number().int().min(0).max(1).default(1),
sortOrder: z.coerce.number().int().min(0).default(0),
startDate: z.string().max(50).nullable().optional(),
endDate: z.string().max(50).nullable().optional(),
});
async function runBanner(
ctx: {
data: Record<string, unknown>;
requestId: unknown;
session: { user: { id: number } };
},
action: "create" | "update" | "delete",
) {
return contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"banner.change",
{ action, ...ctx.data },
);
}
export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => {
const result = await runBanner(ctx, "create");
if (!result.ok) throw new ActionError("Banner creation failed");
return actionOk({ id: Number(result.data.output?.id) });
},
);
const updateBannerInput = bannerSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => {
const result = await runBanner(ctx, "update");
if (!result.ok) throw new ActionError("Banner not found");
return actionOk({ id: ctx.data.id });
},
);
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => {
const result = await runBanner(ctx, "delete");
if (!result.ok) throw new ActionError("Banner not found");
return actionOk();
},
);
+1 -2
View File
@@ -6,8 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
function revalidateBuilderClub(): void {
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
revalidatePath("/ase/economy/catalog");
}
export async function updateBcPage({
+1 -1
View File
@@ -9,7 +9,7 @@ import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
function revalidateCatalog(): void {
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
}
export async function createCatalogItem(data: {
+6 -6
View File
@@ -16,7 +16,7 @@ export async function updateCatalogPage({
id,
...fields,
});
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
return { ok: true as const, data: {} };
}
@@ -26,7 +26,7 @@ export async function deleteCatalogPage({ id }: { id: number }) {
action: "delete",
id,
});
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
return { ok: true as const, data: {} };
}
@@ -42,7 +42,7 @@ export async function toggleCatalogPage({
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
id,
});
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
return { ok: true as const, data: {} };
}
@@ -64,7 +64,7 @@ export async function createCatalogPage(input: {
{ action: "create", ...input },
);
const createdId = Number(snapshot.output?.id);
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
return { ok: true as const, data: { id: createdId } };
}
@@ -75,7 +75,7 @@ export async function reorderTreePage(input: {
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
return { ok: true as const, data: {} };
}
@@ -85,6 +85,6 @@ export async function deleteTreePage(input: {
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
revalidatePath("/admin/catalog");
revalidatePath("/ase/economy/catalog");
return { ok: true as const, data: {} };
}
-283
View File
@@ -1,283 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
type SystemMutationContext,
type SystemMutationOperation,
systemMutationService,
} from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import type { AdminActionContext } from "@/lib/foundation/types";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?";
function mutationContext(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
): SystemMutationContext {
return {
capability: createHousekeepingCapabilityContext(
{
id: Number(ctx.session.user.id),
rank: Number(ctx.session.user.rank),
username: ctx.session.user.username,
},
ctx.permissions,
),
correlationId: String(ctx.requestId),
};
}
async function runRconMutation(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
operation: SystemMutationOperation,
input: unknown,
): Promise<void> {
const result = await systemMutationService.execute(
mutationContext(ctx),
operation,
input,
);
if (result.ok) return;
if (result.error.messageKey === "errors.housekeeping.system.userNotFound") {
throw new ActionError("User not found");
}
if (result.error.messageKey === "errors.housekeeping.system.rankNotFound") {
throw new ActionError("Rank does not exist");
}
if (
result.error.messageKey ===
"errors.housekeeping.system.cannotChangePeerRank"
) {
throw new ActionError("Cannot change rank of a user at or above your rank");
}
if (
result.error.messageKey ===
"errors.housekeeping.system.cannotAssignPeerRank"
) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
throw new ActionError(RCON_FAIL);
}
function revalidate(): void {
revalidatePath(PATH);
}
export const updateCatalog = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async (ctx) => {
await runRconMutation(ctx, "rcon.update-catalog", {});
revalidate();
return actionOk();
},
);
export const updateWordFilter = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async (ctx) => {
await runRconMutation(ctx, "rcon.update-word-filter", {});
revalidate();
return actionOk();
},
);
export const updateNavigator = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async (ctx) => {
await runRconMutation(ctx, "rcon.update-navigator", {});
revalidate();
return actionOk();
},
);
const hotelAlertSchema = z.object({
message: z.string().trim().min(1).max(512),
});
export const hotelAlert = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.hotel-alert", {
message: ctx.data.message.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
username: z.string().trim().min(1),
});
export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.disconnect-user", {
userId: ctx.data.userId,
username: ctx.data.username.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().trim().min(1).max(512),
});
export const alertUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.alert-user", {
userId: ctx.data.userId,
message: ctx.data.message.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
const forwardUserSchema = z.object({
userId: z.coerce.number().int().positive(),
roomId: z.coerce.number().int().positive(),
});
export const forwardUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.forward-user", ctx.data);
revalidate();
return actionOk();
},
);
const giveCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
credits: z.coerce.number().int().positive(),
});
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.give-credits", {
userId: ctx.data.userId,
amount: ctx.data.credits,
});
revalidate();
return actionOk();
},
);
const giveAmountSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().positive(),
});
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.give-duckets", ctx.data);
revalidate();
return actionOk();
},
);
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.give-diamonds", ctx.data);
revalidate();
return actionOk();
},
);
const giveBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badge: z.string().trim().min(1).max(32),
});
export const giveBadge = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.give-badge", {
userId: ctx.data.userId,
badge: ctx.data.badge.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
const setMottoSchema = z.object({
userId: z.coerce.number().int().positive(),
motto: z.string().trim().min(1).max(127),
});
export const setMotto = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.set-motto", {
userId: ctx.data.userId,
motto: ctx.data.motto.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
const setRankSchema = z.object({
userId: z.coerce.number().int().positive(),
rank: z.coerce.number().int().min(1).max(9999),
});
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.set-rank", ctx.data);
revalidate();
return actionOk();
},
);
const executeCommandSchema = z.object({
userId: z.coerce.number().int().positive(),
command: z.string().trim().min(1).max(100),
});
export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.execute-command", {
userId: ctx.data.userId,
command: ctx.data.command.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
const sendGiftSchema = z.object({
userId: z.coerce.number().int().positive(),
itemId: z.coerce.number().int().positive(),
message: z.string().trim().max(255).optional().default("Here is a gift."),
});
export const sendGift = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
async (ctx) => {
await runRconMutation(ctx, "rcon.send-gift", {
userId: ctx.data.userId,
itemId: ctx.data.itemId,
message: ctx.data.message.trim().slice(0, 255) || "Here is a gift.",
});
revalidate();
return actionOk();
},
);
+3 -28
View File
@@ -136,7 +136,7 @@ beforeEach(() => {
});
});
describe("Content legacy wrappers", () => {
describe("Content compatibility wrappers", () => {
it("delegates article creation and preserves redirect ordering", async () => {
await createArticle(
form({
@@ -151,7 +151,7 @@ describe("Content legacy wrappers", () => {
"article.change",
expect.objectContaining({ action: "create", title: "Launch" }),
);
expect(redirect).toHaveBeenCalledWith("/admin/articles");
expect(redirect).toHaveBeenCalledWith("/ase/content/editorial/articles");
});
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
@@ -165,7 +165,7 @@ describe("Content legacy wrappers", () => {
"ad.change",
expect.objectContaining({ action: "create" }),
);
expect(redirect).toHaveBeenCalledWith("/admin/ads");
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
});
it("delegates media and favicon uploads while retaining public result shapes", async () => {
@@ -308,22 +308,14 @@ describe("Content legacy wrappers", () => {
for (const path of [
"src/actions/admin-ads.ts",
"src/actions/admin-articles.ts",
"src/actions/admin-banners.ts",
"src/actions/admin-email-templates.ts",
"src/actions/admin-help.ts",
"src/actions/admin-media.ts",
"src/actions/admin-nav-menu.ts",
"src/actions/admin-photos.ts",
"src/actions/admin-tags.ts",
"src/actions/admin-theme.ts",
"src/actions/admin-writeable-boxes.ts",
"src/actions/banners.ts",
"src/actions/events.ts",
"src/actions/polls.ts",
"src/actions/prefixes.ts",
"src/actions/save-favicon.ts",
"src/actions/save-logo.ts",
"src/actions/translations.ts",
"src/actions/emulator.ts",
]) {
const source = readFileSync(path, "utf8");
@@ -335,21 +327,4 @@ describe("Content legacy wrappers", () => {
).toBe(true);
}
});
it("routes the real banner manager through the required compatibility action", () => {
const source = readFileSync(
"src/app/admin/banners/banners-manager.tsx",
"utf8",
);
expect(source).toContain('from "@/actions/admin-banners"');
expect(source).not.toContain('from "@/actions/banners"');
});
it("exports explicit async banner wrappers accepted by Next server actions", () => {
const source = readFileSync("src/actions/admin-banners.ts", "utf8");
expect(source).not.toMatch(/export\s*\{/u);
for (const action of ["createBanner", "updateBanner", "deleteBanner"]) {
expect(source).toContain(`export async function ${action}`);
}
});
});
-128
View File
@@ -1,128 +0,0 @@
"use server";
import {
type EconomyMutationOperation,
executeLegacyEconomyMutation,
} from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionOk,
handleActionError,
} from "@/lib/safe-action-shared";
import type {
AlignResult,
DedupResult,
FixOfferResult,
FixSpriteResult,
FurniHealth,
ReconcileResult,
} from "@/lib/services/furni-maintenance";
import * as maintenance from "@/lib/services/furni-maintenance";
async function guard() {
return requirePermission(PERMS.CATALOG_EDIT);
}
async function mutate<T>(
operation: EconomyMutationOperation,
input: Record<string, unknown> = {},
): Promise<T> {
const staff = await guard();
const snapshot = await executeLegacyEconomyMutation(staff, operation, input);
return snapshot.output as T;
}
export async function getFurniHealthAction(): Promise<
ActionResult<{ health: FurniHealth }>
> {
try {
await guard();
const health = await maintenance.getFurniHealth();
return actionOk({ health });
} catch (e) {
return handleActionError(e);
}
}
export async function fixSpriteIdsAction(): Promise<
ActionResult<FixSpriteResult>
> {
try {
return actionOk(
await mutate<FixSpriteResult>("maintenance.fix-sprite-ids"),
);
} catch (e) {
return handleActionError(e);
}
}
export async function fixCatalogOffersAction(): Promise<
ActionResult<FixOfferResult>
> {
try {
return actionOk(await mutate<FixOfferResult>("maintenance.fix-offers"));
} catch (e) {
return handleActionError(e);
}
}
export async function reconcileIdsAction(): Promise<
ActionResult<ReconcileResult>
> {
try {
return actionOk(await mutate<ReconcileResult>("maintenance.reconcile-ids"));
} catch (e) {
return handleActionError(e);
}
}
export async function removeDuplicateItemsBaseAction(): Promise<
ActionResult<DedupResult>
> {
try {
return actionOk(await mutate<DedupResult>("maintenance.remove-duplicates"));
} catch (e) {
return handleActionError(e);
}
}
export async function previewAlignIdsAction(): Promise<
ActionResult<AlignResult>
> {
try {
return actionOk(
await mutate<AlignResult>("maintenance.align-ids", { apply: false }),
);
} catch (e) {
return handleActionError(e);
}
}
export async function applyAlignIdsAction(): Promise<
ActionResult<AlignResult>
> {
try {
return actionOk(
await mutate<AlignResult>("maintenance.align-ids", { apply: true }),
);
} catch (e) {
return handleActionError(e);
}
}
export async function fixEverythingAction(input?: {
dedupePages?: boolean;
}): Promise<ActionResult<maintenance.FixAllResult>> {
try {
return actionOk(
await mutate<maintenance.FixAllResult>(
"maintenance.fix-everything",
input ?? {},
),
);
} catch (e) {
return handleActionError(e);
}
}
-38
View File
@@ -1,38 +0,0 @@
"use server";
import { z } from "zod";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { deleteImportedItem } from "@/lib/services/furni-import";
import { siteSettings } from "@/lib/services/site-settings";
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) =>
actionOk(
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
),
);
const translateToggleSchema = z.object({ enabled: z.boolean() });
/** Persist the global "translate furniture names" setting from the studio. */
export const setFurnidataTranslateEnabled = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: translateToggleSchema },
async (ctx) => {
const value = ctx.data.enabled ? "1" : "0";
await db
.insert(WebsiteSetting)
.values({ key: "furnidata_translate_enabled", value })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
return actionOk({ enabled: ctx.data.enabled });
},
);
-29
View File
@@ -1,29 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
const updateSchema = z.object({
id: z.coerce.number().int().positive(),
fields: z.record(z.string(), z.unknown()),
});
export const updateItemsBase = adminAction(
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
async (context) => {
const { id, fields } = context.data;
await executeLegacyEconomyMutation(
{ id: Number(context.session.user.id) },
"items-base.update",
{ id, fields },
);
revalidatePath("/admin/items");
revalidatePath(`/admin/items/${id}`);
revalidatePath("/admin/catalog");
return actionOk({ id });
},
);
-68
View File
@@ -1,68 +0,0 @@
"use server";
import { asc, count, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export interface MultiAccountCluster {
key: string;
label: string;
accountCount: number;
accounts: Array<{
id: number;
username: string;
rank: number;
online: string;
}>;
}
export async function detectMultiAccounts({
minAccounts,
limit,
}: {
minAccounts: number;
limit: number;
}) {
await requirePermission(PERMS.USERS_VIEW);
const clusters: MultiAccountCluster[] = [];
const accountCount = count(User.id);
const ipGroups = await db
.select({
ipCurrent: User.ipCurrent,
accountCount,
})
.from(User)
.where(ne(User.ipCurrent, ""))
.groupBy(User.ipCurrent)
.having(gte(accountCount, minAccounts))
.orderBy(desc(sql`COUNT(${User.id})`))
.limit(limit);
for (const group of ipGroups) {
const users = await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
})
.from(User)
.where(eq(User.ipCurrent, group.ipCurrent))
.orderBy(asc(User.id));
clusters.push({
key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`,
accountCount: Number(group.accountCount),
accounts: users.map((u) => ({
id: u.id,
username: u.username,
rank: u.rank,
online: u.online,
})),
});
}
return { ok: true as const, data: { clusters } };
}
+8 -4
View File
@@ -109,14 +109,16 @@ describe("legacy user safe-action wrappers", () => {
});
describe("legacy application and word-filter wrappers", () => {
it("keeps tolerant application dismissal and /admin revalidation", async () => {
it("keeps tolerant application dismissal and ASE revalidation", async () => {
await dismissApplication(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1 }),
"application.decide",
{ applicationId: "9", decision: "dismiss" },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/applications");
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/staff/applications",
);
});
it("preserves application and wordfilter IDs above Number.MAX_SAFE_INTEGER", async () => {
@@ -130,7 +132,7 @@ describe("legacy application and word-filter wrappers", () => {
{ action: "delete", id: "9007199254740993" },
]);
});
it("preserves word-filter ActionResult shapes and /admin revalidation", async () => {
it("preserves word-filter ActionResult shapes and ASE revalidation", async () => {
await expect(addWord({ word: "spam" })).resolves.toEqual({
ok: true,
data: { id: "12" },
@@ -143,6 +145,8 @@ describe("legacy application and word-filter wrappers", () => {
{ action: "add", word: "spam" },
{ action: "delete", id: "12" },
]);
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/moderation/word-filter",
);
});
});
@@ -188,18 +188,17 @@ describe("legacy People support and moderation wrappers", () => {
{ ticketId: "9007199254740993", status: "reopen" },
{ ticketId: "9007199254740993" },
]);
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
expect(revalidatePath).toHaveBeenCalledWith(
"/admin/help-tickets/9007199254740993",
"/ase/people/support/help-tickets",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/mod/help-tickets/9007199254740993",
"/ase/people/support/help-tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/help/tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
});
it("delegates every CFH and moderation transport action", async () => {
+4 -2
View File
@@ -79,7 +79,9 @@ describe("legacy wrapper failure compatibility", () => {
ok: true,
data: {},
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/staff/teams");
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/moderation/word-filter",
);
});
});
-85
View File
@@ -1,85 +0,0 @@
"use server";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const createPrefixSchema = z.object({
username: z.string().min(1),
text: z.string().min(1),
color: z.string().min(1),
icon: z.string().optional(),
effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).default(1),
});
const updatePrefixSchema = z.object({
id: z.coerce.number().int().positive(),
text: z.string().min(1),
color: z.string().min(1),
icon: z.string().optional(),
effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).optional(),
});
const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() });
const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) });
const removeBlacklistWordSchema = z.object({
id: z.coerce.number().int().positive(),
});
const updatePrefixSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
});
async function run(
ctx: {
data: Record<string, unknown>;
requestId: unknown;
session: { user: { id: number } };
},
operation:
| "prefix.change"
| "prefix-blacklist.change"
| "prefix-settings.update",
input: Record<string, unknown>,
) {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
operation,
input,
);
if (!result.ok && result.error.code === "NOT_FOUND")
throw new ActionError("User not found");
if (!result.ok) throw new Error(result.error.messageKey);
return actionOk();
}
export const createPrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
(ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data }),
);
export const updatePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
(ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data }),
);
export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
(ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data }),
);
export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
(ctx) => run(ctx, "prefix-blacklist.change", { action: "add", ...ctx.data }),
);
export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
(ctx) =>
run(ctx, "prefix-blacklist.change", { action: "remove", ...ctx.data }),
);
export const updatePrefixSettings = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
(ctx) => run(ctx, "prefix-settings.update", ctx.data),
);
+5 -5
View File
@@ -9,7 +9,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room-item.update", payload);
const roomId = Number(payload.roomId);
revalidatePath(`/admin/rooms/${roomId}/furni`);
revalidatePath(`/ase/hotel/rooms/${roomId}/furni`);
}
export async function bulkDeleteRoomItems(input: {
@@ -18,7 +18,7 @@ export async function bulkDeleteRoomItems(input: {
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
}
export async function deleteRoomItem(input: {
@@ -27,7 +27,7 @@ export async function deleteRoomItem(input: {
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room-item.delete", input);
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
}
export async function roomRconAction(input: {
@@ -41,7 +41,7 @@ export async function roomRconAction(input: {
export async function deleteRoom(input: { id: number }) {
const staff = await requirePermission(PERMS.ROOMS_DELETE);
await executeLegacyHotelMutation(staff, "room.delete", input);
revalidatePath("/admin/rooms");
revalidatePath("/ase/hotel/rooms");
}
export async function updateRoom(input: {
@@ -53,5 +53,5 @@ export async function updateRoom(input: {
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room.update", input);
revalidatePath(`/admin/rooms/${input.id}`);
revalidatePath(`/ase/hotel/rooms/${input.id}`);
}
+1 -1
View File
@@ -107,5 +107,5 @@ export async function deleteFavicon(): Promise<{
function siteRevalidate(): void {
revalidatePath("/", "layout");
revalidatePath("/admin/favicon");
revalidatePath("/ase/content/brand/favicon");
}
-30
View File
@@ -1,30 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
export async function deleteSoundtrack({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
action: "delete",
id,
});
revalidatePath("/admin/sounds");
}
export async function updateSoundtrack(input: {
id: number;
name: string;
author: string;
track: string;
length: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
action: "update",
...input,
});
revalidatePath("/admin/sounds");
}
-81
View File
@@ -1,81 +0,0 @@
"use server";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import { CLIENT_TRANSLATION_FILES } from "@/lib/client-translation-files";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({
locale: z.enum([
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
]),
data: z.record(z.string(), z.unknown()),
});
const saveClientTranslationsSchema = z.object({
fileId: z.enum(
CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]],
),
data: z.record(z.string(), z.string()),
});
export const saveTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"translation.cms.save",
ctx.data,
);
if (!result.ok) throw new ActionError("Translation save failed");
return actionOk();
},
);
export const saveClientTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"translation.client.save",
ctx.data,
);
if (!result.ok) throw new ActionError("Translation save failed");
return actionOk({
commentsLost: result.data.output?.commentsLost === true,
unpatchedKeys: Array.isArray(result.data.output?.unpatchedKeys)
? result.data.output.unpatchedKeys
: [],
});
},
);
-53
View File
@@ -1,53 +0,0 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { z } from "zod";
import { db, UserWatch } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(),
});
/**
* Toggle a watch entry for the calling staff on the given target user.
* If a row already exists it's removed; otherwise it's created with the
* provided reason (defaulting to empty). Returns the resulting state so
* the UI can flip the badge without re-fetching.
*/
export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => {
const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data;
const [existing] = await db
.select({ id: UserWatch.id })
.from(UserWatch)
.where(
and(
eq(UserWatch.staffId, staffId),
eq(UserWatch.targetUserId, targetUserId),
),
)
.limit(1);
if (existing) {
await db.delete(UserWatch).where(eq(UserWatch.id, existing.id));
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: false });
}
await db.insert(UserWatch).values({
staffId,
targetUserId,
reason: reason ?? "",
});
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true });
},
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* Public site chrome. Route group `(site)` keeps this off `/ase` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*/
export default async function SiteLayout({
-117
View File
@@ -1,117 +0,0 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { Achievements, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Achievement = {
name: string;
category: string;
level: number;
rewardAmount: number;
rewardType: number;
points: number | null;
progressNeeded: number;
};
export default async function AdminAchievements() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[];
try {
achievements = await db
.select({
name: Achievements.name,
category: Achievements.category,
level: Achievements.level,
rewardAmount: Achievements.rewardAmount,
rewardType: Achievements.rewardType,
points: Achievements.points,
progressNeeded: Achievements.progressNeeded,
})
.from(Achievements)
.orderBy(
asc(Achievements.category),
asc(Achievements.name),
asc(Achievements.level),
);
} catch {
achievements = [];
}
const groups = new Map<string, Achievement[]>();
for (const a of achievements) {
const list = groups.get(a.category) ?? [];
list.push(a);
groups.set(a.category, list);
}
const distinctNames = new Set(achievements.map((a) => a.name)).size;
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("achievementRows")}
value={achievements.length}
icon="🏆"
/>
<StatusCard
label={t("distinctAchievements")}
value={distinctNames}
icon="🎖️"
/>
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div>
{groups.size === 0 ? (
<div className="admin-empty">{t("noAchievements")}</div>
) : (
[...groups.entries()].map(([category, rows]) => (
<section key={category} className="mt-6">
<h2 className="admin-section-title">
{category}{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{rows.length}
</span>
</h2>
<div className="admin-card p-0 overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colName")}</th>
<th>{t("colLevel")}</th>
<th>{t("colProgress")}</th>
<th>{t("colRewardType")}</th>
<th>{t("colRewardAmount")}</th>
<th>{t("colPoints")}</th>
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr key={`${a.name}-${a.level}`}>
<td>
<strong>{a.name}</strong>
</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))
)}
</main>
);
}
-71
View File
@@ -1,71 +0,0 @@
import { eq } from "drizzle-orm";
import { Megaphone } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { updateAd } from "@/actions/admin-ads";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteAds } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../edit-ad-delete-button";
export default async function EditAd({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
let ad: typeof WebsiteAds.$inferSelect | null = null;
try {
const [row] = await db
.select()
.from(WebsiteAds)
.where(eq(WebsiteAds.id, BigInt(id)))
.limit(1);
ad = row ?? null;
} catch {
notFound();
}
if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("edit")} #{String(ad.id)}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← {t("title")}</Link>
</p>
</div>
</div>
<form action={updateAd} className="admin-card">
<input type="hidden" name="id" value={String(ad.id)} />
<input
name="image"
defaultValue={ad.image}
placeholder={t("form.imagePlaceholder")}
required
maxLength={255}
/>
<Button type="submit" variant="default">
{t("form.save")}
</Button>
</form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main>
);
}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { deleteAd } from "@/actions/admin-ads";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
export type AdRow = {
id: string;
image: string;
createdAt: string | null;
};
export function AdsTable({ ads }: { ads: AdRow[] }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete(row: AdRow) {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id: row.id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
});
}
return (
<>
{confirmDialog}
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt ?? "—"}
</td>
<td>
<div className="flex items-center gap-2">
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isPending}
onClick={() => handleDelete(ad)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("delete")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
@@ -1,44 +0,0 @@
"use client";
import { Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { deleteAd } from "@/actions/admin-ads";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
export function EditAdDeleteButton({ id }: { id: string }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete() {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
redirectTo: "/admin/ads",
});
}
return (
<>
{confirmDialog}
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={handleDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("form.deleteAd")}
</Button>
</>
);
}
-37
View File
@@ -1,37 +0,0 @@
import { Megaphone } from "lucide-react";
import { redirect } from "next/navigation";
import { createAd } from "@/actions/admin-ads";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewAd() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) {
redirect("/admin");
}
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
New advertisement
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link>
</p>
</div>
</div>
<form action={createAd} className="admin-card">
<input name="image" placeholder="Image URL" required maxLength={255} />
<Button type="submit" variant="default">
Create
</Button>
</form>
</main>
);
}
-66
View File
@@ -1,66 +0,0 @@
import { desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteAds } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdsTable } from "./ads-table";
export default async function AdminAds() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.ads");
let ads: (typeof WebsiteAds.$inferSelect)[] = [];
try {
ads = await db
.select()
.from(WebsiteAds)
.orderBy(desc(WebsiteAds.id))
.limit(500);
} catch {
ads = [];
}
const latest = ads[0]?.createdAt ? formatDate(ads[0].createdAt, "date") : "—";
return (
<main>
<div className="flex justify-end mb-6">
<Button variant="default" asChild>
<Link href="/admin/ads/new">+ {t("newAd")}</Link>
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" />
<StatusCard
label={t("latest")}
value={latest}
hint={t("mostRecentAd")}
icon="🕒"
/>
</div>
<section className="mt-6">
<h2 className="admin-section-title">{t("allAds")}</h2>
{ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div>
) : (
<AdsTable
ads={ads.map((ad) => ({
id: String(ad.id),
image: ad.image,
createdAt: ad.createdAt ? formatDate(ad.createdAt, "date") : null,
}))}
/>
)}
</section>
</main>
);
}
-1
View File
@@ -1 +0,0 @@
export { default } from "../loading";
-179
View File
@@ -1,179 +0,0 @@
import { desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { markAllAlertsRead, sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { AlertLogs, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
case "error":
case "danger":
return "var(--color-danger)";
case "warning":
case "warn":
return "var(--admin-accent)";
case "success":
return "var(--color-secondary)";
case "info":
case "notice":
return "var(--color-accent)";
default:
return "var(--admin-text-muted)";
}
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: "inline-block",
padding: "0.1rem 0.55rem",
borderRadius: "999px",
background: color,
color: "#fff",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.02em",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</span>
);
}
export default async function AdminAlerts() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NOTIFICATIONS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.alerts");
let alerts: (typeof AlertLogs.$inferSelect)[] = [];
try {
alerts = await db
.select()
.from(AlertLogs)
.orderBy(desc(AlertLogs.id))
.limit(100);
} catch {
alerts = [];
}
const critical = alerts.filter((a) =>
CRITICAL.has((a.severity || "").toLowerCase()),
).length;
const warnings = alerts.filter((a) =>
WARNING.has((a.severity || "").toLowerCase()),
).length;
const unread = alerts.filter((a) => !a.isRead).length;
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard
label={t("critical")}
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard
label={t("warnings")}
value={warnings}
state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️"
/>
<StatusCard
label={t("unread")}
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<form action={sendHotelAlert} className="admin-card mb-6">
<h3 className="mt-0">{t("form.send")}</h3>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t.rich("form.description", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="flex gap-2 flex-wrap items-start">
<textarea
name="message"
required
maxLength={1000}
rows={2}
placeholder={t("form.messagePlaceholder")}
className="flex-1 min-w-[240px]"
/>
<Button type="submit" variant="default">
{t("form.send")}
</Button>
</div>
</form>
<section className="admin-card">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="m-0">{t("recentAlerts", { count: alerts.length })}</h3>
{unread > 0 ? (
<form action={markAllAlertsRead}>
<Button type="submit" variant="outline" size="sm">
{t("markAllRead")}
</Button>
</form>
) : null}
</div>
{alerts.length === 0 ? (
<div className="admin-empty">{t("noAlerts")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colSeverity")}</th>
<th>{t("colType")}</th>
<th>{t("colMessage")}</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(a.createdAt)}
</td>
<td>
<Badge
label={a.severity || "—"}
color={severityColor(a.severity)}
/>
</td>
<td>
<Badge
label={a.type || "—"}
color="var(--admin-text-muted)"
/>
</td>
<td>{a.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
-333
View File
@@ -1,333 +0,0 @@
import { count, gte, inArray, sql } from "drizzle-orm";
import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
type DayRow = {
label: string;
chats: number;
commands: number;
bans: number;
registrations: number;
};
type ActivityData = {
dailyData: DayRow[];
weekChats: number;
weekCommands: number;
weekBans: number;
weekRegistrations: number;
topChatters: { userId: number; username: string; messages: number }[];
};
async function safeCount(
fn: () => Promise<number>,
): Promise<{ value: number; ok: boolean }> {
try {
return { value: await fn(), ok: true };
} catch {
return { value: 0, ok: false };
}
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
async function safeRawCount(
query: ReturnType<typeof sql>,
): Promise<{ value: number; ok: boolean }> {
try {
const rows = await rawRows<{ count: bigint }>(query);
return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch {
return { value: 0, ok: false };
}
}
async function getActivityData(
locale: string,
): Promise<ActivityData & { degraded: boolean }> {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const rangeStart = todayStart - 6 * 86400;
let degraded = false;
const emptyDays: DayRow[] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const date = new Date(dayStart * 1000);
emptyDays.push({
label: date.toLocaleDateString(locale, {
weekday: "short",
month: "short",
day: "numeric",
}),
chats: 0,
commands: 0,
bans: 0,
registrations: 0,
});
}
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
let chatMap = new Map<number, number>();
let cmdMap = new Map<number, number>();
let banMap = new Map<number, number>();
let regMap = new Map<number, number>();
try {
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
]);
chatMap = toMap(chatRows);
cmdMap = toMap(cmdRows);
banMap = toMap(banRows);
regMap = toMap(regRows);
} catch {
degraded = true;
}
const dailyData = emptyDays.map((day, idx) => {
const dayStart = todayStart - (6 - idx) * 86400;
const bucket = Math.floor(dayStart / 86400);
return {
...day,
chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0,
registrations: regMap.get(bucket) ?? 0,
};
});
const [weekChats, weekCommands, weekBans, weekRegistrations] =
await Promise.all([
safeRawCount(sql`
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`),
safeRawCount(sql`
SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, weekAgo))
)[0]?.total ?? 0,
),
),
]);
for (const r of [weekChats, weekCommands, weekBans, weekRegistrations]) {
if (!r.ok) degraded = true;
}
let topChatters: ActivityData["topChatters"] = [];
try {
const rows = await rawRows<{ userId: number; messages: bigint }>(sql`
SELECT user_from_id AS userId, COUNT(*) AS messages
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
`);
const userIds = rows.map((c) => Number(c.userId)).filter((n) => n > 0);
const users =
userIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
topChatters = rows.map((c) => {
const userId = Number(c.userId);
return {
userId,
username: userMap.get(userId) ?? "",
messages: Number(c.messages),
};
});
} catch {
topChatters = [];
degraded = true;
}
return {
dailyData,
weekChats: weekChats.value,
weekCommands: weekCommands.value,
weekBans: weekBans.value,
weekRegistrations: weekRegistrations.value,
topChatters,
degraded,
};
}
export default async function ActivityPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics.activity");
const tRoot = await getTranslations("pages.admin.analytics");
const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () =>
getActivityData(locale),
);
return (
<div className="space-y-6">
{data.degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{tRoot("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title={t("chatMessages")}
value={data.weekChats.toLocaleString(locale)}
description={t("last7Days")}
icon={<MessageSquare className="h-4 w-4" />}
/>
<StatsCard
title={t("commands")}
value={data.weekCommands.toLocaleString(locale)}
description={t("last7Days")}
icon={<Terminal className="h-4 w-4" />}
/>
<StatsCard
title={t("bans")}
value={data.weekBans.toLocaleString(locale)}
description={t("last7Days")}
icon={<Shield className="h-4 w-4" />}
/>
<StatsCard
title={t("registrations")}
value={data.weekRegistrations.toLocaleString(locale)}
description={t("last7Days")}
icon={<Users className="h-4 w-4" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t("dailyBreakdownTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground">
<th className="text-left py-2 font-medium">
{t("colDay")}
</th>
<th className="text-right py-2 font-medium">
{t("colChats")}
</th>
<th className="text-right py-2 font-medium">
{t("colCmds")}
</th>
<th className="text-right py-2 font-medium">
{t("colBans")}
</th>
<th className="text-right py-2 font-medium">
{t("colRegs")}
</th>
</tr>
</thead>
<tbody>
{data.dailyData.map((day) => (
<tr key={day.label} className="border-b last:border-0">
<td className="py-2">{day.label}</td>
<td className="text-right tabular-nums">
{day.chats.toLocaleString(locale)}
</td>
<td className="text-right tabular-nums">
{day.commands.toLocaleString(locale)}
</td>
<td className="text-right tabular-nums">{day.bans}</td>
<td className="text-right tabular-nums">
{day.registrations}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("mostActiveUsersTitle")}</CardTitle>
</CardHeader>
<CardContent>
{data.topChatters.length === 0 ? (
<div className="admin-empty">{t("noChatActivity")}</div>
) : (
<div className="divide-y">
{data.topChatters.map((user, i) => (
<div
key={user.userId}
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
{i + 1}
</span>
<span className="flex-1 text-sm font-medium">
{user.username || t("userFallback", { id: user.userId })}
</span>
<span className="text-sm tabular-nums text-muted-foreground">
{t("msgCount", {
count: user.messages.toLocaleString(locale),
})}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
-346
View File
@@ -1,346 +0,0 @@
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
import { ArrowLeftRight } from "lucide-react";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { RevenueChart } from "@/components/admin/revenue-chart";
import { StatsCard } from "@/components/admin/stats-card";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
db,
LogsShopPurchases,
MarketplaceItems,
RoomTradeLog,
User,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
type EconomyData = {
totalCredits: number;
totalPixels: number;
totalPoints: number;
shopPurchases: number;
shopCreditsSpent: number;
shopPointsSpent: number;
marketplaceActive: number;
marketplaceSold: number;
weekTrades: number;
revenueData: { label: string; credits: number; points: number }[];
};
const EMPTY: EconomyData = {
totalCredits: 0,
totalPixels: 0,
totalPoints: 0,
shopPurchases: 0,
shopCreditsSpent: 0,
shopPointsSpent: 0,
marketplaceActive: 0,
marketplaceSold: 0,
weekTrades: 0,
revenueData: [],
};
async function safeCount(fn: () => Promise<number>): Promise<number> {
try {
return await fn();
} catch {
return 0;
}
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
async function getEconomyData(
locale: string,
): Promise<{ data: EconomyData; degraded: boolean }> {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const rangeStart = todayStart - 6 * 86400;
let degraded = false;
try {
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
db
.select({ total: sum(User.credits) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
db
.select({ total: sum(User.pixels) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
db
.select({ total: sum(User.points) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
]);
const totalCredits = Number(creditsAgg[0]?.total ?? 0);
const totalPixels = Number(pixelsAgg[0]?.total ?? 0);
const totalPoints = Number(pointsAgg[0]?.total ?? 0);
let shopPurchases = 0;
let shopCreditsSpent = 0;
let shopPointsSpent = 0;
try {
const [shopRevenue] = await db
.select({
purchases: count(),
credits: sum(LogsShopPurchases.costCredits),
points: sum(LogsShopPurchases.costPoints),
})
.from(LogsShopPurchases)
.where(gte(LogsShopPurchases.timestamp, weekAgo));
shopPurchases = Number(shopRevenue?.purchases ?? 0);
shopCreditsSpent = Number(shopRevenue?.credits ?? 0);
shopPointsSpent = Number(shopRevenue?.points ?? 0);
} catch {
degraded = true;
}
const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all([
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(MarketplaceItems)
.where(eq(MarketplaceItems.state, 1))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(MarketplaceItems)
.where(
and(
eq(MarketplaceItems.state, 2),
gte(MarketplaceItems.soldTimestamp, weekAgo),
),
)
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(RoomTradeLog)
.where(gte(RoomTradeLog.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
]);
const revenueData: EconomyData["revenueData"] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const date = new Date(dayStart * 1000);
revenueData.push({
label: date.toLocaleDateString(locale, { weekday: "short" }),
credits: 0,
points: 0,
});
}
try {
const revRows = await rawRows<{
day_bucket: bigint;
credits: number | bigint;
points: number | bigint;
}>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits,
COALESCE(SUM(cost_points), 0) AS points
FROM logs_shop_purchases
WHERE timestamp >= ${rangeStart}
GROUP BY day_bucket ORDER BY day_bucket
`);
const revMap = new Map(
revRows.map((r) => [
Number(r.day_bucket),
{ credits: Number(r.credits), points: Number(r.points) },
]),
);
for (let i = 0; i < revenueData.length; i++) {
const dayStart = todayStart - (6 - i) * 86400;
const bucket = Math.floor(dayStart / 86400);
const entry = revMap.get(bucket);
if (entry) {
revenueData[i] = { ...revenueData[i], ...entry };
}
}
} catch {
degraded = true;
}
return {
degraded,
data: {
totalCredits,
totalPixels,
totalPoints,
shopPurchases,
shopCreditsSpent,
shopPointsSpent,
marketplaceActive,
marketplaceSold,
weekTrades,
revenueData,
},
};
} catch {
return { data: EMPTY, degraded: true };
}
}
export default async function EconomyPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics.economy");
const tRoot = await getTranslations("pages.admin.analytics");
const { data, degraded } = await redisCache(
`admin:analytics:economy:${locale}`,
120,
() => getEconomyData(locale),
);
return (
<div className="space-y-6">
{degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{tRoot("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title={t("creditsInCirculation")}
value={data.totalCredits.toLocaleString(locale)}
description={t("creditsDescription")}
icon={<CurrencyIcon kind="credits" size={16} />}
/>
<StatsCard
title={t("pixelsInCirculation")}
value={data.totalPixels.toLocaleString(locale)}
description={t("pixelsDescription")}
icon={<CurrencyIcon kind="pixels" size={16} />}
/>
<StatsCard
title={t("pointsInCirculation")}
value={data.totalPoints.toLocaleString(locale)}
description={t("pointsDescription")}
icon={<CurrencyIcon kind="diamonds" size={16} />}
/>
<StatsCard
title={t("weeklyTrades")}
value={data.weekTrades.toLocaleString(locale)}
description={t("marketplaceListings", {
count: data.marketplaceActive,
})}
icon={<ArrowLeftRight className="h-4 w-4" />}
/>
</div>
<RevenueChart data={data.revenueData} />
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("shopRevenueTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("purchases")}</span>
<span className="font-medium">{data.shopPurchases}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("creditsSpent")}
</span>
<span className="font-medium">
{data.shopCreditsSpent.toLocaleString(locale)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("pointsSpent")}
</span>
<span className="font-medium">
{data.shopPointsSpent.toLocaleString(locale)}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("marketplaceTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("activeListings")}
</span>
<span className="font-medium">{data.marketplaceActive}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("sold7d")}</span>
<span className="font-medium">{data.marketplaceSold}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("currencyDistributionTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("credits")}</span>
<span className="font-medium">
{data.totalCredits.toLocaleString(locale)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("pixels")}</span>
<span className="font-medium">
{data.totalPixels.toLocaleString(locale)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("points")}</span>
<span className="font-medium">
{data.totalPoints.toLocaleString(locale)}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
-365
View File
@@ -1,365 +0,0 @@
import { count, eq, gt, gte, sql } from "drizzle-orm";
import {
ArrowLeftRight,
Clock,
DoorOpen,
MessageSquare,
TrendingUp,
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
type AnalyticsData = {
totalUsers: number;
onlineUsers: number;
weekRegistrations: number;
monthRegistrations: number;
totalRooms: number;
activeRooms: number;
weekChatMessages: number;
weekTrades: number;
weekBans: number;
registrationChart: { label: string; value: number }[];
peakHours: { grid: number[][]; max: number };
};
const EMPTY: AnalyticsData = {
totalUsers: 0,
onlineUsers: 0,
weekRegistrations: 0,
monthRegistrations: 0,
totalRooms: 0,
activeRooms: 0,
weekChatMessages: 0,
weekTrades: 0,
weekBans: 0,
registrationChart: [],
peakHours: {
grid: Array.from({ length: 7 }, () => Array(24).fill(0)),
max: 0,
},
};
async function safeCount(
fn: () => Promise<number>,
): Promise<{ value: number; ok: boolean }> {
try {
return { value: await fn(), ok: true };
} catch {
return { value: 0, ok: false };
}
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
async function getAnalyticsData(
locale: string,
): Promise<{ data: AnalyticsData; degraded: boolean }> {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400;
let degraded = false;
try {
const [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
] = await Promise.all([
safeCount(async () =>
Number((await db.select({ total: count() }).from(User))[0]?.total ?? 0),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, monthAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(await db.select({ total: count() }).from(Rooms))[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Rooms)
.where(gt(Rooms.users, 0))
)[0]?.total ?? 0,
),
),
(async () => {
try {
const rows = await rawRows<{ count: bigint }>(sql`
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`);
return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch {
return { value: 0, ok: false };
}
})(),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(RoomTradeLog)
.where(gte(RoomTradeLog.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
]);
for (const r of [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
]) {
if (!r.ok) degraded = true;
}
let registrationChart: { label: string; value: number }[] = [];
try {
const regRows = await rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users
WHERE account_created >= ${monthAgo}
GROUP BY day_bucket ORDER BY day_bucket
`);
const regCountMap = new Map(
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
);
for (let i = 29; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
registrationChart.push({
label: date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
}),
value: regCountMap.get(bucket) ?? 0,
});
}
} catch {
registrationChart = [];
degraded = true;
}
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0;
try {
const peakRows = await rawRows<{ d: number; h: number; c: bigint }>(sql`
SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
HOUR(FROM_UNIXTIME(last_online)) AS h,
COUNT(*) AS c
FROM users
WHERE last_online >= ${weekAgo}
GROUP BY d, h
`);
for (const r of peakRows) {
const day = Number(r.d);
const hour = Number(r.h);
if (day < 0 || day > 6 || hour < 0 || hour > 23) continue;
const cnt = Number(r.c);
grid[day][hour] = cnt;
if (cnt > max) max = cnt;
}
} catch {
degraded = true;
}
return {
degraded,
data: {
totalUsers: totalUsers.value,
onlineUsers: onlineUsers.value,
weekRegistrations: weekRegistrations.value,
monthRegistrations: monthRegistrations.value,
totalRooms: totalRooms.value,
activeRooms: activeRooms.value,
weekChatMessages: weekChatMessages.value,
weekTrades: weekTrades.value,
weekBans: weekBans.value,
registrationChart,
peakHours: { grid, max },
},
};
} catch {
return { data: EMPTY, degraded: true };
}
}
export default async function AnalyticsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics");
const { data, degraded } = await redisCache(
`admin:analytics:overview:${locale}`,
120,
() => getAnalyticsData(locale),
);
return (
<div className="space-y-6">
{degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{t("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title={t("totalUsers")}
value={data.totalUsers.toLocaleString(locale)}
description={t("onlineNow", { count: data.onlineUsers })}
icon={<Users className="h-4 w-4" />}
/>
<StatsCard
title={t("weekRegistrations")}
value={data.weekRegistrations.toLocaleString(locale)}
description={t("thisMonth", { count: data.monthRegistrations })}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatsCard
title={t("activeRooms")}
value={data.activeRooms.toLocaleString(locale)}
description={t("totalRooms", { count: data.totalRooms })}
icon={<DoorOpen className="h-4 w-4" />}
/>
<StatsCard
title={t("weeklyActivity")}
value={data.weekChatMessages.toLocaleString(locale)}
description={t("tradesAndBans", {
trades: data.weekTrades,
bans: data.weekBans,
})}
icon={<MessageSquare className="h-4 w-4" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<DashboardChart
title={t("registrationsChartTitle")}
data={data.registrationChart}
color="hsl(262, 83%, 58%)"
description={t("registrationsChartDescription")}
/>
<PeakHoursHeatmap data={data.peakHours} />
</div>
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" /> {t("chatActivity")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.weekChatMessages.toLocaleString(locale)}
</div>
<p className="text-sm text-muted-foreground">
{t("messagesLast7Days")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowLeftRight className="h-4 w-4" /> {t("trades")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.weekTrades.toLocaleString(locale)}
</div>
<p className="text-sm text-muted-foreground">
{t("tradesLast7Days")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4" /> {t("retention")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.totalUsers > 0
? Math.round((data.onlineUsers / data.totalUsers) * 100)
: 0}
%
</div>
<p className="text-sm text-muted-foreground">
{t("usersOnlineOverTotal")}
</p>
</CardContent>
</Card>
</div>
</div>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { desc, inArray } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteStaffApplications } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type ApplicationRow = {
id: bigint;
userId: number;
rankId: number;
content: string;
createdAt: Date | null;
};
export default async function AdminApplications() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[];
try {
applications = await db
.select()
.from(WebsiteStaffApplications)
.orderBy(desc(WebsiteStaffApplications.createdAt))
.limit(100);
} catch {
applications = [];
}
// Resolve applicant usernames (no relation defined on the model).
const userMap = new Map<number, string>();
if (applications.length > 0) {
try {
const ids = Array.from(new Set(applications.map((a) => a.userId)));
const users = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, ids));
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
}
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("pending")}
value={applications.length}
state={applications.length > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("applicationsTitle", { count: applications.length })}
</h2>
{applications.length === 0 ? (
<div className="admin-empty">{t("noApplications")}</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{applications.map((a) => {
const username = userMap.get(a.userId);
return (
<article key={String(a.id)} className="admin-card p-3">
<div className="flex items-baseline justify-between gap-2 mb-1">
<h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]">
{username ? (
<Link href={`/admin/users/${a.userId}`}>
{username}
</Link>
) : (
t("userNumber", { id: a.userId })
)}
</h3>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{t("rankLabel", { rank: a.rankId })}
</span>
</div>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0 mb-2">
{formatDate(a.createdAt, "datetime", "—")}
</p>
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
{a.content}
</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<Button type="submit" variant="destructive">
{t("dismiss")}
</Button>
</form>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
-84
View File
@@ -1,84 +0,0 @@
import { eq } from "drizzle-orm";
import { Newspaper } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteArticles } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function EditArticle({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const [article] = await db
.select({
id: WebsiteArticles.id,
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
image: WebsiteArticles.image,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, BigInt(id)))
.limit(1)
.catch(() => []);
if (!article) notFound();
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Newspaper size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("form.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/articles">← {t("title")}</Link>
</p>
</div>
</div>
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<ArticleForm
action={async (fd) => {
"use server";
fd.set("id", String(article.id));
await updateArticle(fd);
}}
defaultValues={{
title: article.title,
slug: article.slug,
image: article.image,
shortStory: article.shortStory,
fullStory: article.fullStory,
}}
/>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(article.id)} />
<Button type="submit" variant="default">
{t("form.deleteArticle")}
</Button>
</form>
</main>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { Newspaper } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
import Link from "@/components/link";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewArticle({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Newspaper size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("form.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/articles">← {t("title")}</Link>
</p>
</div>
</div>
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<ArticleForm action={createArticle} />
</main>
);
}
-107
View File
@@ -1,107 +0,0 @@
import { desc, inArray } from "drizzle-orm";
import { Newspaper } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { ArticleCard } from "@/components/admin/article-card";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteArticles } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminArticles({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
slug: WebsiteArticles.slug,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
userId: WebsiteArticles.userId,
})
.from(WebsiteArticles)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(50)
.catch(() => []);
const authorMap = new Map<number, string>();
const authorIds = articles
.map((a) => a.userId)
.filter((id): id is number => Boolean(id));
if (authorIds.length > 0) {
const authors = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, authorIds))
.catch(() => []);
for (const u of authors) authorMap.set(u.id, u.username);
}
const latest = articles[0]?.createdAt
? formatDate(articles[0].createdAt, "date")
: "—";
return (
<AdminPageShell
icon={Newspaper}
title={t("title")}
subtitle={t("subtitle")}
actions={
<Button variant="default" asChild>
<Link href="/admin/articles/new">{t("newArticle")}</Link>
</Button>
}
>
{error ? (
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("articlesShown")}
value={articles.length}
icon="📰"
/>
<StatusCard
label={t("latest")}
value={latest}
hint={t("mostRecentPost")}
icon="🕒"
/>
</div>
<section className="mt-2">
<h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{articles.map((a) => (
<ArticleCard
key={String(a.id)}
id={String(a.id)}
title={a.title}
image={a.image}
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
author={authorMap.get(a.userId ?? -1)}
/>
))}
</div>
)}
</section>
</AdminPageShell>
);
}
-160
View File
@@ -1,160 +0,0 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { giveBadge } from "@/actions/admin-badges";
import { Button } from "@/components/ui/button";
import { db, WebsiteBadges } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const metadata = { title: "Badges" };
export default async function AdminBadges({
searchParams,
}: {
searchParams: Promise<{ uploaded?: string; error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.badges");
const { uploaded, error } = await searchParams;
let badges: (typeof WebsiteBadges.$inferSelect)[] = [];
try {
badges = await db
.select()
.from(WebsiteBadges)
.orderBy(asc(WebsiteBadges.badgeName))
.limit(500);
} catch {
badges = [];
}
return (
<main>
{uploaded && (
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)] text-xs">
{t("uploadedMessage", { code: uploaded })}
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-error-subtle)] text-[var(--admin-error)] dark:text-[var(--admin-error)] text-xs">
{t("errorMessage", { error })}
</div>
)}
<section className="mt-6">
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("uploadSubtitle")}
</p>
<form action={uploadBadge} className="admin-card">
<div className="flex gap-2 flex-wrap items-center">
<input type="file" name="file" accept=".gif,.png" required />
<input
name="code"
placeholder={t("badgeCodePlaceholder")}
required
maxLength={64}
pattern="[A-Za-z0-9_-]{1,64}"
className="flex-1 min-w-[160px]"
/>
<Button type="submit" variant="default">
{t("uploadButton")}
</Button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("giveTitle")}</h2>
<form action={giveBadge} className="admin-card">
<div className="flex gap-2 flex-wrap items-center">
<input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
/>
<input
name="code"
placeholder={t("badgeCodePlaceholder")}
required
maxLength={32}
className="flex-1 min-w-[160px]"
list="badge-codes"
/>
<datalist id="badge-codes">
{badges.map((b) => (
<option key={String(b.id)} value={b.badgeKey}>
{b.badgeName}
</option>
))}
</datalist>
<Button type="submit" variant="default">
{t("giveButton")}
</Button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">
{t("catalogueTitle")} ({badges.length})
</h2>
{badges.length === 0 ? (
<div className="admin-empty">{t("noBadges")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colCode")}</th>
<th>{t("colName")}</th>
<th>{t("colDescription")}</th>
<th />
</tr>
</thead>
<tbody>
{badges.map((b) => (
<tr key={String(b.id)}>
<td>
<code>{b.badgeKey}</code>
</td>
<td>{b.badgeName}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted">
{b.badgeDescription}
</td>
<td>
<form
action={giveBadge}
className="flex gap-2 items-center"
>
<input type="hidden" name="code" value={b.badgeKey} />
<input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
className="w-[90px]"
/>
<Button type="submit" variant="outline">
{t("giveButton")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
-292
View File
@@ -1,292 +0,0 @@
"use client";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import {
createBanner,
deleteBanner,
updateBanner,
} from "@/actions/admin-banners";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useServerAction } from "@/hooks/use-server-action";
interface Banner {
id: number;
title: string;
subtitle: string;
image: string;
link: string;
color: string;
isActive: number;
sortOrder: number;
startDate: string | null;
endDate: string | null;
}
interface Props {
banners: Banner[];
}
export function BannersManager({ banners }: Props) {
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({
title: "",
subtitle: "",
image: "",
link: "",
color: "",
isActive: 1,
sortOrder: 0,
startDate: "" as string | null,
endDate: "" as string | null,
});
function resetForm() {
setForm({
title: "",
subtitle: "",
image: "",
link: "",
color: "",
isActive: 1,
sortOrder: 0,
startDate: "",
endDate: "",
});
setEditingId(null);
setShowForm(false);
}
function handleEdit(b: Banner) {
setForm({
title: b.title,
subtitle: b.subtitle,
image: b.image,
link: b.link,
color: b.color,
isActive: b.isActive,
sortOrder: b.sortOrder,
startDate: b.startDate,
endDate: b.endDate,
});
setEditingId(b.id);
setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const data = {
...form,
startDate: form.startDate || null,
endDate: form.endDate || null,
};
if (editingId) {
run(() => updateBanner({ id: editingId, ...data }), {
successMessage: "Banner updated!",
});
} else {
run(() => createBanner(data), { successMessage: "Banner created!" });
}
resetForm();
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
onClick={() => {
resetForm();
setShowForm(!showForm);
}}
>
<Plus className="h-4 w-4 mr-2" /> New Banner
</Button>
</div>
{showForm && (
<Card>
<CardHeader>
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Title</Label>
<Input
value={form.title}
onChange={(e) =>
setForm({ ...form, title: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label>Subtitle</Label>
<Input
value={form.subtitle}
onChange={(e) =>
setForm({ ...form, subtitle: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Image URL</Label>
<Input
value={form.image}
onChange={(e) =>
setForm({ ...form, image: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={form.link}
onChange={(e) => setForm({ ...form, link: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Color</Label>
<Input
type="color"
value={form.color || "#000000"}
onChange={(e) =>
setForm({ ...form, color: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<DateTimePicker
value={form.startDate}
onChange={(d) =>
setForm({
...form,
startDate: d ? d.toISOString() : null,
})
}
placeholder="Select start date"
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<DateTimePicker
value={form.endDate}
onChange={(d) =>
setForm({ ...form, endDate: d ? d.toISOString() : null })
}
placeholder="Select end date"
/>
</div>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={form.isActive === 1}
onCheckedChange={(v) =>
setForm({ ...form, isActive: v ? 1 : 0 })
}
/>
<Label>Active</Label>
</div>
<div className="space-y-2 flex items-center gap-2">
<Label>Order</Label>
<Input
type="number"
className="w-20"
value={form.sortOrder}
onChange={(e) =>
setForm({ ...form, sortOrder: Number(e.target.value) })
}
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{editingId ? "Update" : "Create"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{banners.length === 0 && (
<p className="text-muted-foreground col-span-full text-center py-8">
No banners yet.
</p>
)}
{banners.map((b) => (
<Card key={b.id} className="overflow-hidden">
{b.image && (
<div
className="h-32 bg-cover bg-center"
style={{ backgroundImage: `url(${b.image})` }}
/>
)}
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">{b.title}</h3>
{b.subtitle && (
<p className="text-xs text-muted-foreground">
{b.subtitle}
</p>
)}
<div className="flex gap-1 mt-2">
<Badge variant={b.isActive ? "default" : "outline"}>
{b.isActive ? "Active" : "Inactive"}
</Badge>
{b.startDate && (
<Badge variant="secondary">Scheduled</Badge>
)}
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(b)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
run(() => deleteBanner({ id: b.id }), {
successMessage: "Banner deleted",
})
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db, WebsiteBanner } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { BannersManager } from "./banners-manager";
export default async function BannersPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const banners = await db
.select()
.from(WebsiteBanner)
.orderBy(asc(WebsiteBanner.sortOrder));
return (
<div className="space-y-6">
<BannersManager banners={banners} />
</div>
);
}
-148
View File
@@ -1,148 +0,0 @@
import { desc, eq, gt, or } from "drizzle-orm";
import { Ban as BanIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { unixNow } from "@/lib/bans";
import { Ban, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminBans() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.bans");
const now = unixNow();
let bans: (typeof Ban.$inferSelect)[] = [];
try {
bans = await db
.select()
.from(Ban)
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)))
.orderBy(desc(Ban.timestamp))
.limit(100);
} catch {
bans = [];
}
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
return (
<AdminPageShell icon={BanIcon} title={t("title")} subtitle={t("subtitle")}>
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("activeBans")}
value={bans.length}
state={bans.length > 0 ? "warn" : "ok"}
icon="🔨"
/>
<StatusCard
label={t("permanent")}
value={permanent}
state={permanent > 0 ? "danger" : "neutral"}
icon="⛔"
/>
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
</div>
<form action={createBan} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
{t("newBan")}
</h3>
<div className="flex gap-2 flex-wrap">
<input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
/>
<select name="type">
<option value="account">{t("typeAccount")}</option>
<option value="ip">{t("typeIp")}</option>
<option value="machine">{t("typeMachine")}</option>
<option value="super">{t("typeSuper")}</option>
</select>
<input
name="hours"
type="number"
min={0}
placeholder={t("hoursPlaceholder")}
/>
<input
name="reason"
placeholder={t("reasonPlaceholder")}
className="flex-1 min-w-[160px]"
/>
<Button type="submit" variant="destructive">
{t("ban")}
</Button>
</div>
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activeBans")} ({bans.length})
</h2>
{bans.length === 0 ? (
<div className="admin-empty">{t("noBans")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
<th />
</tr>
</thead>
<tbody>
{bans.map((b) => (
<tr key={b.id}>
<td>{b.userId}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{b.type}
</span>
</td>
<td>{b.banReason || "—"}</td>
<td>
{b.banExpire === 0 ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("permanent")}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(new Date(b.banExpire * 1000))}
</span>
)}
</td>
<td>
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<Button type="submit" variant="outline">
{t("lift")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
</AdminPageShell>
);
}
-211
View File
@@ -1,211 +0,0 @@
import { asc, eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { CalendarCampaigns, CalendarRewards, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
};
type Reward = {
id: number;
campaignId: number;
productName: string;
customImage: string;
credits: number;
pixels: number;
points: number;
pointsType: number;
badge: string;
itemId: number;
subscriptionType: string | null;
subscriptionDays: number;
};
export default async function AdminCalendarCampaign({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.calendar");
const { id } = await params;
const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound();
let campaign: Campaign | null = null;
try {
const [row] = await db
.select({
id: CalendarCampaigns.id,
name: CalendarCampaigns.name,
image: CalendarCampaigns.image,
startTimestamp: CalendarCampaigns.startTimestamp,
totalDays: CalendarCampaigns.totalDays,
lockExpired: CalendarCampaigns.lockExpired,
enabled: CalendarCampaigns.enabled,
})
.from(CalendarCampaigns)
.where(eq(CalendarCampaigns.id, campaignId))
.limit(1);
campaign = row ?? null;
} catch {
campaign = null;
}
if (!campaign) notFound();
let rewards: Reward[] = [];
try {
rewards = await db
.select({
id: CalendarRewards.id,
campaignId: CalendarRewards.campaignId,
productName: CalendarRewards.productName,
customImage: CalendarRewards.customImage,
credits: CalendarRewards.credits,
pixels: CalendarRewards.pixels,
points: CalendarRewards.points,
pointsType: CalendarRewards.pointsType,
badge: CalendarRewards.badge,
itemId: CalendarRewards.itemId,
subscriptionType: CalendarRewards.subscriptionType,
subscriptionDays: CalendarRewards.subscriptionDays,
})
.from(CalendarRewards)
.where(eq(CalendarRewards.campaignId, campaignId))
.orderBy(asc(CalendarRewards.id))
.limit(500);
} catch {
rewards = [];
}
const disabled = campaign.enabled !== "1";
return (
<main>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-4">
<Link href="/admin/calendar">← {t("title")}</Link>
</p>
<div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="w-24 h-24 object-cover"
src={campaign.image}
alt={campaign.name}
/>
) : null}
<div>
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("detail.campaignMeta", {
id: campaign.id,
days: campaign.totalDays,
date: formatDate(
new Date(campaign.startTimestamp * 1000),
"date",
),
})}
{disabled
? ` · ${t("detail.disabled")}`
: ` · ${t("detail.enabled")}`}
{campaign.lockExpired === "1"
? ` · ${t("detail.locksExpired")}`
: ""}
</p>
</div>
</div>
<h2 className="admin-section-title">{t("detail.rewards")}</h2>
{rewards.length === 0 ? (
<div className="admin-empty">{t("detail.noRewards")}</div>
) : (
<table className="w-full">
<thead>
<tr className="text-left text-[var(--muted)]">
<th>{t("detail.colId")}</th>
<th>{t("detail.colProduct")}</th>
<th>{t("detail.colItem")}</th>
<th>{t("detail.colCredits")}</th>
<th>{t("detail.colPixels")}</th>
<th>{t("detail.colPoints")}</th>
<th>{t("detail.colBadge")}</th>
<th>{t("detail.colSubscription")}</th>
</tr>
</thead>
<tbody>
{rewards.map((r) => (
<tr key={r.id} className="border-t border-[var(--admin-border)]">
<td>{r.id}</td>
<td>
{r.productName || (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.itemId > 0 ? r.itemId : "—"}
</td>
<td>
{r.credits > 0 ? (
r.credits
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td>
{r.pixels > 0 ? (
r.pixels
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td>
{r.points > 0 ? (
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.badge || "—"}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.subscriptionType
? `${r.subscriptionType} · ${r.subscriptionDays}d`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-xs theme-text-muted dark:theme-text-muted mt-4">
{t("detail.rewardCount", { count: rewards.length })}
</p>
</main>
);
}
-139
View File
@@ -1,139 +0,0 @@
import { count, desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { CalendarCampaigns, CalendarRewards, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
};
export default async function AdminCalendar() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.calendar");
let campaigns: Campaign[] = [];
let loadError = false;
try {
campaigns = await db
.select({
id: CalendarCampaigns.id,
name: CalendarCampaigns.name,
image: CalendarCampaigns.image,
startTimestamp: CalendarCampaigns.startTimestamp,
totalDays: CalendarCampaigns.totalDays,
lockExpired: CalendarCampaigns.lockExpired,
enabled: CalendarCampaigns.enabled,
})
.from(CalendarCampaigns)
.orderBy(desc(CalendarCampaigns.id))
.limit(200);
} catch {
campaigns = [];
loadError = true;
}
const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try {
const grouped = await db
.select({
campaignId: CalendarRewards.campaignId,
total: count(),
})
.from(CalendarRewards)
.groupBy(CalendarRewards.campaignId);
for (const g of grouped) {
rewardCounts.set(g.campaignId, Number(g.total));
totalRewards += Number(g.total);
}
} catch {
loadError = true;
}
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
return (
<main>
{loadError ? (
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
Could not load some calendar data (database unavailable).
</div>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
<StatusCard
label={t("enabled")}
value={enabledCount}
state={enabledCount > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
</div>
{campaigns.length === 0 ? (
<div className="admin-empty">{t("noCampaigns")}</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{campaigns.map((c) => {
const enabled = c.enabled === "1";
const count = rewardCounts.get(c.id) ?? 0;
return (
<Link
key={c.id}
href={`/admin/calendar/${c.id}`}
className="admin-card hover article"
>
{c.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} />
) : (
<div className="article-img" />
)}
<div className="body">
<div className="flex items-center justify-between gap-2 mb-1">
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]"}`}
>
{enabled ? t("statusEnabled") : t("statusDisabled")}
</span>
</div>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("campaignMeta", {
id: c.id,
days: c.totalDays,
rewards: count,
})}
</p>
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
{t("startsAt", {
date: formatDate(
new Date(c.startTimestamp * 1000),
"date",
),
})}
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
</p>
</div>
</Link>
);
})}
</div>
)}
</main>
);
}
-44
View File
@@ -1,44 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogDetailLoading() {
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-20" />
</div>
{/* Title */}
<div>
<Skeleton className="h-9 w-64" />
<Skeleton className="h-5 w-40 mt-2" />
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b pb-2">
{Array.from({ length: 4 }, (_, i) => i).map((n) => (
<Skeleton
key={`skeleton-tab-${n}`}
className="h-9 w-24 rounded-md"
/>
))}
</div>
{/* Tab content */}
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
<div key={`skeleton-field-${n}`} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
);
}
-139
View File
@@ -1,139 +0,0 @@
import { asc, eq } from "drizzle-orm";
import { ChevronRight } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { Fragment } from "react";
import Link from "@/components/link";
import { CatalogPages, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from "./catalog-detail-tabs";
export default async function CatalogEditPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound();
const [catalogPage] = await db
.select()
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([
db
.select({
id: CatalogPages.id,
caption: CatalogPages.caption,
enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, pageId))
.orderBy(asc(CatalogPages.orderNum)),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
]);
const {
items: catalogItems,
baseItems,
catalogNameMap,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
gamedataHotel,
} = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
<Link
href={"/admin/catalog"}
className="hover:text-foreground transition-colors"
>
Catalog
</Link>
{ancestors.map((bc) => (
<Fragment key={bc.id}>
<ChevronRight className="h-3 w-3 flex-shrink-0" />
<Link
href={`/admin/catalog/${bc.id}`}
className="hover:text-foreground transition-colors"
>
{bc.caption}
</Link>
</Fragment>
))}
<ChevronRight className="h-3 w-3 flex-shrink-0" />
<span className="text-foreground font-medium">
{catalogPage.caption}
</span>
</nav>
{/* Title */}
<div>
<h1 className="text-3xl font-bold tracking-tight">
{catalogPage.caption}
<span className="ml-2 text-base font-normal text-muted-foreground">
#{catalogPage.id}
</span>
</h1>
<p className="text-muted-foreground">
Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items
</p>
</div>
{/* Tabs */}
<CatalogDetailTabs
catalogPage={{
id: catalogPage.id,
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled,
visible: catalogPage.visible,
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly,
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial,
pageText1: catalogPage.pageText1,
pageText2: catalogPage.pageText2,
pageTextDetails: catalogPage.pageTextDetails,
pageTextTeaser: catalogPage.pageTextTeaser,
}}
items={catalogItems}
baseItems={baseItems}
catalogNameMap={catalogNameMap}
childPages={children}
pageId={catalogPage.id}
canEdit={canEdit}
furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap}
allPages={allPages}
interactionTypes={interactionTypes}
gamedataHotel={gamedataHotel}
/>
</div>
);
}
@@ -1,67 +0,0 @@
import { asc, eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({
params: paramsPromise,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound();
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const [bcPage] = await db
.select()
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!bcPage) notFound();
const bcItems = await db
.select()
.from(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, id))
.orderBy(asc(CatalogItemsBc.orderNumber));
return (
<BcPageDetail
page={{
id: bcPage.id,
parentId: bcPage.parentId,
caption: bcPage.caption,
pageLayout: bcPage.pageLayout,
iconColor: bcPage.iconColor,
iconImage: bcPage.iconImage,
orderNum: bcPage.orderNum,
visible: bcPage.visible,
enabled: bcPage.enabled,
pageHeadline: bcPage.pageHeadline,
pageTeaser: bcPage.pageTeaser,
pageSpecial: bcPage.pageSpecial ?? "",
pageText1: bcPage.pageText1 ?? "",
pageText2: bcPage.pageText2 ?? "",
pageTextDetails: bcPage.pageTextDetails ?? "",
pageTextTeaser: bcPage.pageTextTeaser ?? "",
}}
items={bcItems.map((i) => ({
id: i.id,
pageId: i.pageId,
itemIds: i.itemIds,
catalogName: i.catalogName,
orderNumber: i.orderNumber,
extradata: i.extradata,
}))}
canEdit={canEdit}
/>
);
}
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function BuilderClubPage() {
redirect("/admin/catalog?catalog=bc");
}
-158
View File
@@ -1,158 +0,0 @@
"use client";
import { Loader2, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
interface CatalogActionsProps {
catalogType?: "normal" | "bc";
/** Prefill parent and auto-open create dialog (from ?newPageParent=). */
initialParentId?: string | null;
}
export function CatalogActions({
catalogType = "normal",
initialParentId = null,
}: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const router = useRouter();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
useEffect(() => {
if (initialParentId == null || initialParentId === "") return;
const n = Number.parseInt(initialParentId, 10);
if (!Number.isFinite(n)) return;
setParentId(String(n));
setOpen(true);
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (url.searchParams.has("newPageParent")) {
url.searchParams.delete("newPageParent");
const qs = url.searchParams.toString();
router.replace(`${url.pathname}${qs ? `?${qs}` : ""}`, {
scroll: false,
});
}
}, [initialParentId, router]);
function handleCreate() {
if (!caption.trim()) {
toast.error("Caption is required");
return;
}
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
};
const fn = isBc
? () => createBcPage(input)
: () => createCatalogPage(input);
run(fn, {
successMessage: `Catalog page "${caption.trim()}" created.`,
onSuccess: (data) => {
setOpen(false);
setCaption("");
setParentId("-1");
if (data.id) {
window.location.href = isBc
? `/admin/catalog/builder-club/${data.id}`
: `/admin/catalog/${data.id}`;
}
},
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Page
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isBc ? "Create Builder Club Page" : "Create Catalog Page"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Input
id="caption"
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Page name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="parentId">Parent ID (-1 = root)</Label>
<Input
id="parentId"
type="number"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Layout</Label>
<Select value={pageLayout} onValueChange={setPageLayout}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((layout) => (
<SelectItem key={layout} value={layout}>
{layout}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleCreate}
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Create Page
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,13 +0,0 @@
"use client";
import dynamic from "next/dynamic";
export const CatalogManagerDialog = dynamic(
() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({
default: m.CatalogManagerDialog,
}),
),
{ ssr: false },
);
-211
View File
@@ -1,211 +0,0 @@
"use client";
import { Pencil } from "lucide-react";
import { deleteCatalogPage, toggleCatalogPage } from "@/actions/catalog";
import { deleteBcTreePage, toggleBcPage } from "@/actions/catalog-bc";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface CatalogPageRow {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}
interface CatalogTableProps {
data: PaginatedResult<CatalogPageRow>;
canEdit: boolean;
catalogType?: "normal" | "bc";
}
export function CatalogTable({
data,
canEdit,
catalogType = "normal",
}: CatalogTableProps) {
const { run, isPending } = useServerAction();
const isBc = catalogType === "bc";
// Parent URLs stay in the same catalog; root takes you to the catalog index.
const detailHref = (id: number) =>
isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`;
function handleToggle(id: number, action: "toggleEnabled" | "toggleVisible") {
if (isBc) {
const field = action === "toggleEnabled" ? "enabled" : "visible";
run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
} else {
run(() => toggleCatalogPage({ id, action }), {
successMessage: "Updated.",
});
}
}
const columns: DataTableColumn<CatalogPageRow>[] = [
{ key: "id", label: "ID", sortable: true },
{
key: "caption",
label: "Caption",
sortable: true,
render: (value, row) => (
<Link
href={detailHref(row.id)}
className="font-medium text-primary hover:underline"
>
{String(value)}
</Link>
),
},
{
key: "parentId",
label: "Parent",
sortable: true,
render: (_, row) =>
row.parentId === -1 ? (
<span className="text-xs text-muted-foreground">Root</span>
) : (
<Link
href={detailHref(row.parentId)}
className="text-xs hover:underline"
>
{row.parentCaption}
</Link>
),
},
{
key: "pageLayout",
label: "Layout",
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
},
{
key: "enabled",
label: "Enabled",
sortable: true,
filterKey: "filter_enabled",
filterOptions: [
{ label: "Enabled", value: "1" },
{ label: "Disabled", value: "0" },
],
render: (value, row) =>
canEdit ? (
<button
type="button"
onClick={() => handleToggle(row.id, "toggleEnabled")}
disabled={isPending}
className="cursor-pointer disabled:opacity-50"
>
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
</button>
) : (
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
),
},
{
key: "visible",
label: "Visible",
sortable: true,
filterKey: "filter_visible",
filterOptions: [
{ label: "Visible", value: "1" },
{ label: "Hidden", value: "0" },
],
render: (value, row) =>
canEdit ? (
<button
type="button"
onClick={() => handleToggle(row.id, "toggleVisible")}
disabled={isPending}
className="cursor-pointer disabled:opacity-50"
>
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
</button>
) : (
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
),
},
...(isBc
? []
: [
{
key: "minRank" as const,
label: "Min Rank",
sortable: true,
} satisfies DataTableColumn<CatalogPageRow>,
]),
{
key: "itemCount",
label: "Items",
render: (value) => <span>{String(value)}</span>,
},
{
key: "childCount",
label: "Children",
render: (value) => <span>{String(value)}</span>,
},
{
key: "actions",
label: "Actions",
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={detailHref(row.id)}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
),
},
];
async function handleBulkAction(action: string, ids: (number | string)[]) {
if (action !== "delete" || ids.length === 0) return;
for (const id of ids) {
const numId = typeof id === "string" ? Number(id) : id;
if (Number.isInteger(numId) && numId > 0) {
run(() =>
isBc
? deleteBcTreePage({ pageId: numId, mode: "reparent" })
: deleteCatalogPage({ id: numId }),
);
}
}
}
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit
? [
{
label: "Delete Selected",
value: "delete",
variant: "destructive",
},
]
: undefined
}
onBulkAction={canEdit ? handleBulkAction : undefined}
/>
);
}
@@ -1,100 +0,0 @@
"use client";
import { FolderTree, HardHat, List, Package } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
currentView: string;
catalogType?: string;
}
export function CatalogViewToggle({
currentView,
catalogType = "normal",
}: CatalogViewToggleProps) {
const router = useRouter();
const searchParams = useSearchParams();
function setView(view: string) {
const params = new URLSearchParams(searchParams.toString());
if (view === "tree") {
params.delete("view");
} else {
params.set("view", view);
}
router.push(`?${params.toString()}`);
}
function setCatalog(type: string) {
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
router.push(`?${params.toString()}`);
}
return (
<div className="flex items-center gap-2">
{/* Catalog type selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-r-none",
catalogType === "normal" && "bg-accent",
)}
onClick={() => setCatalog("normal")}
>
<Package className="mr-1.5 h-4 w-4" />
Catalog
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-l-none",
catalogType === "bc" && "bg-accent",
)}
onClick={() => setCatalog("bc")}
>
<HardHat className="mr-1.5 h-4 w-4" />
Builder Club
</Button>
</div>
{/* View mode selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-r-none",
currentView === "tree" && "bg-accent",
)}
onClick={() => setView("tree")}
>
<FolderTree className="mr-1.5 h-4 w-4" />
Tree
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-l-none",
currentView === "table" && "bg-accent",
)}
onClick={() => setView("table")}
>
<List className="mr-1.5 h-4 w-4" />
Table
</Button>
</div>
</div>
);
}
-44
View File
@@ -1,44 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
{/* Tree skeleton */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-56" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 12 }, (_, i) => i).map((n) => (
<div
key={`skeleton-tree-${n}`}
className="flex items-center gap-2 px-2 py-1.5"
style={{ paddingLeft: `${(n % 3) * 20 + 8}px` }}
>
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-4 flex-1 max-w-48" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
))}
</div>
</div>
</div>
);
}
@@ -1,5 +0,0 @@
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
export default function CatalogMaintenancePage() {
return <CatalogMaintenancePanel />;
}
-526
View File
@@ -1,526 +0,0 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
or,
type SQL,
sql,
} from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import { Store } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { CatalogFixAllButton } from "@/components/admin/catalog/catalog-fix-all-button";
import { CatalogTree } from "@/components/admin/catalog-tree";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
db,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
import { CatalogActions } from "./catalog-actions";
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
export default async function CatalogPagesPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const t = await getTranslations("pages.admin.catalog");
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
type TreeNodeShape = {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
};
if (catalogType === "bc") {
const [totalBcPagesRows, totalBcItemsRows, enabledBcPagesRows] =
await Promise.all([
db.select({ total: count() }).from(CatalogPagesBc),
db.select({ total: count() }).from(CatalogItemsBc),
db
.select({ total: count() })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.enabled, "1")),
]);
const totalBcPages = Number(totalBcPagesRows[0]?.total ?? 0);
const totalBcItems = Number(totalBcItemsRows[0]?.total ?? 0);
const enabledBcPages = Number(enabledBcPagesRows[0]?.total ?? 0);
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await db
.select()
.from(CatalogPagesBc)
.where(
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
)
.orderBy(asc(CatalogPagesBc.orderNum));
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? db
.select({
parentId: CatalogPagesBc.parentId,
total: count(),
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, bcRootIds))
.groupBy(CatalogPagesBc.parentId)
: [],
bcRootIds.length > 0
? db
.select({
pageId: CatalogItemsBc.pageId,
total: count(),
})
.from(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, bcRootIds))
.groupBy(CatalogItemsBc.pageId)
: [],
]);
const bcChildMap = Object.fromEntries(
bcChildCounts.map((c) => [c.parentId, Number(c.total)]),
);
const bcItemMap = Object.fromEntries(
bcItemCounts.map((c) => [c.pageId, Number(c.total)]),
);
bcRootPages = bcRootRaw.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: bcChildMap[p.id] ?? 0,
itemCount: bcItemMap[p.id] ?? 0,
}));
}
let bcRows: {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}[] = [];
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const conditions: SQL[] = [];
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
conditions.push(like(CatalogPagesBc.caption, `%${search}%`));
}
if (hasParent) conditions.push(eq(CatalogPagesBc.parentId, parentId));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortKeyMap: Record<string, AnyMySqlColumn> = {
parentId: CatalogPagesBc.parentId,
pageLayout: CatalogPagesBc.pageLayout,
orderNum: CatalogPagesBc.orderNum,
caption: CatalogPagesBc.caption,
id: CatalogPagesBc.id,
};
const sortCol = sort
? (sortKeyMap[sort] ?? CatalogPagesBc.orderNum)
: CatalogPagesBc.orderNum;
const orderBy =
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
const [pages, totalRows] = await Promise.all([
db
.select()
.from(CatalogPagesBc)
.where(where)
.orderBy(orderBy)
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(CatalogPagesBc).where(where),
]);
const total = Number(totalRows[0]?.total ?? 0);
bcPagination = calcPagination(total, page, perPage);
const pageIds = pages.map((p) => p.id);
const parentIds = [
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
];
const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0
? db
.select({
parentId: CatalogPagesBc.parentId,
total: count(),
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, pageIds))
.groupBy(CatalogPagesBc.parentId)
: [],
pageIds.length > 0
? db
.select({
pageId: CatalogItemsBc.pageId,
total: count(),
})
.from(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, pageIds))
.groupBy(CatalogItemsBc.pageId)
: [],
parentIds.length > 0
? db
.select({
id: CatalogPagesBc.id,
caption: CatalogPagesBc.caption,
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, parentIds))
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, Number(c.total)]),
);
const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, Number(c.total)]),
);
const parentNameMap = Object.fromEntries(
parentPages.map((p) => [p.id, p.caption]),
);
bcRows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
parentCaption:
p.parentId === -1
? "Root"
: (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: String(p.pageLayout),
enabled: String(p.enabled),
visible: String(p.visible),
minRank: 0,
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && (
<CatalogManagerDialog
catalogType="bc"
canEdit={canEdit}
totals={{
totalPages: totalBcPages,
enabledPages: enabledBcPages,
totalItems: totalBcItems,
}}
initialRoots={bcRootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && (
<CatalogActions
catalogType="bc"
initialParentId={sp.get("newPageParent")}
/>
)}
<CatalogFixAllButton />
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable
data={{ rows: bcRows, ...bcPagination }}
canEdit={canEdit}
catalogType="bc"
/>
)}
</div>
</AdminPageShell>
);
}
const [totalPagesRows, totalItemsRows, enabledPagesRows] = await Promise.all([
db.select({ total: count() }).from(CatalogPages),
db.select({ total: count() }).from(CatalogItems),
db
.select({ total: count() })
.from(CatalogPages)
.where(eq(CatalogPages.enabled, "1")),
]);
const totalPages = Number(totalPagesRows[0]?.total ?? 0);
const totalItems = Number(totalItemsRows[0]?.total ?? 0);
const enabledPages = Number(enabledPagesRows[0]?.total ?? 0);
const rootPagesRaw = await db
.select()
.from(CatalogPages)
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
.orderBy(asc(CatalogPages.orderNum));
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCountMap] = await Promise.all([
rootIds.length > 0
? db
.select({
parentId: CatalogPages.parentId,
total: count(),
})
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, rootIds))
.groupBy(CatalogPages.parentId)
: [],
getCatalogItemCounts(rootIds),
]);
const rootChildMap = Object.fromEntries(
rootChildCounts.map((c) => [c.parentId, Number(c.total)]),
);
const rootItemMap = Object.fromEntries(rootItemCountMap);
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: rootChildMap[p.id] ?? 0,
itemCount: rootItemMap[p.id] ?? 0,
}));
let rows: {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}[] = [];
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const conditions: SQL[] = [];
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`;
const parentClause = hasParent
? sql`AND cp.parent_id = ${parentId}`
: sql``;
const matchedIds = await rawRows<{ id: number }>(sql`
SELECT DISTINCT cp.id FROM catalog_pages cp
LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR)
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
WHERE (cp.caption LIKE ${searchPattern}
OR cp.page_layout LIKE ${searchPattern}
OR ci.catalog_name LIKE ${searchPattern}
OR ib.public_name LIKE ${searchPattern}
OR ib.item_name LIKE ${searchPattern})
${parentClause}
`);
const ids = matchedIds.map((r) => r.id);
if (ids.length > 0) conditions.push(inArray(CatalogPages.id, ids));
else conditions.push(sql`1 = 0`);
} else if (hasParent) {
conditions.push(eq(CatalogPages.parentId, parentId));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortColMap: Record<string, AnyMySqlColumn> = {
caption: CatalogPages.caption,
parentId: CatalogPages.parentId,
pageLayout: CatalogPages.pageLayout,
enabled: CatalogPages.enabled,
orderNum: CatalogPages.orderNum,
id: CatalogPages.id,
minRank: CatalogPages.minRank,
};
const sortCol = sort
? (sortColMap[sort] ?? CatalogPages.orderNum)
: CatalogPages.orderNum;
const orderBy =
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
const [pages, totalRows] = await Promise.all([
db
.select()
.from(CatalogPages)
.where(where)
.orderBy(orderBy)
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(CatalogPages).where(where),
]);
const total = Number(totalRows[0]?.total ?? 0);
pagination = calcPagination(total, page, perPage);
const pageIds = pages.map((p) => p.id);
const parentIds = [
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
];
const [childCounts, itemCountMapRaw, parentPages] = await Promise.all([
pageIds.length > 0
? db
.select({
parentId: CatalogPages.parentId,
total: count(),
})
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, pageIds))
.groupBy(CatalogPages.parentId)
: [],
getCatalogItemCounts(pageIds),
parentIds.length > 0
? db
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
.from(CatalogPages)
.where(inArray(CatalogPages.id, parentIds))
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, Number(c.total)]),
);
const itemCountMap = Object.fromEntries(itemCountMapRaw);
const parentNameMap = Object.fromEntries(
parentPages.map((p) => [p.id, p.caption]),
);
rows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
parentCaption:
p.parentId === -1
? "Root"
: (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: p.pageLayout,
enabled: p.enabled,
visible: p.visible,
minRank: p.minRank,
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && (
<CatalogManagerDialog
catalogType="normal"
canEdit={canEdit}
totals={{
totalPages,
enabledPages,
totalItems,
}}
initialRoots={rootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && (
<CatalogActions initialParentId={sp.get("newPageParent")} />
)}
<CatalogFixAllButton />
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
</AdminPageShell>
);
}
@@ -1,798 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
alertUser,
disconnectUser,
executeCommand,
forwardUser,
giveBadge,
giveCredits,
giveDiamonds,
giveDuckets,
hotelAlert,
sendGift,
setMotto,
setRank,
updateCatalog,
updateNavigator,
updateWordFilter,
} from "@/actions/commandocentrum";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
const RCON_ERROR = "RCON command failed. Is the emulator running?";
export function CommandocentrumControls() {
const t = useTranslations("pages.admin.commandocentrum");
const { run, isPending } = useServerAction();
const [hotelMessage, setHotelMessage] = useState("");
const [disconnect, setDisconnect] = useState({ userId: "", username: "" });
const [alert, setAlert] = useState({ userId: "", message: "" });
const [forward, setForward] = useState({ userId: "", roomId: "" });
const [credits, setCredits] = useState({ userId: "", credits: "" });
const [duckets, setDuckets] = useState({ userId: "", amount: "" });
const [diamonds, setDiamonds] = useState({ userId: "", amount: "" });
const [badge, setBadge] = useState({ userId: "", badge: "" });
const [motto, setMottoForm] = useState({ userId: "", motto: "" });
const [rank, setRankForm] = useState({ userId: "", rank: "" });
const [command, setCommand] = useState({ userId: "", command: "" });
const [gift, setGift] = useState({
userId: "",
itemId: "",
message: t("defaultGiftMessage"),
});
return (
<>
<section className="mt-6">
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateCatalog")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateCatalogDesc")}
</p>
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() =>
run(() => updateCatalog(), {
successMessage: "Catalog updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateCatalog")}
</Button>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateWordFilter")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateWordFilterDesc")}
</p>
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() =>
run(() => updateWordFilter(), {
successMessage: "Word filter updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateWordFilter")}
</Button>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateNavigator")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateNavigatorDesc")}
</p>
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() =>
run(() => updateNavigator(), {
successMessage: "Navigator updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateNavigator")}
</Button>
</div>
</div>
<div className="admin-card mt-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("hotelAlert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("hotelAlertDesc")}
</p>
<div className="flex gap-2">
<input
value={hotelMessage}
onChange={(e) => setHotelMessage(e.target.value)}
required
maxLength={512}
placeholder={t("messageToBroadcast")}
className="flex-1 min-w-[220px]"
disabled={isPending}
/>
<Button
type="button"
variant="destructive"
disabled={isPending || !hotelMessage.trim()}
onClick={() =>
run(() => hotelAlert({ message: hotelMessage }), {
successMessage: "Hotel alert sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setHotelMessage(""),
})
}
>
{t("sendAlert")}
</Button>
</div>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("userActions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("userActionsDesc")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.disconnect")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("disconnectDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={disconnect.userId}
onChange={(e) =>
setDisconnect((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("usernameRequired")}
value={disconnect.username}
onChange={(e) =>
setDisconnect((s) => ({ ...s, username: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="destructive"
className="w-full"
disabled={
isPending || !disconnect.userId || !disconnect.username.trim()
}
onClick={() =>
run(
() =>
disconnectUser({
userId: Number(disconnect.userId),
username: disconnect.username,
}),
{
successMessage: "User disconnected.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setDisconnect({ userId: "", username: "" }),
},
)
}
>
{t("actions.disconnect")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.alert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("alertDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={alert.userId}
onChange={(e) =>
setAlert((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("alertMessage")}
value={alert.message}
onChange={(e) =>
setAlert((s) => ({ ...s, message: e.target.value }))
}
maxLength={512}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !alert.userId || !alert.message.trim()}
onClick={() =>
run(
() =>
alertUser({
userId: Number(alert.userId),
message: alert.message,
}),
{
successMessage: "Alert sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setAlert({ userId: "", message: "" }),
},
)
}
>
{t("actions.alert")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("forwardToRoom")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("forwardDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={forward.userId}
onChange={(e) =>
setForward((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("roomId")}
value={forward.roomId}
onChange={(e) =>
setForward((s) => ({ ...s, roomId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !forward.userId || !forward.roomId}
onClick={() =>
run(
() =>
forwardUser({
userId: Number(forward.userId),
roomId: Number(forward.roomId),
}),
{
successMessage: "User forwarded.",
errorMessage: RCON_ERROR,
onSuccess: () => setForward({ userId: "", roomId: "" }),
},
)
}
>
{t("forwardUser")}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveCredits")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveCreditsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={credits.userId}
onChange={(e) =>
setCredits((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={credits.credits}
onChange={(e) =>
setCredits((s) => ({ ...s, credits: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !credits.userId || !credits.credits}
onClick={() =>
run(
() =>
giveCredits({
userId: Number(credits.userId),
credits: Number(credits.credits),
}),
{
successMessage: "Credits sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setCredits({ userId: "", credits: "" }),
},
)
}
>
{t("giveCredits")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDuckets")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDucketsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={duckets.userId}
onChange={(e) =>
setDuckets((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={duckets.amount}
onChange={(e) =>
setDuckets((s) => ({ ...s, amount: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !duckets.userId || !duckets.amount}
onClick={() =>
run(
() =>
giveDuckets({
userId: Number(duckets.userId),
amount: Number(duckets.amount),
}),
{
successMessage: "Duckets sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setDuckets({ userId: "", amount: "" }),
},
)
}
>
{t("giveDuckets")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDiamonds")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDiamondsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={diamonds.userId}
onChange={(e) =>
setDiamonds((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={diamonds.amount}
onChange={(e) =>
setDiamonds((s) => ({ ...s, amount: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !diamonds.userId || !diamonds.amount}
onClick={() =>
run(
() =>
giveDiamonds({
userId: Number(diamonds.userId),
amount: Number(diamonds.amount),
}),
{
successMessage: "Diamonds sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setDiamonds({ userId: "", amount: "" }),
},
)
}
>
{t("giveDiamonds")}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveBadge")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveBadgeDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={badge.userId}
onChange={(e) =>
setBadge((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("badgeCode")}
value={badge.badge}
onChange={(e) =>
setBadge((s) => ({ ...s, badge: e.target.value }))
}
maxLength={32}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !badge.userId || !badge.badge.trim()}
onClick={() =>
run(
() =>
giveBadge({
userId: Number(badge.userId),
badge: badge.badge,
}),
{
successMessage: "Badge granted.",
errorMessage: RCON_ERROR,
onSuccess: () => setBadge({ userId: "", badge: "" }),
},
)
}
>
{t("giveBadge")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setMotto")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setMottoDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={motto.userId}
onChange={(e) =>
setMottoForm((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("newMotto")}
value={motto.motto}
onChange={(e) =>
setMottoForm((s) => ({ ...s, motto: e.target.value }))
}
maxLength={127}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !motto.userId || !motto.motto.trim()}
onClick={() =>
run(
() =>
setMotto({
userId: Number(motto.userId),
motto: motto.motto,
}),
{
successMessage: "Motto updated.",
errorMessage: RCON_ERROR,
onSuccess: () => setMottoForm({ userId: "", motto: "" }),
},
)
}
>
{t("setMotto")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setRank")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setRankDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={rank.userId}
onChange={(e) =>
setRankForm((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={0}
max={10}
placeholder={t("rankPlaceholder")}
value={rank.rank}
onChange={(e) =>
setRankForm((s) => ({ ...s, rank: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !rank.userId || rank.rank === ""}
onClick={() =>
run(
() =>
setRank({
userId: Number(rank.userId),
rank: Number(rank.rank),
}),
{
successMessage: "Rank updated.",
errorMessage: RCON_ERROR,
onSuccess: () => setRankForm({ userId: "", rank: "" }),
},
)
}
>
{t("setRank")}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("executeCommand")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("executeCommandDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={command.userId}
onChange={(e) =>
setCommand((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("commandPlaceholder")}
value={command.command}
onChange={(e) =>
setCommand((s) => ({ ...s, command: e.target.value }))
}
maxLength={100}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={
isPending || !command.userId || !command.command.trim()
}
onClick={() =>
run(
() =>
executeCommand({
userId: Number(command.userId),
command: command.command,
}),
{
successMessage: "Command executed.",
errorMessage: RCON_ERROR,
onSuccess: () => setCommand({ userId: "", command: "" }),
},
)
}
>
{t("execute")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("sendGift")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("sendGiftDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={gift.userId}
onChange={(e) =>
setGift((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("furniId")}
value={gift.itemId}
onChange={(e) =>
setGift((s) => ({ ...s, itemId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("giftMessage")}
value={gift.message}
onChange={(e) =>
setGift((s) => ({ ...s, message: e.target.value }))
}
maxLength={255}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !gift.userId || !gift.itemId}
onClick={() =>
run(
() =>
sendGift({
userId: Number(gift.userId),
itemId: Number(gift.itemId),
message: gift.message,
}),
{
successMessage: "Gift sent.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setGift({
userId: "",
itemId: "",
message: t("defaultGiftMessage"),
}),
},
)
}
>
{t("sendGift")}
</Button>
</div>
</div>
</div>
</section>
</>
);
}
@@ -1 +0,0 @@
export { default } from "../loading";
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff. Show more