feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -17,9 +17,6 @@ NODE_ENV=production
|
||||
PORT=3002
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
UV_THREADPOOL_SIZE=16
|
||||
# Non-production preview only; production always returns 404.
|
||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
||||
|
||||
# --- HOTEL & URLS ---
|
||||
HOTEL_NAME=EPIC WEB CONTROL
|
||||
APP_URL=http://localhost:3002
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
pnpm exec lint-staged
|
||||
@@ -1,2 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
@@ -34,66 +34,6 @@ const nextConfig: NextConfig = {
|
||||
productionBrowserSourceMaps: false,
|
||||
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/admin/import",
|
||||
destination: "/admin/studio/furni",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/badges",
|
||||
destination: "/admin/studio/badges",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/furni",
|
||||
destination: "/admin/studio/furni",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/furni/upload",
|
||||
destination: "/admin/studio/upload",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/clothing",
|
||||
destination: "/admin/studio/clothing",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/effects",
|
||||
destination: "/admin/studio/effects",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/pets",
|
||||
destination: "/admin/studio/pets",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/clone",
|
||||
destination: "/admin/studio/clone",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/sync",
|
||||
destination: "/admin/studio/sync",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/repair-icons",
|
||||
destination: "/admin/studio/repair-icons",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/audit",
|
||||
destination: "/admin/studio/audit",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
turbopack: {
|
||||
ignoreIssue: [
|
||||
{
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -60,7 +58,6 @@
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.85.0",
|
||||
"resend": "6.21.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
|
||||
Generated
-127
@@ -34,12 +34,6 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2([email protected])
|
||||
'@hookform/resolvers':
|
||||
specifier: 5.9.1
|
||||
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.14.10
|
||||
version: 3.14.10([email protected]([email protected]))([email protected])
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -112,9 +106,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8([email protected])
|
||||
react-hook-form:
|
||||
specifier: 7.85.0
|
||||
version: 7.85.0([email protected])
|
||||
resend:
|
||||
specifier: 6.21.0
|
||||
version: 6.21.0
|
||||
@@ -549,84 +540,6 @@ packages:
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
||||
|
||||
'@hookform/[email protected]':
|
||||
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
|
||||
peerDependencies:
|
||||
'@sinclair/typebox': '>=0.25.24'
|
||||
'@standard-schema/spec': ^1.0.0
|
||||
'@typeschema/main': '>=0.13.7'
|
||||
'@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||
ajv: ^8.12.0
|
||||
ajv-errors: ^3.0.0
|
||||
ajv-formats: ^2.1.1
|
||||
arktype: ^2.0.0
|
||||
ata-validator: ^1.2.0
|
||||
class-transformer: '>=0.4.0'
|
||||
class-validator: '>=0.12.0'
|
||||
computed-types: ^1.0.0
|
||||
effect: ^3.10.3
|
||||
fluentvalidation-ts: ^3.0.0
|
||||
fp-ts: ^2.7.0
|
||||
io-ts: ^2.0.0
|
||||
joi: ^17.0.0 || ^18.0.0
|
||||
nope-validator: '>=0.12.0'
|
||||
react-hook-form: ^7.55.0
|
||||
superstruct: '>=0.12.0'
|
||||
typanion: ^3.3.2
|
||||
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
|
||||
vest: '>=6.0.0'
|
||||
yup: ^1.0.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
'@sinclair/typebox':
|
||||
optional: true
|
||||
'@standard-schema/spec':
|
||||
optional: true
|
||||
'@typeschema/main':
|
||||
optional: true
|
||||
'@vinejs/vine':
|
||||
optional: true
|
||||
ajv:
|
||||
optional: true
|
||||
ajv-errors:
|
||||
optional: true
|
||||
ajv-formats:
|
||||
optional: true
|
||||
arktype:
|
||||
optional: true
|
||||
ata-validator:
|
||||
optional: true
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
computed-types:
|
||||
optional: true
|
||||
effect:
|
||||
optional: true
|
||||
fluentvalidation-ts:
|
||||
optional: true
|
||||
fp-ts:
|
||||
optional: true
|
||||
io-ts:
|
||||
optional: true
|
||||
joi:
|
||||
optional: true
|
||||
nope-validator:
|
||||
optional: true
|
||||
superstruct:
|
||||
optional: true
|
||||
typanion:
|
||||
optional: true
|
||||
valibot:
|
||||
optional: true
|
||||
vest:
|
||||
optional: true
|
||||
yup:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1484,9 +1397,6 @@ packages:
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1685,15 +1595,6 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
|
||||
|
||||
'@tokenizer/[email protected]':
|
||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2658,12 +2559,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.8
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3302,14 +3197,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.7
|
||||
|
||||
'@hookform/[email protected](@standard-schema/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.85.0([email protected])
|
||||
optionalDependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@img/[email protected]': {}
|
||||
|
||||
'@img/[email protected]':
|
||||
@@ -3873,8 +3760,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@swc/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -4019,14 +3904,6 @@ snapshots:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.8
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@tokenizer/[email protected]([email protected])':
|
||||
dependencies:
|
||||
debug: 4.4.3([email protected])
|
||||
@@ -4862,10 +4739,6 @@ snapshots:
|
||||
react: 19.2.8
|
||||
scheduler: 0.27.0
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
|
||||
import {
|
||||
discoverLegacyPages,
|
||||
recordedLegacyPages,
|
||||
} from "../src/features/housekeeping/migration/discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
|
||||
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
|
||||
|
||||
const discovered = discoverLegacyPages();
|
||||
const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX);
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
recorded,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
if (discovered.length > 0) {
|
||||
issues.push(
|
||||
`legacy UI routes remain after cutover: ${discovered.map((page) => page.legacyPath).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parityTest = spawnSync(
|
||||
process.execPath,
|
||||
@@ -31,7 +40,7 @@ if (issues.length > 0 || !parityPassed) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
|
||||
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${recorded.length} historical rows valid; legacy UI pages present=${discovered.length}`,
|
||||
);
|
||||
const removed = HOUSEKEEPING_MIGRATION_MATRIX.filter(
|
||||
(row) => row.status === "REMOVED",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
: "";
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -107,5 +107,5 @@ export async function deleteFavicon(): Promise<{
|
||||
|
||||
function siteRevalidate(): void {
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
revalidatePath("/ase/content/brand/favicon");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export { default } from "../loading";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} · {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");
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user