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
|
PORT=3002
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
UV_THREADPOOL_SIZE=16
|
UV_THREADPOOL_SIZE=16
|
||||||
# Non-production preview only; production always returns 404.
|
|
||||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
|
||||||
|
|
||||||
# --- HOTEL & URLS ---
|
# --- HOTEL & URLS ---
|
||||||
HOTEL_NAME=EPIC WEB CONTROL
|
HOTEL_NAME=EPIC WEB CONTROL
|
||||||
APP_URL=http://localhost:3002
|
APP_URL=http://localhost:3002
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
pnpm exec lint-staged
|
pnpm exec lint-staged
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
@@ -34,66 +34,6 @@ const nextConfig: NextConfig = {
|
|||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
|
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: {
|
turbopack: {
|
||||||
ignoreIssue: [
|
ignoreIssue: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,8 +34,6 @@
|
|||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@hookform/resolvers": "5.9.1",
|
|
||||||
"@tanstack/react-virtual": "3.14.10",
|
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
@@ -60,7 +58,6 @@
|
|||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.85.0",
|
|
||||||
"resend": "6.21.0",
|
"resend": "6.21.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
|
|||||||
Generated
-127
@@ -34,12 +34,6 @@ importers:
|
|||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: 3.2.2
|
specifier: 3.2.2
|
||||||
version: 3.2.2([email protected])
|
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:
|
class-variance-authority:
|
||||||
specifier: 0.7.1
|
specifier: 0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -112,9 +106,6 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.8
|
specifier: 19.2.8
|
||||||
version: 19.2.8([email protected])
|
version: 19.2.8([email protected])
|
||||||
react-hook-form:
|
|
||||||
specifier: 7.85.0
|
|
||||||
version: 7.85.0([email protected])
|
|
||||||
resend:
|
resend:
|
||||||
specifier: 6.21.0
|
specifier: 6.21.0
|
||||||
version: 6.21.0
|
version: 6.21.0
|
||||||
@@ -549,84 +540,6 @@ packages:
|
|||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
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]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1484,9 +1397,6 @@ packages:
|
|||||||
'@standard-schema/[email protected]':
|
'@standard-schema/[email protected]':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@standard-schema/[email protected]':
|
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
|
resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1685,15 +1595,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
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]':
|
'@tokenizer/[email protected]':
|
||||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2658,12 +2559,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.8
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3302,14 +3197,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/fast-memoize': 3.1.7
|
'@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]': {}
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
@@ -3873,8 +3760,6 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/[email protected]': {}
|
'@standard-schema/[email protected]': {}
|
||||||
|
|
||||||
'@standard-schema/[email protected]': {}
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4019,14 +3904,6 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 4.3.3
|
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])':
|
'@tokenizer/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3([email protected])
|
debug: 4.4.3([email protected])
|
||||||
@@ -4862,10 +4739,6 @@ snapshots:
|
|||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
[email protected]([email protected]):
|
|
||||||
dependencies:
|
|
||||||
react: 19.2.8
|
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { resolve } from "node:path";
|
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 { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
|
||||||
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
|
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
|
||||||
|
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
|
const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX);
|
||||||
const issues = validateMigrationEntries(
|
const issues = validateMigrationEntries(
|
||||||
discovered,
|
recorded,
|
||||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||||
);
|
);
|
||||||
|
if (discovered.length > 0) {
|
||||||
|
issues.push(
|
||||||
|
`legacy UI routes remain after cutover: ${discovered.map((page) => page.legacyPath).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parityTest = spawnSync(
|
const parityTest = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
@@ -31,7 +40,7 @@ if (issues.length > 0 || !parityPassed) {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
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(
|
const removed = HOUSEKEEPING_MIGRATION_MATRIX.filter(
|
||||||
(row) => row.status === "REMOVED",
|
(row) => row.status === "REMOVED",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ describe("Content advertisement legacy wrappers", () => {
|
|||||||
"ad.change",
|
"ad.change",
|
||||||
{ action: "create", image: "https://example.com/ad.png" },
|
{ 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 () => {
|
it("returns early when image is empty", async () => {
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
action: "createAd",
|
action: "createAd",
|
||||||
error: result.error.messageKey,
|
error: result.error.messageKey,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/ads");
|
revalidatePath("/ase/content/media/ads");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/admin/ads");
|
redirect("/ase/content/media/ads");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAd(formData: FormData): Promise<void> {
|
export async function updateAd(formData: FormData): Promise<void> {
|
||||||
@@ -57,10 +57,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
|||||||
id: Number(raw),
|
id: Number(raw),
|
||||||
error: result.error.messageKey,
|
error: result.error.messageKey,
|
||||||
});
|
});
|
||||||
revalidatePath(`/admin/ads/${raw}`);
|
revalidatePath(`/ase/content/media/ads/${raw}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/admin/ads");
|
redirect("/ase/content/media/ads");
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAdInput = z.object({
|
const deleteAdInput = z.object({
|
||||||
@@ -82,7 +82,7 @@ export const deleteAd = adminAction(
|
|||||||
{ action: "delete", id: ctx.data.id.toString() },
|
{ action: "delete", id: ctx.data.id.toString() },
|
||||||
);
|
);
|
||||||
if (!result.ok) throw new ActionError("Advertisement not found");
|
if (!result.ok) throw new ActionError("Advertisement not found");
|
||||||
revalidatePath("/admin/ads");
|
revalidatePath("/ase/content/media/ads");
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -37,7 +37,9 @@ describe("sendHotelAlert", () => {
|
|||||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
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 () => {
|
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.
|
// 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. */
|
/** Mark every unread ops alert as read. */
|
||||||
@@ -67,5 +67,5 @@ export async function markAllAlertsRead(): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/alerts");
|
revalidatePath("/ase/system/operations/alerts");
|
||||||
}
|
}
|
||||||
@@ -22,5 +22,5 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
|||||||
{ applicationId, decision: "dismiss" },
|
{ applicationId, decision: "dismiss" },
|
||||||
);
|
);
|
||||||
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
|
// 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) {
|
if (!result.ok) {
|
||||||
redirect(
|
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> {
|
export async function updateArticle(formData: FormData): Promise<void> {
|
||||||
@@ -53,9 +53,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
"article.change",
|
"article.change",
|
||||||
{ action: "update", id, ...articleInput(formData) },
|
{ action: "update", id, ...articleInput(formData) },
|
||||||
);
|
);
|
||||||
if (!result.ok) redirect("/admin/articles?error=Update failed");
|
if (!result.ok)
|
||||||
revalidatePath(`/admin/articles/${id}`);
|
redirect("/ase/content/editorial/articles?error=Update failed");
|
||||||
redirect("/admin/articles");
|
revalidatePath(`/ase/content/editorial/articles/${id}`);
|
||||||
|
redirect("/ase/content/editorial/articles");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
@@ -66,6 +67,7 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
"article.change",
|
"article.change",
|
||||||
{ action: "delete", id },
|
{ action: "delete", id },
|
||||||
);
|
);
|
||||||
if (!result.ok) redirect("/admin/articles?error=Delete failed");
|
if (!result.ok)
|
||||||
redirect("/admin/articles");
|
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",
|
"ban.create",
|
||||||
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
|
{ 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 () => {
|
it("returns early when userId is invalid", async () => {
|
||||||
@@ -62,6 +62,6 @@ describe("legacy admin ban wrappers", () => {
|
|||||||
"ban.lift",
|
"ban.lift",
|
||||||
{ id: 42 },
|
{ 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");
|
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> {
|
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");
|
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", () => {
|
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" }));
|
await disbandGuild(form({ id: "9" }));
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
|
||||||
guildId: 9,
|
guildId: 9,
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/community/guilds");
|
||||||
});
|
});
|
||||||
it("keeps invalid IDs as a no-op", async () => {
|
it("keeps invalid IDs as a no-op", async () => {
|
||||||
await disbandGuild(form({ id: "0" }));
|
await disbandGuild(form({ id: "0" }));
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
|||||||
if (result.error.code === "NOT_FOUND") return;
|
if (result.error.code === "NOT_FOUND") return;
|
||||||
throw new Error("Could not disband guild");
|
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) {
|
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||||
const id = String(ticketId);
|
const id = String(ticketId);
|
||||||
revalidatePath("/admin/help-tickets");
|
revalidatePath("/ase/people/support/help-tickets");
|
||||||
revalidatePath(`/admin/help-tickets/${id}`);
|
revalidatePath(`/ase/people/support/help-tickets/${id}`);
|
||||||
revalidatePath("/mod/help-tickets");
|
|
||||||
revalidatePath(`/mod/help-tickets/${id}`);
|
|
||||||
revalidatePath("/help/tickets");
|
revalidatePath("/help/tickets");
|
||||||
revalidatePath(`/help/tickets/${id}`);
|
revalidatePath(`/help/tickets/${id}`);
|
||||||
}
|
}
|
||||||
@@ -76,10 +74,10 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
revalidatePath("/admin/bans");
|
revalidatePath("/ase/people/moderation/bans");
|
||||||
const removed = Number(result.data.output?.removed ?? 0);
|
const removed = Number(result.data.output?.removed ?? 0);
|
||||||
const userId = Number(result.data.output?.userId);
|
const userId = Number(result.data.output?.userId);
|
||||||
revalidatePath(`/admin/users/show/${userId}`);
|
revalidatePath(`/ase/people/users/${userId}`);
|
||||||
return actionOk({ removed, userId });
|
return actionOk({ removed, userId });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("Content help legacy wrappers", () => {
|
|||||||
"help-question.change",
|
"help-question.change",
|
||||||
expect.objectContaining({ action: "create", name: "FAQ" }),
|
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 () => {
|
it("delegates update and redirects", async () => {
|
||||||
await updateHelpQuestion(
|
await updateHelpQuestion(
|
||||||
@@ -56,7 +56,7 @@ describe("Content help legacy wrappers", () => {
|
|||||||
"help-question.change",
|
"help-question.change",
|
||||||
expect.objectContaining({ action: "update", id: "42" }),
|
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 () => {
|
it("delegates delete and redirects", async () => {
|
||||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||||
@@ -65,6 +65,6 @@ describe("Content help legacy wrappers", () => {
|
|||||||
"help-question.change",
|
"help-question.change",
|
||||||
{ action: "delete", id: "42" },
|
{ 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 },
|
{ action: "create", ...input },
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
revalidatePath("/admin/help-questions");
|
revalidatePath("/ase/content/help/questions");
|
||||||
redirect(
|
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");
|
revalidatePath("/ase/content/help/questions");
|
||||||
redirect("/admin/help-questions");
|
redirect("/ase/content/help/questions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||||
@@ -60,10 +60,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
{ action: "update", id, ...input },
|
{ action: "update", id, ...input },
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
revalidatePath(`/admin/help-questions/${id}`);
|
revalidatePath(`/ase/content/help/questions/${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/admin/help-questions");
|
redirect("/ase/content/help/questions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||||
@@ -75,5 +75,5 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
"help-question.change",
|
"help-question.change",
|
||||||
{ action: "delete", id },
|
{ 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,
|
input,
|
||||||
);
|
);
|
||||||
if (!result.ok) throw new Error("Could not update IP rules");
|
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> {
|
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ describe("saveMaintenance", () => {
|
|||||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
expect(mockReload).toHaveBeenCalledOnce();
|
expect(mockReload).toHaveBeenCalledOnce();
|
||||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/ase/system/operations/maintenance",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables maintenance mode", async () => {
|
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);
|
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",
|
filename: "photo.png",
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
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");
|
if (!result.ok) throw new Error("Media upload failed");
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/ase/content/media/library");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export async function deleteMedia(name: string): Promise<void> {
|
|||||||
{ filename: name },
|
{ filename: name },
|
||||||
);
|
);
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/ase/content/media/library");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadMediaAndReturn(
|
export async function uploadMediaAndReturn(
|
||||||
@@ -67,7 +67,7 @@ export async function uploadMediaAndReturn(
|
|||||||
);
|
);
|
||||||
if (!result.ok) return "";
|
if (!result.ok) return "";
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/ase/content/media/library");
|
||||||
return typeof result.data.output?.url === "string"
|
return typeof result.data.output?.url === "string"
|
||||||
? result.data.output.url
|
? 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", {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
||||||
id: 42,
|
id: 42,
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/photos");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||||
});
|
});
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
|||||||
"photo.delete",
|
"photo.delete",
|
||||||
{ id },
|
{ id },
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/ase/content/media/photos");
|
||||||
revalidatePath("/photos");
|
revalidatePath("/photos");
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
|||||||
allowedIps: text(formData, "allowedIps") || undefined,
|
allowedIps: text(formData, "allowedIps") || undefined,
|
||||||
rateLimit: Number(text(formData, "rateLimit") || 300),
|
rateLimit: Number(text(formData, "rateLimit") || 300),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/api-keys");
|
revalidatePath("/ase/hotel/radio/api-keys");
|
||||||
redirect("/admin/radio/api-keys?created=1");
|
redirect("/ase/hotel/radio/api-keys?created=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
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", {
|
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
|
||||||
id: text(formData, "id"),
|
id: text(formData, "id"),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/api-keys");
|
revalidatePath("/ase/hotel/radio/api-keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
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", {
|
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
|
||||||
id: text(formData, "id"),
|
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),
|
sortOrder: Number(text(formData, "sortOrder") || 0),
|
||||||
isActive: enabled(formData, "isActive"),
|
isActive: enabled(formData, "isActive"),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/autodj");
|
revalidatePath("/ase/hotel/radio/autodj");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||||
@@ -36,7 +36,7 @@ export async function toggleTrack(formData: FormData): Promise<void> {
|
|||||||
id: text(formData, "id"),
|
id: text(formData, "id"),
|
||||||
isActive: enabled(formData, "isActive"),
|
isActive: enabled(formData, "isActive"),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/autodj");
|
revalidatePath("/ase/hotel/radio/autodj");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
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", {
|
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
|
||||||
id: text(formData, "id"),
|
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,
|
comment: text(formData, "comment") || undefined,
|
||||||
});
|
});
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
revalidatePath("/admin/radio/settings");
|
revalidatePath("/ase/hotel/radio/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||||
@@ -42,7 +42,7 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
|
|||||||
entries,
|
entries,
|
||||||
});
|
});
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
revalidatePath("/admin/radio/settings");
|
revalidatePath("/ase/hotel/radio/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
function bannerInput(formData: FormData) {
|
function bannerInput(formData: FormData) {
|
||||||
@@ -62,7 +62,7 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
|||||||
"radio.banner.create",
|
"radio.banner.create",
|
||||||
bannerInput(formData),
|
bannerInput(formData),
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/radio/banners");
|
revalidatePath("/ase/hotel/radio/banners");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||||
@@ -71,7 +71,7 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
|
|||||||
id: text(formData, "id"),
|
id: text(formData, "id"),
|
||||||
...bannerInput(formData),
|
...bannerInput(formData),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/banners");
|
revalidatePath("/ase/hotel/radio/banners");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
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", {
|
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
|
||||||
id: text(formData, "id"),
|
id: text(formData, "id"),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/banners");
|
revalidatePath("/ase/hotel/radio/banners");
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankInput(formData: FormData) {
|
function rankInput(formData: FormData) {
|
||||||
@@ -98,7 +98,7 @@ export async function createRadioRank(formData: FormData): Promise<void> {
|
|||||||
"radio.rank.create",
|
"radio.rank.create",
|
||||||
rankInput(formData),
|
rankInput(formData),
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/radio/ranks");
|
revalidatePath("/ase/hotel/radio/ranks");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||||
@@ -107,7 +107,7 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
|
|||||||
id: text(formData, "id"),
|
id: text(formData, "id"),
|
||||||
...rankInput(formData),
|
...rankInput(formData),
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/radio/ranks");
|
revalidatePath("/ase/hotel/radio/ranks");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
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", {
|
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
|
||||||
id: text(formData, "id"),
|
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", {
|
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
|
||||||
id: String(formData.get("id") ?? "").trim(),
|
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();
|
siteSettings.reload();
|
||||||
revalidatePath("/admin/radio/points");
|
revalidatePath("/ase/hotel/radio/points");
|
||||||
redirect("/admin/radio/points?saved=1");
|
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",
|
name: "News",
|
||||||
backgroundColor: "#ff0000",
|
backgroundColor: "#ff0000",
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
||||||
});
|
});
|
||||||
it("uses the legacy default color", async () => {
|
it("uses the legacy default color", async () => {
|
||||||
await createTag(form({ name: "Test" }));
|
await createTag(form({ name: "Test" }));
|
||||||
@@ -64,7 +64,7 @@ describe("Content tag legacy wrappers", () => {
|
|||||||
correlationId: "legacy",
|
correlationId: "legacy",
|
||||||
});
|
});
|
||||||
await createTag(form({ name: "News" }));
|
await createTag(form({ name: "News" }));
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
||||||
});
|
});
|
||||||
it("delegates update", async () => {
|
it("delegates update", async () => {
|
||||||
await updateTag(
|
await updateTag(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function createTag(formData: FormData): Promise<void> {
|
|||||||
"tag.change",
|
"tag.change",
|
||||||
{ action: "create", ...input },
|
{ action: "create", ...input },
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/tags");
|
revalidatePath("/ase/content/editorial/tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTag(formData: FormData): Promise<void> {
|
export async function updateTag(formData: FormData): Promise<void> {
|
||||||
@@ -44,7 +44,7 @@ export async function updateTag(formData: FormData): Promise<void> {
|
|||||||
"tag.change",
|
"tag.change",
|
||||||
{ action: "update", id, ...input },
|
{ action: "update", id, ...input },
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/tags");
|
revalidatePath("/ase/content/editorial/tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTag(formData: FormData): Promise<void> {
|
export async function deleteTag(formData: FormData): Promise<void> {
|
||||||
@@ -56,5 +56,5 @@ export async function deleteTag(formData: FormData): Promise<void> {
|
|||||||
"tag.change",
|
"tag.change",
|
||||||
{ action: "delete", id },
|
{ 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");
|
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> {
|
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") {
|
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||||
throw new Error("Could not delete team");
|
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(
|
await saveVpn(
|
||||||
form({
|
form({
|
||||||
vpn_block_enabled: "1",
|
vpn_block_enabled: "1",
|
||||||
@@ -51,8 +51,8 @@ it("preserves VPN payload, /admin revalidation, and redirect", async () => {
|
|||||||
apiKey: "abc123",
|
apiKey: "abc123",
|
||||||
blockMessage: "",
|
blockMessage: "",
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/vpn");
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
|
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" }));
|
await saveVpn(form({ vpn_provider: "none" }));
|
||||||
expect(revalidatePath).not.toHaveBeenCalled();
|
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),
|
.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.
|
// 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)
|
if (!result.ok)
|
||||||
return actionError("Could not add word (it may already exist)");
|
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 ?? "") });
|
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") {
|
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||||
return actionError("Could not remove word");
|
return actionError("Could not remove word");
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/wordfilter");
|
revalidatePath("/ase/people/moderation/word-filter");
|
||||||
return actionOk();
|
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";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
function revalidateBuilderClub(): void {
|
function revalidateBuilderClub(): void {
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBcPage({
|
export async function updateBcPage({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
function revalidateCatalog(): void {
|
function revalidateCatalog(): void {
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCatalogItem(data: {
|
export async function createCatalogItem(data: {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function updateCatalogPage({
|
|||||||
id,
|
id,
|
||||||
...fields,
|
...fields,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export async function deleteCatalogPage({ id }: { id: number }) {
|
|||||||
action: "delete",
|
action: "delete",
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export async function toggleCatalogPage({
|
|||||||
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
|
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export async function createCatalogPage(input: {
|
|||||||
{ action: "create", ...input },
|
{ action: "create", ...input },
|
||||||
);
|
);
|
||||||
const createdId = Number(snapshot.output?.id);
|
const createdId = Number(snapshot.output?.id);
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
return { ok: true as const, data: { id: createdId } };
|
return { ok: true as const, data: { id: createdId } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export async function reorderTreePage(input: {
|
|||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
|
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +85,6 @@ export async function deleteTreePage(input: {
|
|||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
|
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/ase/economy/catalog");
|
||||||
return { ok: true as const, data: {} };
|
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 () => {
|
it("delegates article creation and preserves redirect ordering", async () => {
|
||||||
await createArticle(
|
await createArticle(
|
||||||
form({
|
form({
|
||||||
@@ -151,7 +151,7 @@ describe("Content legacy wrappers", () => {
|
|||||||
"article.change",
|
"article.change",
|
||||||
expect.objectContaining({ action: "create", title: "Launch" }),
|
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 () => {
|
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||||
@@ -165,7 +165,7 @@ describe("Content legacy wrappers", () => {
|
|||||||
"ad.change",
|
"ad.change",
|
||||||
expect.objectContaining({ action: "create" }),
|
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 () => {
|
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
||||||
@@ -308,22 +308,14 @@ describe("Content legacy wrappers", () => {
|
|||||||
for (const path of [
|
for (const path of [
|
||||||
"src/actions/admin-ads.ts",
|
"src/actions/admin-ads.ts",
|
||||||
"src/actions/admin-articles.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-help.ts",
|
||||||
"src/actions/admin-media.ts",
|
"src/actions/admin-media.ts",
|
||||||
"src/actions/admin-nav-menu.ts",
|
|
||||||
"src/actions/admin-photos.ts",
|
"src/actions/admin-photos.ts",
|
||||||
"src/actions/admin-tags.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/events.ts",
|
||||||
"src/actions/polls.ts",
|
"src/actions/polls.ts",
|
||||||
"src/actions/prefixes.ts",
|
|
||||||
"src/actions/save-favicon.ts",
|
"src/actions/save-favicon.ts",
|
||||||
"src/actions/save-logo.ts",
|
"src/actions/save-logo.ts",
|
||||||
"src/actions/translations.ts",
|
|
||||||
"src/actions/emulator.ts",
|
"src/actions/emulator.ts",
|
||||||
]) {
|
]) {
|
||||||
const source = readFileSync(path, "utf8");
|
const source = readFileSync(path, "utf8");
|
||||||
@@ -335,21 +327,4 @@ describe("Content legacy wrappers", () => {
|
|||||||
).toBe(true);
|
).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", () => {
|
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" }));
|
await dismissApplication(form({ id: "9" }));
|
||||||
expect(execute).toHaveBeenCalledWith(
|
expect(execute).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ expectedActorId: 1 }),
|
expect.objectContaining({ expectedActorId: 1 }),
|
||||||
"application.decide",
|
"application.decide",
|
||||||
{ applicationId: "9", decision: "dismiss" },
|
{ 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 () => {
|
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" },
|
{ 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({
|
await expect(addWord({ word: "spam" })).resolves.toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { id: "12" },
|
data: { id: "12" },
|
||||||
@@ -143,6 +145,8 @@ describe("legacy application and word-filter wrappers", () => {
|
|||||||
{ action: "add", word: "spam" },
|
{ action: "add", word: "spam" },
|
||||||
{ action: "delete", id: "12" },
|
{ 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", status: "reopen" },
|
||||||
{ ticketId: "9007199254740993" },
|
{ ticketId: "9007199254740993" },
|
||||||
]);
|
]);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
expect(revalidatePath).toHaveBeenCalledWith(
|
||||||
"/admin/help-tickets/9007199254740993",
|
"/ase/people/support/help-tickets",
|
||||||
);
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
expect(revalidatePath).toHaveBeenCalledWith(
|
||||||
"/mod/help-tickets/9007199254740993",
|
"/ase/people/support/help-tickets/9007199254740993",
|
||||||
);
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
expect(revalidatePath).toHaveBeenCalledWith(
|
||||||
"/help/tickets/9007199254740993",
|
"/help/tickets/9007199254740993",
|
||||||
);
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delegates every CFH and moderation transport action", async () => {
|
it("delegates every CFH and moderation transport action", async () => {
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ describe("legacy wrapper failure compatibility", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
data: {},
|
data: {},
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/staff/teams");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
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);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room-item.update", payload);
|
await executeLegacyHotelMutation(staff, "room-item.update", payload);
|
||||||
const roomId = Number(payload.roomId);
|
const roomId = Number(payload.roomId);
|
||||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
revalidatePath(`/ase/hotel/rooms/${roomId}/furni`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeleteRoomItems(input: {
|
export async function bulkDeleteRoomItems(input: {
|
||||||
@@ -18,7 +18,7 @@ export async function bulkDeleteRoomItems(input: {
|
|||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
|
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: {
|
export async function deleteRoomItem(input: {
|
||||||
@@ -27,7 +27,7 @@ export async function deleteRoomItem(input: {
|
|||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room-item.delete", input);
|
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: {
|
export async function roomRconAction(input: {
|
||||||
@@ -41,7 +41,7 @@ export async function roomRconAction(input: {
|
|||||||
export async function deleteRoom(input: { id: number }) {
|
export async function deleteRoom(input: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||||
await executeLegacyHotelMutation(staff, "room.delete", input);
|
await executeLegacyHotelMutation(staff, "room.delete", input);
|
||||||
revalidatePath("/admin/rooms");
|
revalidatePath("/ase/hotel/rooms");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRoom(input: {
|
export async function updateRoom(input: {
|
||||||
@@ -53,5 +53,5 @@ export async function updateRoom(input: {
|
|||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room.update", input);
|
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 {
|
function siteRevalidate(): void {
|
||||||
revalidatePath("/", "layout");
|
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";
|
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.
|
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||||
*/
|
*/
|
||||||
export default async function SiteLayout({
|
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