style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
604 files changed
+19666 -12215

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+8 -5
View File
@@ -1,10 +1,10 @@
import js from "@eslint/js"; import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import nextPlugin from "@next/eslint-plugin-next"; import nextPlugin from "@next/eslint-plugin-next";
import security from "eslint-plugin-security";
import jsxA11y from "eslint-plugin-jsx-a11y";
import prettier from "eslint-config-prettier"; import prettier from "eslint-config-prettier";
import jsxA11y from "eslint-plugin-jsx-a11y";
import reactHooks from "eslint-plugin-react-hooks";
import security from "eslint-plugin-security";
import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
js.configs.recommended, js.configs.recommended,
@@ -25,7 +25,10 @@ export default tseslint.config(
"no-console": "off", "no-console": "off",
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-non-null-assertion": "warn", "@typescript-eslint/no-non-null-assertion": "warn",
+16 -3
View File
@@ -3,7 +3,10 @@ import createNextIntlPlugin from "next-intl/plugin";
const securityHeaders = [ const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" }, { key: "X-DNS-Prefetch-Control", value: "on" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, {
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{ key: "X-Frame-Options", value: "DENY" }, { key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
@@ -30,7 +33,12 @@ const securityHeaders = [
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
turbopack: { root: import.meta.dirname }, turbopack: { root: import.meta.dirname },
serverExternalPackages: ["@prisma/adapter-mariadb", "mariadb", "@prisma/client", "lzma"], serverExternalPackages: [
"@prisma/adapter-mariadb",
"mariadb",
"@prisma/client",
"lzma",
],
// Compress responses with gzip // Compress responses with gzip
compress: true, compress: true,
@@ -52,7 +60,12 @@ const nextConfig: NextConfig = {
}, },
{ {
source: "/assets/(.*)", source: "/assets/(.*)",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }], headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
}, },
{ {
source: "/images/(.*)", source: "/images/(.*)",
+8 -12
View File
@@ -12,8 +12,9 @@
"start": "next start", "start": "next start",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 200", "biome:check": "biome check --write .",
"format": "prettier --write .", "biome:lint": "biome lint .",
"biome:format": "biome format --write .",
"test": "vitest run", "test": "vitest run",
"db:migrate": "tsx scripts/apply-migrations.ts", "db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status", "db:migrate:status": "tsx scripts/apply-migrations.ts --status",
@@ -57,8 +58,7 @@
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@biomejs/biome": "2.5.3",
"@next/eslint-plugin-next": "^16.2.10",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
@@ -70,18 +70,11 @@
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@vitalets/google-translate-api": "^9.2.1", "@vitalets/google-translate-api": "^9.2.1",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-security": "^4.0.1",
"postcss": "^8.5.16", "postcss": "^8.5.16",
"prettier": "^3.9.5",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"tsx": "^4.22.5", "tsx": "^4.22.5",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"typescript-eslint": "^8.63.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"
}, },
"pnpm": { "pnpm": {
@@ -89,7 +82,10 @@
"esbuild", "esbuild",
"prisma", "prisma",
"@prisma/client", "@prisma/client",
"@prisma/engines" "@prisma/engines",
"sharp",
"@parcel/watcher",
"@swc/core"
], ],
"overrides": { "overrides": {
"fast-uri": "^3.1.3", "fast-uri": "^3.1.3",
+2890 -7575
View File
File diff suppressed because it is too large. Load diff
+17 -4
View File
@@ -33,13 +33,23 @@
"get": { "get": {
"summary": "List published articles", "summary": "List published articles",
"parameters": [ "parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }, {
{ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } } "name": "limit",
"in": "query",
"schema": { "type": "integer", "default": 10 }
},
{
"name": "offset",
"in": "query",
"schema": { "type": "integer", "default": 0 }
}
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Article list", "description": "Article list",
"content": { "application/json": { "schema": { "type": "object" } } } "content": {
"application/json": { "schema": { "type": "object" } }
}
} }
} }
} }
@@ -52,7 +62,10 @@
"description": "Online count", "description": "Online count",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "type": "object", "properties": { "count": { "type": "integer" } } } "schema": {
"type": "object",
"properties": { "count": { "type": "integer" } }
}
} }
} }
} }
+6 -4
View File
@@ -1,12 +1,14 @@
(function () { (() => {
try { try {
var s = localStorage.getItem("theme"); var s = localStorage.getItem("theme");
var dd = document.querySelector('meta[name="theme-default-dark"]'); var dd = document.querySelector('meta[name="theme-default-dark"]');
var defaultDark = dd ? dd.getAttribute("content") === "true" : false; var defaultDark = dd ? dd.getAttribute("content") === "true" : false;
if (s === "dark" || (!s && defaultDark)) document.documentElement.classList.add("dark"); if (s === "dark" || (!s && defaultDark))
document.documentElement.classList.add("dark");
var nc = localStorage.getItem("navbarColor"), var nc = localStorage.getItem("navbarColor"),
nt = localStorage.getItem("navbarTextColor"); nt = localStorage.getItem("navbarTextColor");
if (nc) document.documentElement.style.setProperty("--color-navbar", nc); if (nc) document.documentElement.style.setProperty("--color-navbar", nc);
if (nt) document.documentElement.style.setProperty("--color-navbar-text", nt); if (nt)
} catch (e) {} document.documentElement.style.setProperty("--color-navbar-text", nt);
} catch (_e) {}
})(); })();
+9 -2
View File
@@ -11,7 +11,11 @@ self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches caches
.keys() .keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)),
),
)
.then(() => self.clients.claim()), .then(() => self.clients.claim()),
); );
}); });
@@ -23,7 +27,10 @@ self.addEventListener("fetch", (event) => {
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
// Cache-first for immutable static assets. // Cache-first for immutable static assets.
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/_next/static/")) { if (
url.pathname.startsWith("/assets/") ||
url.pathname.startsWith("/_next/static/")
) {
event.respondWith( event.respondWith(
caches.open(CACHE).then((cache) => caches.open(CACHE).then((cache) =>
cache.match(req).then( cache.match(req).then(
+15 -7
View File
@@ -1,9 +1,9 @@
import "dotenv/config"; import "dotenv/config";
import { readFileSync, readdirSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { splitSqlStatements } from "./sql-statements";
import { mysqlConnectionUrl } from "./db-url"; import { mysqlConnectionUrl } from "./db-url";
import { splitSqlStatements } from "./sql-statements";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations"); const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
@@ -19,7 +19,8 @@ function getDbConfig(): { url: string; database: string } {
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is required"); if (!url) throw new Error("DATABASE_URL is required");
const parsed = new URL(url); const parsed = new URL(url);
const dbName = decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "atomcms"; const dbName =
decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "atomcms";
return { url: mysqlConnectionUrl(url), database: dbName }; return { url: mysqlConnectionUrl(url), database: dbName };
} }
@@ -45,7 +46,9 @@ async function getApplied(): Promise<Set<string>> {
const mysql = await import("mysql2/promise"); const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url); const conn = await mysql.createConnection(url);
try { try {
const [rows] = await conn.execute(`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`); const [rows] = await conn.execute(
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
);
return new Set((rows as { migration: string }[]).map((r) => r.migration)); return new Set((rows as { migration: string }[]).map((r) => r.migration));
} catch { } catch {
return new Set(); return new Set();
@@ -78,7 +81,10 @@ async function apply(migration: MigrationFile): Promise<void> {
await conn.execute(stmt); await conn.execute(stmt);
} }
await conn.execute(`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`, [migration.id]); await conn.execute(
`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`,
[migration.id],
);
console.log(`[migrate] Applied: ${migration.name}`); console.log(`[migrate] Applied: ${migration.name}`);
} finally { } finally {
await conn.end(); await conn.end();
@@ -96,7 +102,9 @@ async function main() {
console.log("\nMigration status:\n"); console.log("\nMigration status:\n");
for (const m of all) { for (const m of all) {
const done = applied.has(m.id); const done = applied.has(m.id);
console.log(` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`); console.log(
` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`,
);
} }
const pending = all.filter((m) => !applied.has(m.id)); const pending = all.filter((m) => !applied.has(m.id));
+2 -1
View File
@@ -7,6 +7,7 @@ if (missing.length === 0) {
process.exit(0); process.exit(0);
} }
for (const item of missing) console.error(`${item.importer}: ${item.specifier}`); for (const item of missing)
console.error(`${item.importer}: ${item.specifier}`);
console.error(`${missing.length} unresolved local import(s).`); console.error(`${missing.length} unresolved local import(s).`);
process.exitCode = 1; process.exitCode = 1;
+6 -2
View File
@@ -1,7 +1,11 @@
const MYSQL2_UNSUPPORTED_OPTIONS = ["connection_limit", "pool_timeout"] as const; const MYSQL2_UNSUPPORTED_OPTIONS = [
"connection_limit",
"pool_timeout",
] as const;
export function mysqlConnectionUrl(value: string): string { export function mysqlConnectionUrl(value: string): string {
const url = new URL(value); const url = new URL(value);
for (const option of MYSQL2_UNSUPPORTED_OPTIONS) url.searchParams.delete(option); for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
url.searchParams.delete(option);
return url.toString(); return url.toString();
} }
+20 -6
View File
@@ -5,11 +5,15 @@ import { prisma } from "../src/lib/prisma";
async function backupEmulatorJar(): Promise<void> { async function backupEmulatorJar(): Promise<void> {
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return; if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = await import("node:fs"); const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
await import("node:fs");
const { resolve } = await import("node:path"); const { resolve } = await import("node:path");
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-"); const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const backupFile = resolve(env.EMULATOR_BACKUP_DIR, `emulator-${timestamp}.jar`); const backupFile = resolve(
env.EMULATOR_BACKUP_DIR,
`emulator-${timestamp}.jar`,
);
if (!existsSync(env.EMULATOR_BACKUP_DIR)) { if (!existsSync(env.EMULATOR_BACKUP_DIR)) {
mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true }); mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true });
@@ -38,7 +42,9 @@ async function backupEmulatorJar(): Promise<void> {
async function cleanupOldLogs(): Promise<void> { async function cleanupOldLogs(): Promise<void> {
try { try {
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await prisma.websiteLoginLogs.deleteMany({ where: { createdAt: { lt: cutoff } } }); await prisma.websiteLoginLogs.deleteMany({
where: { createdAt: { lt: cutoff } },
});
console.log("[jobs] Cleaned up login logs older than 30 days"); console.log("[jobs] Cleaned up login logs older than 30 days");
} catch (err) { } catch (err) {
console.error("[jobs] Log cleanup failed:", err); console.error("[jobs] Log cleanup failed:", err);
@@ -48,7 +54,9 @@ async function cleanupOldLogs(): Promise<void> {
async function cleanupOldSessions(): Promise<void> { async function cleanupOldSessions(): Promise<void> {
try { try {
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await prisma.passwordReset.deleteMany({ where: { createdAt: { lt: cutoff } } }); await prisma.passwordReset.deleteMany({
where: { createdAt: { lt: cutoff } },
});
console.log("[jobs] Cleaned up expired password reset tokens"); console.log("[jobs] Cleaned up expired password reset tokens");
} catch (err) { } catch (err) {
console.error("[jobs] Session cleanup failed:", err); console.error("[jobs] Session cleanup failed:", err);
@@ -61,7 +69,9 @@ async function main() {
// JAR backup — daily at 03:00 // JAR backup — daily at 03:00
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) { if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
new Cron("0 3 * * *", () => { new Cron("0 3 * * *", () => {
backupEmulatorJar().catch((e) => console.error("[jobs] Backup error:", e)); backupEmulatorJar().catch((e) =>
console.error("[jobs] Backup error:", e),
);
}); });
console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)"); console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)");
} }
@@ -75,7 +85,11 @@ async function main() {
console.log("[jobs] Scheduled: old data cleanup (daily 04:00)"); console.log("[jobs] Scheduled: old data cleanup (daily 04:00)");
// Run once on startup // Run once on startup
await Promise.all([backupEmulatorJar(), cleanupOldLogs(), cleanupOldSessions()]); await Promise.all([
backupEmulatorJar(),
cleanupOldLogs(),
cleanupOldSessions(),
]);
} }
main().catch((err) => { main().catch((err) => {
+15 -5
View File
@@ -9,7 +9,13 @@
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts * npx tsx scripts/migrate-aes-cbc-to-gcm.ts
*/ */
import "dotenv/config"; import "dotenv/config";
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import {
createCipheriv,
createDecipheriv,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { prisma } from "../src/lib/prisma"; import { prisma } from "../src/lib/prisma";
function getKey(appKey: string): Buffer { function getKey(appKey: string): Buffer {
@@ -39,7 +45,8 @@ function decryptCbc(payload: string, key: Buffer, serialize = true): string {
} }
const iv = Buffer.from(json.iv, "base64"); const iv = Buffer.from(json.iv, "base64");
const decipher = createDecipheriv("aes-256-cbc", key, iv); const decipher = createDecipheriv("aes-256-cbc", key, iv);
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8"); const plain =
decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
return serialize ? phpUnserializeString(plain) : plain; return serialize ? phpUnserializeString(plain) : plain;
} }
@@ -48,7 +55,8 @@ function encryptGcm(plaintext: string, key: Buffer, serialize = true): string {
const iv = randomBytes(12); const iv = randomBytes(12);
const data = serialize ? phpSerializeString(plaintext) : plaintext; const data = serialize ? phpSerializeString(plaintext) : plaintext;
const cipher = createCipheriv("aes-256-gcm", key, iv); const cipher = createCipheriv("aes-256-gcm", key, iv);
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64"); const valueB64 =
cipher.update(data, "utf8", "base64") + cipher.final("base64");
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
const ivB64 = iv.toString("base64"); const ivB64 = iv.toString("base64");
const tagB64 = tag.toString("base64"); const tagB64 = tag.toString("base64");
@@ -57,7 +65,7 @@ function encryptGcm(plaintext: string, key: Buffer, serialize = true): string {
} }
/** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */ /** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */
function isAlreadyGcm(payload: string, key: Buffer): boolean { function isAlreadyGcm(payload: string, _key: Buffer): boolean {
try { try {
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
if (!json.tag && !json.mac) return false; // can't determine format if (!json.tag && !json.mac) return false; // can't determine format
@@ -111,7 +119,9 @@ async function main() {
} }
} }
console.log(`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`); console.log(
`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`,
);
if (errors > 0) process.exit(1); if (errors > 0) process.exit(1);
} }
+4 -1
View File
@@ -4,7 +4,10 @@ import { describe, expect, it } from "vitest";
describe("radio columns migration", () => { describe("radio columns migration", () => {
it("adds every column idempotently for partially migrated databases", () => { it("adds every column idempotently for partially migrated databases", () => {
const sql = readFileSync(resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"), "utf8"); const sql = readFileSync(
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
"utf8",
);
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? []; const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
expect(additions).toEqual([]); expect(additions).toEqual([]);
expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`"); expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`");
+3 -1
View File
@@ -1,5 +1,7 @@
export function splitSqlStatements(sql: string): string[] { export function splitSqlStatements(sql: string): string[] {
const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*--.*$/gm, ""); const withoutComments = sql
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*--.*$/gm, "");
return withoutComments return withoutComments
.split(";") .split(";")
+1 -1
View File
@@ -3,9 +3,9 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
// CRUD for website advertisements (website_ads). Emulator does not own this // CRUD for website advertisements (website_ads). Emulator does not own this
// table; it only stores an image URL rendered in the site layout/widgets. // table; it only stores an image URL rendered in the site layout/widgets.
+1 -1
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> { export async function dismissApplication(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
+10 -4
View File
@@ -2,16 +2,20 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { slugify } from "@/lib/format";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { slugify } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
async function uniqueSlug(title: string): Promise<string> { async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title); const base = slugify(title);
let slug = base; let slug = base;
let n = 2; let n = 2;
while (await prisma.websiteArticles.findUnique({ where: { slug }, select: { id: true } })) { while (
await prisma.websiteArticles.findUnique({
where: { slug },
select: { id: true },
})
) {
slug = `${base}-${n++}`; slug = `${base}-${n++}`;
} }
return slug; return slug;
@@ -49,7 +53,9 @@ export async function createArticle(formData: FormData): Promise<void> {
}); });
} catch { } catch {
// Database error — re-render unchanged with error. // Database error — re-render unchanged with error.
redirect("/admin/articles/new?error=Database error while creating article. Please try again."); redirect(
"/admin/articles/new?error=Database error while creating article. Please try again.",
);
} }
redirect("/admin/articles"); redirect("/admin/articles");
} }
+1 -1
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import path from "node:path";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import path from "node:path";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
+9 -3
View File
@@ -1,14 +1,19 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import type { $Enums } from "@/generated/prisma/client";
import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import type { $Enums } from "@/generated/prisma/client";
type BanType = $Enums.bans_type; type BanType = $Enums.bans_type;
const BAN_TYPES: ReadonlySet<string> = new Set(["account", "ip", "machine", "super"]); const BAN_TYPES: ReadonlySet<string> = new Set([
"account",
"ip",
"machine",
"super",
]);
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600; const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> { export async function createBan(formData: FormData): Promise<void> {
@@ -24,7 +29,8 @@ export async function createBan(formData: FormData): Promise<void> {
if (!(userId > 0) || !BAN_TYPES.has(type)) return; if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS; const banExpire =
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
+1 -1
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> { export async function createEmailTemplate(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
+4 -2
View File
@@ -3,9 +3,9 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry // CRUD for help-center FAQ entries (website_help_center_categories). Each entry
// is a titled content block with an optional image and call-to-action button. // is a titled content block with an optional image and call-to-action button.
@@ -73,7 +73,9 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
} catch { } catch {
// Unique name collision or DB error — re-render unchanged with error. // Unique name collision or DB error — re-render unchanged with error.
revalidatePath("/admin/help-questions"); revalidatePath("/admin/help-questions");
redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again."); redirect(
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
);
} }
revalidatePath("/admin/help-questions"); revalidatePath("/admin/help-questions");
redirect("/admin/help-questions"); redirect("/admin/help-questions");
+3 -1
View File
@@ -44,7 +44,9 @@ export async function deletePermission(formData: FormData): Promise<void> {
if (!raw) return; if (!raw) return;
try { try {
await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } }); await prisma.websiteHousekeepingPermissions.delete({
where: { id: BigInt(raw) },
});
} catch { } catch {
// Already gone / invalid id. // Already gone / invalid id.
} }
+6 -3
View File
@@ -19,8 +19,10 @@ const KEY_MIN_RANK = "min_maintenance_login_rank";
const COMMENTS: Record<string, string> = { const COMMENTS: Record<string, string> = {
[KEY_ENABLED]: "Determines whether maintenance is enabled or not", [KEY_ENABLED]: "Determines whether maintenance is enabled or not",
[KEY_MESSAGE]: "The maintenance message displayed to users while maintenance is activated", [KEY_MESSAGE]:
[KEY_MIN_RANK]: "The minimum rank required to login to the hotel during maintenance", "The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]:
"The minimum rank required to login to the hotel during maintenance",
}; };
async function upsertSetting(key: string, value: string): Promise<void> { async function upsertSetting(key: string, value: string): Promise<void> {
@@ -47,7 +49,8 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const parsedRank = Number.parseInt(rawRank, 10); const parsedRank = Number.parseInt(rawRank, 10);
const minRank = Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5; const minRank =
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
await upsertSetting(KEY_ENABLED, enabled); await upsertSetting(KEY_ENABLED, enabled);
await upsertSetting(KEY_MESSAGE, message); await upsertSetting(KEY_MESSAGE, message);
+6 -4
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
const MEDIA_DIR = "public/assets/images/media"; const MEDIA_DIR = "public/assets/images/media";
@@ -34,7 +34,7 @@ export async function uploadMedia(formData: FormData): Promise<void> {
export async function deleteMedia(name: string): Promise<void> { export async function deleteMedia(name: string): Promise<void> {
await requireStaff(); await requireStaff();
const { unlink } = await import("fs/promises"); const { unlink } = await import("node:fs/promises");
const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
const filePath = path.resolve(baseDir, name); const filePath = path.resolve(baseDir, name);
if (!filePath.startsWith(baseDir + path.sep)) return; if (!filePath.startsWith(baseDir + path.sep)) return;
@@ -47,7 +47,9 @@ export async function deleteMedia(name: string): Promise<void> {
revalidatePath("/admin/media"); revalidatePath("/admin/media");
} }
export async function uploadMediaAndReturn(formData: FormData): Promise<string> { export async function uploadMediaAndReturn(
formData: FormData,
): Promise<string> {
await requireStaff(); await requireStaff();
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file || file.size === 0) return ""; if (!file || file.size === 0) return "";
+13 -4
View File
@@ -3,10 +3,10 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
// website_permissions (model WebsitePermissions) is the CMS-owned permission -> // website_permissions (model WebsitePermissions) is the CMS-owned permission ->
// minimum-rank mapping. Editable columns are exactly: permission (unique name), // minimum-rank mapping. Editable columns are exactly: permission (unique name),
@@ -40,7 +40,13 @@ export async function createPermission(formData: FormData): Promise<void> {
await prisma.websitePermissions.upsert({ await prisma.websitePermissions.upsert({
where: { permission }, where: { permission },
update: { minRank, description, updatedAt: now }, update: { minRank, description, updatedAt: now },
create: { permission, minRank, description, createdAt: now, updatedAt: now }, create: {
permission,
minRank,
description,
createdAt: now,
updatedAt: now,
},
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -49,7 +55,10 @@ export async function createPermission(formData: FormData): Promise<void> {
targetType: "permission", targetType: "permission",
}); });
} catch (error) { } catch (error) {
logServerError("admin.permission_create_failed", error, { staffId: staff.id, permission }); logServerError("admin.permission_create_failed", error, {
staffId: staff.id,
permission,
});
redirect("/admin/permissions?error=save"); redirect("/admin/permissions?error=save");
// ignore (e.g. constraint failure) — page re-renders current state // ignore (e.g. constraint failure) — page re-renders current state
} }
+2 -1
View File
@@ -41,7 +41,8 @@ export async function createApiKey(formData: FormData): Promise<void> {
if (!name) return; if (!name) return;
const rateLimit = intOr(formData.get("rateLimit"), 300); const rateLimit = intOr(formData.get("rateLimit"), 300);
const allowedIps = str(formData.get("allowedIps")).trim().slice(0, 255) || null; const allowedIps =
str(formData.get("allowedIps")).trim().slice(0, 255) || null;
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)). // Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
const key = randomBytes(24).toString("hex"); const key = randomBytes(24).toString("hex");
+6 -2
View File
@@ -97,7 +97,9 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
const title = str(formData.get("title")).trim().slice(0, 255); const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim(); const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder"))); const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0; const sortOrder = Number.isFinite(sortOrderNum)
? Math.trunc(sortOrderNum)
: 0;
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
const now = new Date(); const now = new Date();
@@ -129,7 +131,9 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
const title = str(formData.get("title")).trim().slice(0, 255); const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim(); const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder"))); const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0; const sortOrder = Number.isFinite(sortOrderNum)
? Math.trunc(sortOrderNum)
: 0;
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
if (!imagePath) return; if (!imagePath) return;
+1 -1
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */ /** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null { function parseId(raw: FormDataEntryValue | null): bigint | null {
+16 -5
View File
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Radio listener-points settings (website_settings radio_points_* keys). // Radio listener-points settings (website_settings radio_points_* keys).
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in // Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
@@ -21,7 +21,12 @@ const POINTS_KEYS = [
"radio_points_min_listeners", "radio_points_min_listeners",
] as const; ] as const;
const ALLOWED_CURRENCIES = new Set(["credits", "duckets", "diamonds", "points"]); const ALLOWED_CURRENCIES = new Set([
"credits",
"duckets",
"diamonds",
"points",
]);
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
@@ -43,15 +48,21 @@ function intStr(raw: FormDataEntryValue | null): string {
export async function savePoints(formData: FormData): Promise<void> { export async function savePoints(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase(); const currencyRaw = str(formData.get("radio_points_currency"))
const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits"; .trim()
.toLowerCase();
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
? currencyRaw
: "credits";
const values: Record<(typeof POINTS_KEYS)[number], string> = { const values: Record<(typeof POINTS_KEYS)[number], string> = {
radio_points_enabled: boolStr(formData.get("radio_points_enabled")), radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")), radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
radio_points_currency: currency, radio_points_currency: currency,
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")), radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
radio_points_min_listeners: intStr(formData.get("radio_points_min_listeners")), radio_points_min_listeners: intStr(
formData.get("radio_points_min_listeners"),
),
}; };
try { try {
+7 -3
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> { export async function createCategory(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
@@ -16,7 +16,10 @@ export async function createCategory(formData: FormData): Promise<void> {
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const priorityRaw = Number(formData.get("priority")); const priorityRaw = Number(formData.get("priority"));
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1; const priority =
Number.isFinite(priorityRaw) && priorityRaw > 0
? Math.floor(priorityRaw)
: 1;
if (!name || !badge) return; if (!name || !badge) return;
try { try {
@@ -60,7 +63,8 @@ export async function createValue(formData: FormData): Promise<void> {
if (!name || !furnitureIcon) return; if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId")); const itemIdRaw = Number(formData.get("itemId"));
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null; const itemId =
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "") const creditValueRaw = String(formData.get("creditValue") ?? "")
.normalize("NFC") .normalize("NFC")
+6 -3
View File
@@ -3,10 +3,10 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Website store packages (website_shop_articles). This CMS-owned table backs // Website store packages (website_shop_articles). This CMS-owned table backs
// the public store; rows here are the buyable packages, not orders. The closest // the public store; rows here are the buyable packages, not orders. The closest
@@ -78,7 +78,10 @@ export async function createShopArticle(formData: FormData): Promise<void> {
targetId: Number(created.id), targetId: Number(created.id),
}); });
} catch (error) { } catch (error) {
logServerError("admin.shop_create_failed", error, { staffId: staff.id, name }); logServerError("admin.shop_create_failed", error, {
staffId: staff.id,
name,
});
// Unique constraint on `name` (or DB unavailable) — swallow and re-render. // Unique constraint on `name` (or DB unavailable) — swallow and re-render.
return; return;
} }
+4 -3
View File
@@ -6,14 +6,14 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
import { import {
deleteCustomThemeStore, deleteCustomThemeStore,
getCustomTheme, getCustomTheme,
snapshotCurrentTheme, snapshotCurrentTheme,
upsertCustomTheme, upsertCustomTheme,
} from "@/lib/theme-custom-store"; } from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). // Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
@@ -103,7 +103,8 @@ export async function applyPreset(formData: FormData): Promise<void> {
if (!preset) redirect("/admin/theme"); if (!preset) redirect("/admin/theme");
try { try {
for (const [key, value] of presetSettings(preset)) await writeSetting(key, value); for (const [key, value] of presetSettings(preset))
await writeSetting(key, value);
await writeSetting("theme_preset", name); await writeSetting("theme_preset", name);
siteSettings.reload(); siteSettings.reload();
await logStaffActivity({ await logStaffActivity({
+12 -2
View File
@@ -7,7 +7,12 @@ import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
const CURRENCIES: ReadonlySet<string> = new Set(["credits", "duckets", "diamonds", "points"]); const CURRENCIES: ReadonlySet<string> = new Set([
"credits",
"duckets",
"diamonds",
"points",
]);
export async function giveCurrency(formData: FormData): Promise<void> { export async function giveCurrency(formData: FormData): Promise<void> {
const staff = await requireStaffRateLimited(); const staff = await requireStaffRateLimited();
@@ -15,7 +20,12 @@ export async function giveCurrency(formData: FormData): Promise<void> {
const type = String(formData.get("type")); const type = String(formData.get("type"));
const amount = Number(formData.get("amount")); const amount = Number(formData.get("amount"));
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) { if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount); await sendCurrency(
{ rcon, db: prisma },
userId,
type as CurrencyName,
amount,
);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "give_currency", action: "give_currency",
+6 -3
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
export async function createVoucher(formData: FormData): Promise<void> { export async function createVoucher(formData: FormData): Promise<void> {
@@ -15,7 +15,8 @@ export async function createVoucher(formData: FormData): Promise<void> {
.slice(0, 255); .slice(0, 255);
const amount = Number(formData.get("amount")); const amount = Number(formData.get("amount"));
const maxUsesRaw = Number(formData.get("maxUses")); const maxUsesRaw = Number(formData.get("maxUses"));
const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1; const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) return; if (!code || !(amount > 0)) return;
@@ -60,7 +61,9 @@ export async function deleteVoucher(formData: FormData): Promise<void> {
try { try {
await prisma.websiteShopVouchers.delete({ where: { id } }); await prisma.websiteShopVouchers.delete({ where: { id } });
} catch (error) { } catch (error) {
logServerError("admin.voucher_delete_failed", error, { voucherId: String(id) }); logServerError("admin.voucher_delete_failed", error, {
voucherId: String(id),
});
return; return;
} }
+10 -2
View File
@@ -15,7 +15,11 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]); const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
/** Upsert one website_settings key with a stable housekeeping comment. */ /** Upsert one website_settings key with a stable housekeeping comment. */
async function writeSetting(key: string, value: string, comment: string): Promise<void> { async function writeSetting(
key: string,
value: string,
comment: string,
): Promise<void> {
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
@@ -58,7 +62,11 @@ export async function saveVpn(formData: FormData): Promise<void> {
provider, provider,
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)", "VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
); );
await writeSetting("vpn_api_key", apiKey, "API key for the VPN/proxy detection provider"); await writeSetting(
"vpn_api_key",
apiKey,
"API key for the VPN/proxy detection provider",
);
await writeSetting( await writeSetting(
"vpn_block_message", "vpn_block_message",
blockMessage, blockMessage,
+4 -2
View File
@@ -60,7 +60,8 @@ export async function createBox(formData: FormData): Promise<void> {
.slice(0, 255) || null, .slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"), content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"), position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
@@ -104,7 +105,8 @@ export async function updateBox(formData: FormData): Promise<void> {
.slice(0, 255) || null, .slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"), content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"), position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
+13 -5
View File
@@ -1,9 +1,9 @@
"use server"; "use server";
import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password"; import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
export type PrecheckResult = "ok" | "invalid" | "twofactor"; export type PrecheckResult = "ok" | "invalid" | "twofactor";
@@ -11,14 +11,18 @@ export type PrecheckResult = "ok" | "invalid" | "twofactor";
* Validates username+password WITHOUT creating a session, and reports whether a * Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow. * TOTP code is still required. Lets the login form do the two-step 2FA flow.
*/ */
export async function precheckLogin(username: string, password: string): Promise<PrecheckResult> { export async function precheckLogin(
username: string,
password: string,
): Promise<PrecheckResult> {
const u = String(username ?? "") const u = String(username ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const p = String(password ?? ""); const p = String(password ?? "");
if (!u || !p) return "invalid"; if (!u || !p) return "invalid";
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok) return "invalid"; if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null; let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
try { try {
@@ -31,9 +35,13 @@ export async function precheckLogin(username: string, password: string): Promise
} }
if (!user) { if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check. // Prevent timing-based enumeration: always run a dummy hash check.
await checkLogin(p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", { await checkLogin(
p,
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
{
convertPasswords: false, convertPasswords: false,
}); },
);
return "invalid"; return "invalid";
} }
+13 -2
View File
@@ -11,10 +11,21 @@ export async function getBadgeData({ code }: { code: string }) {
select: { badgeName: true, badgeDescription: true }, select: { badgeName: true, badgeDescription: true },
}); });
if (!badge) return { ok: false as const, data: null }; if (!badge) return { ok: false as const, data: null };
return { ok: true as const, data: { name: badge.badgeName, desc: badge.badgeDescription } }; 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 }) { export async function updateBadge({
code,
name,
desc,
}: {
code: string;
name: string;
desc: string;
}) {
await requireStaff(); await requireStaff();
await prisma.websiteBadges.upsert({ await prisma.websiteBadges.upsert({
where: { badgeKey: code }, where: { badgeKey: code },
+3 -1
View File
@@ -34,7 +34,9 @@ export const createBanner = adminAction(
}, },
); );
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() }); const updateBannerInput = bannerSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction( export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
+33 -10
View File
@@ -1,11 +1,10 @@
"use server"; "use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function bulkUnban({ export async function bulkUnban({
userIds, userIds,
@@ -13,14 +12,19 @@ export async function bulkUnban({
userIds: number[]; userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> { }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requireStaff(); const staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } }); const result = await prisma.ban.deleteMany({
where: { userId: { in: userIds } },
});
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_unban", action: "bulk_unban",
description: `Unbanned ${result.count} user(s)`, description: `Unbanned ${result.count} user(s)`,
targetType: "user", targetType: "user",
}); });
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } }; return {
ok: true as const,
data: { unbanned: result.count, total: userIds.length },
};
} }
export async function bulkBan({ export async function bulkBan({
@@ -74,7 +78,11 @@ export async function bulkGiveCurrency({
amount: number; amount: number;
type: "credits" | "pixels" | "points"; type: "credits" | "pixels" | "points";
}): Promise< }): Promise<
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }> ActionResult<{
given: number;
total: number;
failedIds: Array<{ userId: number; reason: string }>;
}>
> { > {
const staff = await requireStaff(); const staff = await requireStaff();
let given = 0; let given = 0;
@@ -83,7 +91,10 @@ export async function bulkGiveCurrency({
for (const userId of userIds) { for (const userId of userIds) {
try { try {
if (type === "credits") { if (type === "credits") {
await prisma.user.update({ where: { id: userId }, data: { credits: { increment: amount } } }); await prisma.user.update({
where: { id: userId },
data: { credits: { increment: amount } },
});
await rcon.giveCredits(userId, amount); await rcon.giveCredits(userId, amount);
} else if (type === "pixels") { } else if (type === "pixels") {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
@@ -112,7 +123,10 @@ export async function bulkGiveCurrency({
description: `Gave ${amount} ${type} to ${given} user(s)`, description: `Gave ${amount} ${type} to ${given} user(s)`,
targetType: "user", targetType: "user",
}); });
return { ok: true as const, data: { given, total: userIds.length, failedIds } }; return {
ok: true as const,
data: { given, total: userIds.length, failedIds },
};
} }
export async function bulkGiveBadge({ export async function bulkGiveBadge({
@@ -122,7 +136,11 @@ export async function bulkGiveBadge({
userIds: number[]; userIds: number[];
badgeCode: string; badgeCode: string;
}): Promise< }): Promise<
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }> ActionResult<{
given: number;
total: number;
failedIds: Array<{ userId: number; reason: string }>;
}>
> { > {
const staff = await requireStaff(); const staff = await requireStaff();
let given = 0; let given = 0;
@@ -140,7 +158,9 @@ export async function bulkGiveBadge({
_max: { slotId: true }, _max: { slotId: true },
}); });
const slotId = (max._max.slotId ?? 0) + 1; const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({ data: { userId, slotId, badgeCode } }); await prisma.usersBadges.create({
data: { userId, slotId, badgeCode },
});
await rcon.giveBadge(userId, badgeCode); await rcon.giveBadge(userId, badgeCode);
} }
given++; given++;
@@ -155,5 +175,8 @@ export async function bulkGiveBadge({
description: `Gave badge "${badgeCode}" to ${given} user(s)`, description: `Gave badge "${badgeCode}" to ${given} user(s)`,
targetType: "user", targetType: "user",
}); });
return { ok: true as const, data: { given, total: userIds.length, failedIds } }; return {
ok: true as const,
data: { given, total: userIds.length, failedIds },
};
} }
+16 -3
View File
@@ -6,7 +6,10 @@ import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateBcPage({ id, ...fields }: { id: number } & Record<string, unknown>) { export async function updateBcPage({
id,
...fields
}: { id: number } & Record<string, unknown>) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any }); await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
await rcon.updateCatalog(); await rcon.updateCatalog();
@@ -79,7 +82,13 @@ export async function createBcItem({
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
} }
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) { export async function toggleBcPage({
id,
field,
}: {
id: number;
field: "enabled" | "visible";
}) {
await requireStaff(); await requireStaff();
const page = await prisma.catalogPagesBc.findUnique({ const page = await prisma.catalogPagesBc.findUnique({
where: { id }, where: { id },
@@ -93,7 +102,11 @@ export async function toggleBcPage({ id, field }: { id: number; field: "enabled"
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
} }
export async function createBcPage(input: { caption: string; parentId: number; pageLayout: string }) { export async function createBcPage(input: {
caption: string;
parentId: number;
pageLayout: string;
}) {
const staff = await requireStaff(); const staff = await requireStaff();
const created = await prisma.catalogPagesBc.create({ const created = await prisma.catalogPagesBc.create({
data: { data: {
+16 -4
View File
@@ -51,7 +51,13 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) { export async function moveCatalogItems({
ids,
targetPageId,
}: {
ids: number[];
targetPageId: number;
}) {
await requireStaff(); await requireStaff();
await prisma.catalogItems.updateMany({ await prisma.catalogItems.updateMany({
where: { id: { in: ids } }, where: { id: { in: ids } },
@@ -86,9 +92,15 @@ export async function updateCatalogItem({
baseItem?: { id: number; fields: Record<string, unknown> }; baseItem?: { id: number; fields: Record<string, unknown> };
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any }); await prisma.catalogItems.update({
where: { id },
data: catalogFields as any,
});
if (baseItem) { if (baseItem) {
await prisma.itemsBase.update({ where: { id: baseItem.id }, data: baseItem.fields as any }); await prisma.itemsBase.update({
where: { id: baseItem.id },
data: baseItem.fields as any,
});
} }
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
@@ -111,7 +123,7 @@ export async function translateCatalogItems({
let namesUpdated = 0; let namesUpdated = 0;
let descriptionsUpdated = 0; let descriptionsUpdated = 0;
let furniDataUpdated = 0; let furniDataUpdated = 0;
let furniDataInserted = 0; const _furniDataInserted = 0;
for (const item of items) { for (const item of items) {
const existing = await prisma.catalogItems.findUnique({ const existing = await prisma.catalogItems.findUnique({
+13 -4
View File
@@ -3,9 +3,9 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function updateCatalogPage({ export async function updateCatalogPage({
id, id,
@@ -105,13 +105,19 @@ export async function createCatalogPage(input: {
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: created.id } };
} }
export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) { export async function reorderTreePage(input: {
pageId: number;
newParentId?: number;
newOrderNum: number;
}) {
await requireStaff(); await requireStaff();
await prisma.catalogPages.update({ await prisma.catalogPages.update({
where: { id: input.pageId }, where: { id: input.pageId },
data: { data: {
orderNum: input.newOrderNum, orderNum: input.newOrderNum,
...(input.newParentId === undefined ? {} : { parentId: input.newParentId }), ...(input.newParentId === undefined
? {}
: { parentId: input.newParentId }),
}, },
}); });
await rcon.updateCatalog(); await rcon.updateCatalog();
@@ -119,7 +125,10 @@ export async function reorderTreePage(input: { pageId: number; newParentId?: num
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade" }) { export async function deleteTreePage(input: {
pageId: number;
mode: "reparent" | "cascade";
}) {
await requireStaff(); await requireStaff();
if (input.mode === "cascade") { if (input.mode === "cascade") {
const children = await prisma.catalogPages.findMany({ const children = await prisma.catalogPages.findMany({
+2 -2
View File
@@ -2,11 +2,11 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import type { Prisma } from "@/generated/prisma/client";
/** /**
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to * Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
@@ -64,7 +64,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
select: { id: true, badgePath: true, published: true }, select: { id: true, badgePath: true, published: true },
}); });
if (!badge || !badge.published) { if (!badge?.published) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const code = badgeCodeFromPath(badge.badgePath); const code = badgeCodeFromPath(badge.badgePath);
+18 -5
View File
@@ -17,21 +17,29 @@ import { siteSettings } from "@/lib/services/site-settings";
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */ /** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string { function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET; const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret) throw new Error("APP_KEY or AUTH_SECRET must be set for email verification"); if (!secret)
throw new Error(
"APP_KEY or AUTH_SECRET must be set for email verification",
);
return secret; return secret;
} }
/** Compute the verification token for an email (lowercased + trimmed). */ /** Compute the verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> { export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase(); const normalised = email.trim().toLowerCase();
return createHash("sha256").update(`${normalised}|${verifySecret()}`).digest("hex"); return createHash("sha256")
.update(`${normalised}|${verifySecret()}`)
.digest("hex");
} }
/** /**
* Constant-time check that `token` matches the expected digest for `email`. * Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing. * Returns false on any length/format mismatch rather than throwing.
*/ */
export async function isValidVerificationToken(email: string, token: string): Promise<boolean> { export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false; if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
const expected = await verificationToken(email); const expected = await verificationToken(email);
const a = Buffer.from(expected, "utf8"); const a = Buffer.from(expected, "utf8");
@@ -52,7 +60,8 @@ export async function sendVerification(email: string): Promise<boolean> {
const base = env.APP_URL.replace(/\/+$/, ""); const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`; const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME; const hotelName =
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
const html = ` const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a"> <div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
@@ -73,5 +82,9 @@ export async function sendVerification(email: string): Promise<boolean> {
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
+14 -4
View File
@@ -41,7 +41,9 @@ export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({ where: { id } }); const existing = await prisma.websiteEventType.findUnique({
where: { id },
});
if (!existing) throw new ActionError("Event type not found"); if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.update({ where: { id }, data }); await prisma.websiteEventType.update({ where: { id }, data });
@@ -64,7 +66,9 @@ const deleteEventTypeInput = z.object({
export const deleteEventType = adminAction( export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } }); const existing = await prisma.websiteEventType.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event type not found"); if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } }); await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
@@ -132,7 +136,9 @@ const deleteEventInput = z.object({
export const deleteEvent = adminAction( export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } }); const existing = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event not found"); if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } }); await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
@@ -178,7 +184,11 @@ export const addEventWinner = adminAction(
action: "event_winner_add", action: "event_winner_add",
target: "WebsiteEventWinner", target: "WebsiteEventWinner",
targetId: winner.id, targetId: winner.id,
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position }, after: {
eventId: ctx.data.eventId,
userId: ctx.data.userId,
position: ctx.data.position,
},
}); });
return actionOk({ id: winner.id }); return actionOk({ id: winner.id });
}, },
+1 -1
View File
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { moderateOrThrow } from "@/lib/services/moderation";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
const ticketSchema = z.object({ const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255), title: z.string().min(1, "Title is required").max(255),
+5 -1
View File
@@ -16,7 +16,11 @@ export async function importBadgeFromRemote({
try { try {
await prisma.websiteBadges.upsert({ await prisma.websiteBadges.upsert({
where: { badgeKey: code }, where: { badgeKey: code },
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() }, update: {
badgeName: name,
badgeDescription: description,
updatedAt: new Date(),
},
create: { create: {
badgeKey: code, badgeKey: code,
badgeName: name, badgeName: name,
+15 -4
View File
@@ -4,17 +4,28 @@ import { z } from "zod";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; import { actionOk } from "@/lib/safe-action-shared";
import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import"; import {
cleanupConvertedSwfs,
deleteImportedItem,
} from "@/lib/services/furni-import";
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => { export const cleanSwfFiles = adminAction(
{ permission: PERMS.ASSETS_IMPORT },
async () => {
const result = await cleanupConvertedSwfs(); const result = await cleanupConvertedSwfs();
return actionOk(result as unknown as Record<string, unknown>); return actionOk(result as unknown as Record<string, unknown>);
}); },
);
const deleteSchema = z.object({ classname: z.string().trim().min(1) }); const deleteSchema = z.object({ classname: z.string().trim().min(1) });
export const deleteImportedFurni = adminAction( export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema }, { permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) => async (ctx) =>
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>), actionOk(
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
),
); );
+17 -4
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
export async function linkDiscordId(discordId: string): Promise<string | null> { export async function linkDiscordId(discordId: string): Promise<string | null> {
const session = await auth(); const session = await auth();
@@ -17,7 +17,12 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
try { try {
const existing = await prisma.socialAccounts.findUnique({ const existing = await prisma.socialAccounts.findUnique({
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } }, where: {
provider_providerId: {
provider: "discord",
providerId: discordIdClean,
},
},
select: { userId: true }, select: { userId: true },
}); });
if (existing && Number(existing.userId) !== userId) { if (existing && Number(existing.userId) !== userId) {
@@ -29,7 +34,12 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
try { try {
await prisma.socialAccounts.upsert({ await prisma.socialAccounts.upsert({
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } }, where: {
provider_providerId: {
provider: "discord",
providerId: discordIdClean,
},
},
create: { create: {
userId: BigInt(userId), userId: BigInt(userId),
provider: "discord", provider: "discord",
@@ -47,7 +57,10 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
return null; return null;
} catch (e) { } catch (e) {
logger.error("Failed to link Discord account", { module: "link-discord", error: (e as Error).message }); logger.error("Failed to link Discord account", {
module: "link-discord",
error: (e as Error).message,
});
return "Failed to link Discord account"; return "Failed to link Discord account";
} }
} }
+8 -4
View File
@@ -1,10 +1,10 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction, actionOk } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -15,7 +15,9 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
export const assignCfhTicket = adminAction( export const assignCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }); const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
});
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({ await prisma.supportTickets.update({
@@ -42,7 +44,9 @@ const cfhStateSchema = z.object({
export const updateCfhState = adminAction( export const updateCfhState = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }); const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
});
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({ await prisma.supportTickets.update({
+19 -3
View File
@@ -7,10 +7,21 @@ export interface MultiAccountCluster {
key: string; key: string;
label: string; label: string;
accountCount: number; accountCount: number;
accounts: Array<{ id: number; username: string; rank: number; online: string }>; accounts: Array<{
id: number;
username: string;
rank: number;
online: string;
}>;
} }
export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts: number; limit: number }) { export async function detectMultiAccounts({
minAccounts,
limit,
}: {
minAccounts: number;
limit: number;
}) {
await requireStaff(); await requireStaff();
const clusters: MultiAccountCluster[] = []; const clusters: MultiAccountCluster[] = [];
@@ -33,7 +44,12 @@ export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts:
key: group.ipCurrent, key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`, label: `IP: ${group.ipCurrent}`,
accountCount: group._count.id, accountCount: group._count.id,
accounts: users.map((u) => ({ id: u.id, username: u.username, rank: u.rank, online: u.online })), accounts: users.map((u) => ({
id: u.id,
username: u.username,
rank: u.rank,
online: u.online,
})),
}); });
} }
+15 -4
View File
@@ -1,7 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } = const {
vi.hoisted(() => ({ mockFindFirst,
mockUpsert,
mockFindUnique,
mockUpdate,
mockDelete,
mockSendMail,
mockRedirect,
} = vi.hoisted(() => ({
mockFindFirst: vi.fn(), mockFindFirst: vi.fn(),
mockUpsert: vi.fn(), mockUpsert: vi.fn(),
mockFindUnique: vi.fn(), mockFindUnique: vi.fn(),
@@ -9,7 +16,7 @@ const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockS
mockDelete: vi.fn(), mockDelete: vi.fn(),
mockSendMail: vi.fn(), mockSendMail: vi.fn(),
mockRedirect: vi.fn(), mockRedirect: vi.fn(),
})); }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: (...args: unknown[]) => { redirect: (...args: unknown[]) => {
@@ -21,7 +28,11 @@ vi.mock("next/navigation", () => ({
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/prisma", () => ({
prisma: { prisma: {
user: { findFirst: mockFindFirst, update: mockUpdate }, user: { findFirst: mockFindFirst, update: mockUpdate },
passwordReset: { upsert: mockUpsert, findUnique: mockFindUnique, delete: mockDelete }, passwordReset: {
upsert: mockUpsert,
findUnique: mockFindUnique,
delete: mockDelete,
},
}, },
})); }));
+22 -10
View File
@@ -2,11 +2,11 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { hashPassword } from "@/lib/auth/password";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/services/email";
import { env } from "@/env"; import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { sendMail } from "@/lib/services/email";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -21,12 +21,16 @@ export async function requestReset(formData: FormData): Promise<void> {
.toLowerCase(); .toLowerCase();
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse. // Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok; const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000))
.ok;
// Always respond the same way so we don't reveal which emails exist. // Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try { try {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } }); const user = await prisma.user.findFirst({
where: { mail: email },
select: { id: true },
});
if (user) { if (user) {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
await prisma.passwordReset.upsert({ await prisma.passwordReset.upsert({
@@ -72,15 +76,21 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!error) { if (!error) {
try { try {
const row = await prisma.passwordReset.findUnique({ where: { email } }); const row = await prisma.passwordReset.findUnique({ where: { email } });
const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false; const fresh = row?.createdAt
? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS
: false;
const a = Buffer.from(sha256(token), "hex"); const a = Buffer.from(sha256(token), "hex");
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length); const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
const match = row != null && a.length === b.length && timingSafeEqual(a, b); const match =
row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) { if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired"; error = "This reset link is invalid or has expired";
} else { } else {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } }); const user = await prisma.user.findFirst({
where: { mail: email },
select: { id: true },
});
if (!user) { if (!user) {
error = "Account not found"; error = "Account not found";
} else { } else {
@@ -88,7 +98,9 @@ export async function resetPassword(formData: FormData): Promise<void> {
where: { id: user.id }, where: { id: user.id },
data: { password: await hashPassword(password) }, data: { password: await hashPassword(password) },
}); });
await prisma.passwordReset.delete({ where: { email } }).catch(() => {}); await prisma.passwordReset
.delete({ where: { email } })
.catch(() => {});
} }
} }
} catch { } catch {
+16 -5
View File
@@ -6,7 +6,11 @@ import { PERMS } from "@/lib/permission-slugs";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks"; import {
createEmulatorRank,
deleteEmulatorRank,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -47,12 +51,17 @@ export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const users = await prisma.user.count({ where: { rank: ctx.data.id } }); const users = await prisma.user.count({ where: { rank: ctx.data.id } });
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`); if (users > 0)
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } }); throw new ActionError(`Cannot delete: ${users} users have this rank`);
const role = await prisma.aclRole.findFirst({
where: { slug: `rank_${ctx.data.id}` },
});
await deleteEmulatorRank(prisma, ctx.data.id); await deleteEmulatorRank(prisma, ctx.data.id);
if (role) { if (role) {
await prisma.$transaction([ await prisma.$transaction([
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }), prisma.aclModelPermission.deleteMany({
where: { modelId: role.id, modelType: "Role" },
}),
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }), prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
prisma.aclRole.delete({ where: { id: role.id } }), prisma.aclRole.delete({ where: { id: role.id } }),
]); ]);
@@ -116,7 +125,9 @@ export const setCmsPermissions = adminAction(
select: { id: true }, select: { id: true },
}); });
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }); await tx.aclModelPermission.deleteMany({
where: { modelId: role.id, modelType: "Role" },
});
if (permissions.length) { if (permissions.length) {
await tx.aclModelPermission.createMany({ await tx.aclModelPermission.createMany({
data: permissions.map((permission) => ({ data: permissions.map((permission) => ({
+11 -3
View File
@@ -6,7 +6,11 @@ import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll"; import {
createPollSchema,
pollQuestionSchema,
updatePollSchema,
} from "@/lib/validators/poll";
// ── Polls ─────────────────────────────────────────────────────────── // ── Polls ───────────────────────────────────────────────────────────
@@ -56,7 +60,9 @@ const deletePollInput = z.object({
export const deletePoll = adminAction( export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput }, { permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } }); const existing = await prisma.websitePoll.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Poll not found"); if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.delete({ where: { id: ctx.data.id } }); await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
@@ -76,7 +82,9 @@ export const deletePoll = adminAction(
export const addPollQuestion = adminAction( export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => { async (ctx) => {
const question = await prisma.websitePollQuestion.create({ data: ctx.data }); const question = await prisma.websitePollQuestion.create({
data: ctx.data,
});
return actionOk({ id: question.id }); return actionOk({ id: question.id });
}, },
); );
+1 -1
View File
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { moderateOrThrow } from "@/lib/services/moderation";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
const shoutSchema = z.object({ const shoutSchema = z.object({
message: z.string().min(1, "Message is required").max(255), message: z.string().min(1, "Message is required").max(255),
+28 -6
View File
@@ -27,10 +27,20 @@ import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its * redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
* internal control-flow throw is never swallowed. * internal control-flow throw is never swallowed.
*/ */
const VALID_CURRENCIES = new Set<CurrencyName>(["credits", "duckets", "diamonds", "points"]); const VALID_CURRENCIES = new Set<CurrencyName>([
"credits",
"duckets",
"diamonds",
"points",
]);
export async function claimReferral(_formData: FormData): Promise<void> { export async function claimReferral(_formData: FormData): Promise<void> {
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error"; let outcome:
| "claimed"
| "not_enough"
| "no_referrals"
| "bad_config"
| "error" = "error";
try { try {
const session = await auth(); const session = await auth();
@@ -47,16 +57,26 @@ export async function claimReferral(_formData: FormData): Promise<void> {
// 5 referrals needed, 30 diamonds reward. // 5 referrals needed, 30 diamonds reward.
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([ const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
prisma.websiteSetting prisma.websiteSetting
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } }) .findUnique({
where: { key: "referrals_needed" },
select: { value: true },
})
.catch(() => null), .catch(() => null),
prisma.websiteSetting prisma.websiteSetting
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } }) .findUnique({
where: { key: "referral_reward_amount" },
select: { value: true },
})
.catch(() => null), .catch(() => null),
// The seeded key is referral_reward_currency_type; fall back to the // The seeded key is referral_reward_currency_type; fall back to the
// shorter referral_reward_currency name if that is what is configured. // shorter referral_reward_currency name if that is what is configured.
prisma.websiteSetting prisma.websiteSetting
.findFirst({ .findFirst({
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } }, where: {
key: {
in: ["referral_reward_currency_type", "referral_reward_currency"],
},
},
select: { value: true }, select: { value: true },
}) })
.catch(() => null), .catch(() => null),
@@ -64,7 +84,9 @@ export async function claimReferral(_formData: FormData): Promise<void> {
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
const amount = Number.parseInt(amountRaw?.value ?? "30", 10); const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
const currency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase() as CurrencyName; const currency = (currencyRaw?.value ?? "diamonds")
.trim()
.toLowerCase() as CurrencyName;
// The user's referral tally lives in user_referrals (one row per user). // The user's referral tally lives in user_referrals (one row per user).
const referrals = await prisma.userReferrals const referrals = await prisma.userReferrals
+16 -5
View File
@@ -16,7 +16,11 @@ const registerSchema = z.object({
.min(3, "Username must be at least 3 characters") .min(3, "Username must be at least 3 characters")
.max(25, "Username must be at most 25 characters") .max(25, "Username must be at most 25 characters")
.regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"), .regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"),
mail: z.string().email("Enter a valid email address").optional().or(z.literal("")), mail: z
.string()
.email("Enter a valid email address")
.optional()
.or(z.literal("")),
password: z password: z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
@@ -29,7 +33,10 @@ const registerSchema = z.object({
// A valid starter Habbo figure so the avatar renders in-client immediately. // A valid starter Habbo figure so the avatar renders in-client immediately.
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export async function register(prevState: string | null, formData: FormData): Promise<string | null> { export async function register(
_prevState: string | null,
formData: FormData,
): Promise<string | null> {
const raw = { const raw = {
username: String(formData.get("username") ?? "") username: String(formData.get("username") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -63,7 +70,8 @@ export async function register(prevState: string | null, formData: FormData): Pr
const cfg = await captchaConfig(); const cfg = await captchaConfig();
if (cfg.provider !== "none") { if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) return "Captcha verification failed. Please try again."; if (!(await verifyCaptcha(token, ip)))
return "Captcha verification failed. Please try again.";
} }
// VPN/proxy block (only when enabled in /admin/vpn). // VPN/proxy block (only when enabled in /admin/vpn).
@@ -77,8 +85,11 @@ export async function register(prevState: string | null, formData: FormData): Pr
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS. // Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0; const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
if (max > 0) { if (max > 0) {
const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0); const count = await prisma.user
if (count >= max) return "You have reached the maximum number of accounts for your connection."; .count({ where: { ipRegister: ip } })
.catch(() => 0);
if (count >= max)
return "You have reached the maximum number of accounts for your connection.";
} }
// Uniqueness check. // Uniqueness check.
+26 -4
View File
@@ -8,7 +8,11 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateRoomItem(payload: Record<string, unknown>) { export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requireStaff(); const staff = await requireStaff();
const { roomId, itemId, ...data } = payload as { roomId: number; itemId: number; [key: string]: unknown }; const { roomId, itemId, ...data } = payload as {
roomId: number;
itemId: number;
[key: string]: unknown;
};
await prisma.items.update({ where: { id: itemId }, data: data as any }); await prisma.items.update({ where: { id: itemId }, data: data as any });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -20,7 +24,13 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
revalidatePath(`/admin/rooms/${roomId}/furni`); revalidatePath(`/admin/rooms/${roomId}/furni`);
} }
export async function bulkDeleteRoomItems({ roomId, itemIds }: { roomId: number; itemIds: number[] }) { export async function bulkDeleteRoomItems({
roomId,
itemIds,
}: {
roomId: number;
itemIds: number[];
}) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } }); await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } });
await logStaffActivity({ await logStaffActivity({
@@ -32,7 +42,13 @@ export async function bulkDeleteRoomItems({ roomId, itemIds }: { roomId: number;
revalidatePath(`/admin/rooms/${roomId}/furni`); revalidatePath(`/admin/rooms/${roomId}/furni`);
} }
export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemId: number }) { export async function deleteRoomItem({
roomId,
itemId,
}: {
roomId: number;
itemId: number;
}) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.items.delete({ where: { id: itemId } }); await prisma.items.delete({ where: { id: itemId } });
await logStaffActivity({ await logStaffActivity({
@@ -45,7 +61,13 @@ export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemI
revalidatePath(`/admin/rooms/${roomId}/furni`); revalidatePath(`/admin/rooms/${roomId}/furni`);
} }
export async function roomRconAction({ roomId, action }: { roomId: number; action: string }) { export async function roomRconAction({
roomId,
action,
}: {
roomId: number;
action: string;
}) {
await requireStaff(); await requireStaff();
if (action === "reload") { if (action === "reload") {
await rcon.send("reloadroom", { room_id: roomId }); await rcon.send("reloadroom", { room_id: roomId });
+35 -12
View File
@@ -1,24 +1,36 @@
"use server"; "use server";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { mkdir, writeFile, unlink } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const FAVICON_DIR = "public/assets/images/media/favicon"; const FAVICON_DIR = "public/assets/images/media/favicon";
const MAX_SIZE = 2 * 1024 * 1024; // 2MB const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"]; const ALLOWED = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/x-icon",
"image/svg+xml",
];
export async function saveFavicon( export async function saveFavicon(
formData: FormData, formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> { ): Promise<{ success: boolean; url?: string; error?: string }> {
try { try {
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { success: false, error: "No file provided" }; if (!file || file.size === 0)
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" }; return { success: false, error: "No file provided" };
if (file.size > MAX_SIZE)
return { success: false, error: "File too large (max 2MB)" };
if (!ALLOWED.includes(file.type)) if (!ALLOWED.includes(file.type))
return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" }; return {
success: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
};
const mimeExt: Record<string, string> = { const mimeExt: Record<string, string> = {
"image/png": "png", "image/png": "png",
@@ -46,7 +58,7 @@ export async function saveFavicon(
// Remove old favicon file if it exists // Remove old favicon file if it exists
const oldUrl = await siteSettings.get("cms_favicon"); const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) { if (oldUrl?.startsWith("/api/media/favicon/")) {
const oldName = oldUrl.replace("/api/media/favicon/", ""); const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) { if (!oldName.includes("..") && !oldName.includes("/")) {
const oldPath = path.resolve(baseDir, oldName); const oldPath = path.resolve(baseDir, oldName);
@@ -73,14 +85,20 @@ export async function saveFavicon(
return { success: true, url }; return { success: true, url };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} }
} }
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> { export async function deleteFavicon(): Promise<{
success: boolean;
error?: string;
}> {
try { try {
const oldUrl = await siteSettings.get("cms_favicon"); const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) { if (oldUrl?.startsWith("/api/media/favicon/")) {
const baseDir = path.resolve(process.cwd(), FAVICON_DIR); const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
const oldName = oldUrl.replace("/api/media/favicon/", ""); const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) { if (!oldName.includes("..") && !oldName.includes("/")) {
@@ -96,13 +114,18 @@ export async function deleteFavicon(): Promise<{ success: boolean; error?: strin
} }
} }
await prisma.websiteSetting.delete({ where: { key: "cms_favicon" } }).catch(() => {}); await prisma.websiteSetting
.delete({ where: { key: "cms_favicon" } })
.catch(() => {});
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
revalidatePath("/admin/favicon"); revalidatePath("/admin/favicon");
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} }
} }
+12 -5
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { mkdir, writeFile } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -51,7 +51,10 @@ export async function saveLogo(
return { success: true, url }; return { success: true, url };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} }
} }
@@ -60,7 +63,8 @@ export async function saveLogoFromUrl(
): Promise<{ success: boolean; url?: string; error?: string }> { ): Promise<{ success: boolean; url?: string; error?: string }> {
try { try {
const res = await fetch(gifUrl); const res = await fetch(gifUrl);
if (!res.ok) return { success: false, error: `Failed to fetch GIF: ${res.status}` }; if (!res.ok)
return { success: false, error: `Failed to fetch GIF: ${res.status}` };
const contentType = res.headers.get("content-type") ?? "image/gif"; const contentType = res.headers.get("content-type") ?? "image/gif";
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
@@ -100,6 +104,9 @@ export async function saveLogoFromUrl(
return { success: true, url }; return { success: true, url };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} }
} }
+9 -3
View File
@@ -21,20 +21,26 @@ export const createTemplate = adminAction(
}, },
); );
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() }); const updateTemplateInput = templateSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateTemplate = adminAction( export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } }); const existing = await prisma.websiteTicketTemplate.findUnique({
where: { id },
});
if (!existing) throw new ActionError("Template not found"); if (!existing) throw new ActionError("Template not found");
await prisma.websiteTicketTemplate.update({ where: { id }, data }); await prisma.websiteTicketTemplate.update({ where: { id }, data });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() }); const deleteTemplateInput = z.object({
id: z.coerce.number().int().positive(),
});
export const deleteTemplate = adminAction( export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
+16 -7
View File
@@ -16,7 +16,9 @@ import {
// ── User actions (authenticated, no admin perms needed) ────────────── // ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction({ schema: createTicketSchema }, async (ctx) => { export const createTicket = authAction(
{ schema: createTicketSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.create({ const ticket = await prisma.websiteTicket.create({
data: { data: {
subject: ctx.data.subject, subject: ctx.data.subject,
@@ -43,15 +45,19 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
}); });
return actionOk({ id: ticket.id }); return actionOk({ id: ticket.id });
}); },
);
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => { export const userReplyTicket = authAction(
{ schema: replyTicketSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized"); if (ticket.creatorId !== ctx.session.user.id)
throw new ActionError("Unauthorized");
if (ticket.status === "closed") throw new ActionError("Ticket is closed"); if (ticket.status === "closed") throw new ActionError("Ticket is closed");
await prisma.websiteTicketMessage.create({ await prisma.websiteTicketMessage.create({
@@ -72,7 +78,8 @@ export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (
} }
return actionOk(); return actionOk();
}); },
);
export const closeTicketByUser = authAction( export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) }, { schema: replyTicketSchema.pick({ ticketId: true }) },
@@ -82,8 +89,10 @@ export const closeTicketByUser = authAction(
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized"); if (ticket.creatorId !== ctx.session.user.id)
if (ticket.status === "closed") throw new ActionError("Ticket is already closed"); throw new ActionError("Unauthorized");
if (ticket.status === "closed")
throw new ActionError("Ticket is already closed");
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
+41 -11
View File
@@ -4,7 +4,10 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { z } from "zod"; import { z } from "zod";
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files"; import {
CLIENT_TRANSLATION_FILES,
getClientTranslationFile,
} from "@/lib/client-translation-files";
import { patchJson5 } from "@/lib/json5-patch"; import { patchJson5 } from "@/lib/json5-patch";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -37,21 +40,36 @@ const saveTranslationsSchema = z.object({
data: z.record(z.string(), z.unknown()), data: z.record(z.string(), z.unknown()),
}); });
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => { export const saveTranslations = adminAction(
{ schema: saveTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden"); if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`); const filePath = path.join(
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8"); process.cwd(),
"messages",
`${ctx.data.locale}.json`,
);
await fs.writeFile(
filePath,
JSON.stringify(ctx.data.data, null, 2),
"utf-8",
);
return actionOk(); return actionOk();
}); },
);
const saveClientTranslationsSchema = z.object({ const saveClientTranslationsSchema = z.object({
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]), fileId: z.enum(
CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]],
),
data: z.record(z.string(), z.string()), data: z.record(z.string(), z.string()),
}); });
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => { export const saveClientTranslations = adminAction(
{ schema: saveClientTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden"); if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const file = getClientTranslationFile(ctx.data.fileId); const file = getClientTranslationFile(ctx.data.fileId);
@@ -61,12 +79,19 @@ export const saveClientTranslations = adminAction({ schema: saveClientTranslatio
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but // file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it // Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list. // pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath); const absPath = path.join(
/*turbopackIgnore: true*/ process.cwd(),
file.relPath,
);
const raw = await fs.readFile(absPath, "utf-8"); const raw = await fs.readFile(absPath, "utf-8");
if (file.format === "json") { if (file.format === "json") {
// Plain JSON — no comments to preserve, just round-trip. // Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8"); await fs.writeFile(
absPath,
JSON.stringify(ctx.data.data, null, 4),
"utf-8",
);
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] }); return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
} }
@@ -91,6 +116,11 @@ export const saveClientTranslations = adminAction({ schema: saveClientTranslatio
// At least one key could not be patched surgically (e.g. unusual // At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization // formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost. // and warn the caller that comments were lost.
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8"); await fs.writeFile(
absPath,
JSON5.stringify(ctx.data.data, null, 4),
"utf-8",
);
return actionOk({ commentsLost: true, unpatchedKeys }); return actionOk({ commentsLost: true, unpatchedKeys });
}); },
);
+14 -7
View File
@@ -1,14 +1,14 @@
"use server"; "use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { randomBytes } from "node:crypto"; import { env } from "@/env";
import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp"; import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
async function sessionUserId(): Promise<number> { async function sessionUserId(): Promise<number> {
const session = await auth(); const session = await auth();
@@ -42,7 +42,9 @@ async function verifyTwoFactorCode(
// Try TOTP first // Try TOTP first
try { try {
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret); const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(
user.twoFactorSecret,
);
if (verifyTotp(code, secret)) return { ok: true }; if (verifyTotp(code, secret)) return { ok: true };
} catch { } catch {
/* fall through to recovery */ /* fall through to recovery */
@@ -90,7 +92,8 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId(); const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit"); if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok)
redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -99,7 +102,10 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
const { ok } = await verifyTwoFactorCode(id, code); const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode"); if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } }); await prisma.user.update({
where: { id },
data: { twoFactorConfirmedAt: new Date() },
});
redirect("/settings/2fa?enabled=1"); redirect("/settings/2fa?enabled=1");
} }
@@ -107,7 +113,8 @@ export async function disableTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId(); const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit"); if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok)
redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
+10 -5
View File
@@ -1,21 +1,26 @@
"use server"; "use server";
import { z } from "zod";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod";
import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { authAction, actionOk } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
const MOTTO_MAX = 127; const MOTTO_MAX = 127;
const mottoSchema = z.object({ const mottoSchema = z.object({
motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`), motto: z
.string()
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
}); });
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => { const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
try { try {
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } }); await prisma.user.update({
where: { id: ctx.session.user.id },
data: { motto: ctx.data.motto },
});
} catch { } catch {
throw new DatabaseError("Failed to update motto"); throw new DatabaseError("Failed to update motto");
} }
+66 -23
View File
@@ -11,9 +11,15 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { notify } from "@/lib/services/webhook"; import { notify } from "@/lib/services/webhook";
import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user"; import {
banUserSchema,
createUserSchema,
giveBadgeSchema,
updateUserSchema,
} from "@/lib/validators/user";
const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64"; const DEFAULT_LOOK =
"hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
export const createUser = adminAction( export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema }, { permission: PERMS.USERS_EDIT, schema: createUserSchema },
@@ -74,10 +80,15 @@ export const createUser = adminAction(
return actionOk({ id: user.id, username: user.username }); return actionOk({ id: user.id, username: user.username });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
const target = (err.meta?.target as string[]) ?? []; const target = (err.meta?.target as string[]) ?? [];
if (target.includes("username")) throw new ActionError("Username already taken"); if (target.includes("username"))
if (target.includes("mail")) throw new ActionError("Email already registered"); throw new ActionError("Username already taken");
if (target.includes("mail"))
throw new ActionError("Email already registered");
throw new ActionError("Username or email already in use"); throw new ActionError("Username or email already in use");
} }
throw err; throw err;
@@ -96,7 +107,11 @@ export const updateUser = adminAction(
const targetUser = await guardRank(id, ctx.session.user.rank); const targetUser = await guardRank(id, ctx.session.user.rank);
if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) { if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError("Cannot assign rank equal or higher than your own"); throw new ActionError("Cannot assign rank equal or higher than your own");
} }
@@ -122,7 +137,11 @@ export const updateUser = adminAction(
action: "user_edit", action: "user_edit",
target: "User", target: "User",
targetId: id, targetId: id,
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank }, before: {
username: targetUser.username,
mail: targetUser.mail,
rank: targetUser.rank,
},
after: userData, after: userData,
}); });
@@ -139,7 +158,9 @@ export const updateUser = adminAction(
const banInput = banUserSchema.extend({}); const banInput = banUserSchema.extend({});
export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => { export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banInput },
async (ctx) => {
const { userId, reason, duration, type, ip } = ctx.data; const { userId, reason, duration, type, ip } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank); const targetUser = await guardRank(userId, ctx.session.user.rank);
@@ -178,11 +199,14 @@ export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInp
}); });
return actionOk(); return actionOk();
}); },
);
const unbanInput = z.object({ userId: z.coerce.number().int().positive() }); const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => { export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: unbanInput },
async (ctx) => {
const { userId } = ctx.data; const { userId } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank); const targetUser = await guardRank(userId, ctx.session.user.rank);
@@ -203,7 +227,8 @@ export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unba
}); });
return actionOk(); return actionOk();
}); },
);
export const giveBadge = adminAction( export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema }, { permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
@@ -212,7 +237,9 @@ export const giveBadge = adminAction(
await guardRank(userId, ctx.session.user.rank); await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } }); const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
});
if (existing) throw new ActionError("Badge already assigned"); if (existing) throw new ActionError("Badge already assigned");
await prisma.usersBadges.create({ data: { userId, badgeCode } }); await prisma.usersBadges.create({ data: { userId, badgeCode } });
@@ -236,7 +263,9 @@ export const removeBadge = adminAction(
await guardRank(userId, ctx.session.user.rank); await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } }); const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
});
if (!existing) throw new ActionError("Badge not found"); if (!existing) throw new ActionError("Badge not found");
await prisma.usersBadges.delete({ where: { id: existing.id } }); await prisma.usersBadges.delete({ where: { id: existing.id } });
@@ -271,7 +300,10 @@ export const resetPassword = adminAction(
async (ctx) => { async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank); const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16); const newPassword = crypto
.randomBytes(12)
.toString("base64url")
.slice(0, 16);
const hashed = await hash(newPassword, 10); const hashed = await hash(newPassword, 10);
await prisma.user.update({ await prisma.user.update({
@@ -308,7 +340,8 @@ export const disconnectUser = adminAction(
async (ctx) => { async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank); const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.disconnectUser(ctx.data.userId); const success = await rcon.disconnectUser(ctx.data.userId);
if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?"); if (!success)
throw new ActionError("Failed to disconnect. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -338,7 +371,8 @@ export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema }, { permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => { async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message); const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success) throw new ActionError("Failed to send alert. Is the emulator running?"); if (!success)
throw new ActionError("Failed to send alert. Is the emulator running?");
return actionOk(); return actionOk();
}, },
); );
@@ -350,10 +384,13 @@ const muteSchema = z.object({
duration: z.coerce.number().int().min(0).default(0), duration: z.coerce.number().int().min(0).default(0),
}); });
export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => { export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration); const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
if (!success) throw new ActionError("Failed to mute. Is the emulator running?"); if (!success)
throw new ActionError("Failed to mute. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -364,7 +401,8 @@ export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: mute
}); });
return actionOk(); return actionOk();
}); },
);
// ── Unmute User ───────────────────────────────────────────────────── // ── Unmute User ─────────────────────────────────────────────────────
@@ -372,10 +410,13 @@ const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}); });
export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => { export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank); await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.unmuteUser(ctx.data.userId); const success = await rcon.unmuteUser(ctx.data.userId);
if (!success) throw new ActionError("Failed to unmute. Is the emulator running?"); if (!success)
throw new ActionError("Failed to unmute. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -385,7 +426,8 @@ export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: un
}); });
return actionOk(); return actionOk();
}); },
);
// ── Send Credits via RCON ─────────────────────────────────────────── // ── Send Credits via RCON ───────────────────────────────────────────
@@ -399,7 +441,8 @@ export const sendCredits = adminAction(
async (ctx) => { async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount); const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success) throw new ActionError("Failed to send credits. Is the emulator running?"); if (!success)
throw new ActionError("Failed to send credits. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
+33 -8
View File
@@ -23,7 +23,10 @@ export type RedeemState = { ok: boolean; message: string } | null;
* voucher schema carries a single `amount`, granted as the website credits * voucher schema carries a single `amount`, granted as the website credits
* wallet currency. * wallet currency.
*/ */
export async function redeem(_prev: RedeemState, formData: FormData): Promise<RedeemState> { export async function redeem(
_prev: RedeemState,
formData: FormData,
): Promise<RedeemState> {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return { ok: false, message: "You must be signed in to redeem a voucher." }; return { ok: false, message: "You must be signed in to redeem a voucher." };
@@ -31,7 +34,10 @@ export async function redeem(_prev: RedeemState, formData: FormData): Promise<Re
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId)) { if (!Number.isFinite(userId)) {
return { ok: false, message: "Your session is invalid. Please sign in again." }; return {
ok: false,
message: "Your session is invalid. Please sign in again.",
};
} }
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
@@ -52,15 +58,30 @@ export async function redeem(_prev: RedeemState, formData: FormData): Promise<Re
try { try {
voucher = await prisma.websiteShopVouchers.findUnique({ voucher = await prisma.websiteShopVouchers.findUnique({
where: { code }, where: { code },
select: { id: true, amount: true, maxUses: true, useCount: true, expiresAt: true }, select: {
id: true,
amount: true,
maxUses: true,
useCount: true,
expiresAt: true,
},
}); });
} catch { } catch {
return { ok: false, message: "We couldn't reach the server. Please try again." }; return {
ok: false,
message: "We couldn't reach the server. Please try again.",
};
} }
// Not found OR already expired -> generic "no active voucher" (matches AtomCMS). // Not found OR already expired -> generic "no active voucher" (matches AtomCMS).
if (!voucher || (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())) { if (
return { ok: false, message: "No active voucher with the given code was found." }; !voucher ||
(voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())
) {
return {
ok: false,
message: "No active voucher with the given code was found.",
};
} }
// One redemption per user. // One redemption per user.
@@ -73,7 +94,10 @@ export async function redeem(_prev: RedeemState, formData: FormData): Promise<Re
return { ok: false, message: "You can only use each shop voucher once." }; return { ok: false, message: "You can only use each shop voucher once." };
} }
} catch { } catch {
return { ok: false, message: "We couldn't reach the server. Please try again." }; return {
ok: false,
message: "We couldn't reach the server. Please try again.",
};
} }
// Record the redemption first so a successful grant can never be double-claimed. // Record the redemption first so a successful grant can never be double-claimed.
@@ -94,7 +118,8 @@ export async function redeem(_prev: RedeemState, formData: FormData): Promise<Re
// the used-row stands and the balance simply wasn't credited — surface that. // the used-row stands and the balance simply wasn't credited — surface that.
return { return {
ok: false, ok: false,
message: "Your voucher was accepted but the reward could not be delivered. Contact staff.", message:
"Your voucher was accepted but the reward could not be delivered. Contact staff.",
}; };
} }
+17 -4
View File
@@ -52,16 +52,29 @@ export default async function AdminAchievements() {
<Award size={20} className="text-[var(--admin-accent)]" /> <Award size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5"> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", { rows: achievements.length, categories: groups.size })} {t("subtitle", {
rows: achievements.length,
categories: groups.size,
})}
</p> </p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("achievementRows")} value={achievements.length} icon="🏆" /> <StatusCard
<StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" /> label={t("achievementRows")}
value={achievements.length}
icon="🏆"
/>
<StatusCard
label={t("distinctAchievements")}
value={distinctNames}
icon="🎖️"
/>
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" /> <StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div> </div>
+16 -4
View File
@@ -1,12 +1,16 @@
import Link from "next/link";
import { Megaphone } from "lucide-react"; import { Megaphone } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { deleteAd, updateAd } from "@/actions/admin-ads"; import { deleteAd, updateAd } from "@/actions/admin-ads";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function EditAd({ params }: { params: Promise<{ id: string }> }) { export default async function EditAd({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; const { id } = await params;
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null; let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
try { try {
@@ -23,7 +27,9 @@ export default async function EditAd({ params }: { params: Promise<{ id: string
<Megaphone size={20} className="text-[var(--admin-accent)]" /> <Megaphone size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">Edit advertisement</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
Edit advertisement
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5"> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link> <Link href="/admin/ads">← Advertisements</Link>
</p> </p>
@@ -31,7 +37,13 @@ export default async function EditAd({ params }: { params: Promise<{ id: string
</div> </div>
<form action={updateAd} className="admin-card"> <form action={updateAd} className="admin-card">
<input type="hidden" name="id" value={String(ad.id)} /> <input type="hidden" name="id" value={String(ad.id)} />
<input name="image" defaultValue={ad.image} placeholder="Image URL" required maxLength={255} /> <input
name="image"
defaultValue={ad.image}
placeholder="Image URL"
required
maxLength={255}
/>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save Save
</button> </button>
+4 -2
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { Megaphone } from "lucide-react"; import { Megaphone } from "lucide-react";
import Link from "next/link";
import { createAd } from "@/actions/admin-ads"; import { createAd } from "@/actions/admin-ads";
export default function NewAd() { export default function NewAd() {
@@ -10,7 +10,9 @@ export default function NewAd() {
<Megaphone size={20} className="text-[var(--admin-accent)]" /> <Megaphone size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">New advertisement</h1> <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"> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link> <Link href="/admin/ads">← Advertisements</Link>
</p> </p>
+24 -7
View File
@@ -1,6 +1,6 @@
import { Megaphone } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Megaphone } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -18,7 +18,9 @@ export default async function AdminAds() {
ads = []; ads = [];
} }
const latest = ads[0]?.createdAt ? ads[0].createdAt.toISOString().slice(0, 10) : "—"; const latest = ads[0]?.createdAt
? ads[0].createdAt.toISOString().slice(0, 10)
: "—";
return ( return (
<main> <main>
@@ -27,8 +29,12 @@ export default async function AdminAds() {
<Megaphone size={20} className="text-[var(--admin-accent)]" /> <Megaphone size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p> {t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div> </div>
<Link href="/admin/ads/new" className="btn btn-primary"> <Link href="/admin/ads/new" className="btn btn-primary">
+ {t("newAd")} + {t("newAd")}
@@ -37,7 +43,12 @@ export default async function AdminAds() {
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" /> <StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" />
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentAd")} icon="🕒" /> <StatusCard
label={t("latest")}
value={latest}
hint={t("mostRecentAd")}
icon="🕒"
/>
</div> </div>
<section className="mt-6"> <section className="mt-6">
@@ -59,13 +70,19 @@ export default async function AdminAds() {
<tr key={String(ad.id)}> <tr key={String(ad.id)}>
<td> <td>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} /> <img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td> </td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> <td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code> <code>{ad.image}</code>
</td> </td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> <td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"} {ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: "—"}
</td> </td>
<td> <td>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link> <Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
+33 -9
View File
@@ -1,5 +1,5 @@
import { getTranslations } from "next-intl/server";
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { sendHotelAlert } from "@/actions/admin-alerts"; import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -66,8 +66,12 @@ export default async function AdminAlerts() {
alerts = []; alerts = [];
} }
const critical = alerts.filter((a) => CRITICAL.has((a.severity || "").toLowerCase())).length; const critical = alerts.filter((a) =>
const warnings = alerts.filter((a) => WARNING.has((a.severity || "").toLowerCase())).length; 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; const unread = alerts.filter((a) => !a.isRead).length;
return ( return (
@@ -77,21 +81,35 @@ export default async function AdminAlerts() {
<Bell size={20} className="text-[var(--admin-accent)]" /> <Bell size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p> {t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" /> <StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard label={t("critical")} value={critical} state={critical > 0 ? "danger" : "ok"} icon="🚨" /> <StatusCard
label={t("critical")}
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard <StatusCard
label={t("warnings")} label={t("warnings")}
value={warnings} value={warnings}
state={warnings > 0 ? "warn" : "neutral"} state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️" icon="⚠️"
/> />
<StatusCard label={t("unread")} value={unread} state={unread > 0 ? "warn" : "ok"} icon="📨" /> <StatusCard
label={t("unread")}
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
/>
</div> </div>
<form action={sendHotelAlert} className="admin-card mb-6"> <form action={sendHotelAlert} className="admin-card mb-6">
@@ -138,10 +156,16 @@ export default async function AdminAlerts() {
{fromDate(a.createdAt)} {fromDate(a.createdAt)}
</td> </td>
<td> <td>
<Badge label={a.severity || "—"} color={severityColor(a.severity)} /> <Badge
label={a.severity || "—"}
color={severityColor(a.severity)}
/>
</td> </td>
<td> <td>
<Badge label={a.type || "—"} color="var(--admin-text-muted)" /> <Badge
label={a.type || "—"}
color="var(--admin-text-muted)"
/>
</td> </td>
<td>{a.message}</td> <td>{a.message}</td>
</tr> </tr>
+29 -9
View File
@@ -39,7 +39,11 @@ async function getActivityData() {
const bucket = Math.floor(dayStart / 86400); const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000); const date = new Date(dayStart * 1000);
dailyData.push({ dailyData.push({
label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric" }), label: date.toLocaleDateString("en", {
weekday: "short",
month: "short",
day: "numeric",
}),
chats: chatMap.get(bucket) ?? 0, chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0, commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0, bans: banMap.get(bucket) ?? 0,
@@ -48,7 +52,8 @@ async function getActivityData() {
} }
// Totals // Totals
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([ const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] =
await Promise.all([
prisma.$queryRaw< prisma.$queryRaw<
{ count: bigint }[] { count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, >`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
@@ -62,7 +67,9 @@ async function getActivityData() {
const weekCommands = Number(weekCommandRows[0]?.count ?? 0); const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
// Most active users (by chat messages) // Most active users (by chat messages)
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>` const topChatters = await prisma.$queryRaw<
{ userId: number; messages: bigint }[]
>`
SELECT user_from_id AS userId, COUNT(*) AS messages SELECT user_from_id AS userId, COUNT(*) AS messages
FROM chatlogs_room WHERE timestamp >= ${weekAgo} FROM chatlogs_room WHERE timestamp >= ${weekAgo}
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10 GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
@@ -103,7 +110,9 @@ export default async function ActivityPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Activity</h1> <h1 className="text-3xl font-bold tracking-tight">Activity</h1>
<p className="text-muted-foreground">User activity breakdown and engagement metrics</p> <p className="text-muted-foreground">
User activity breakdown and engagement metrics
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -155,10 +164,16 @@ export default async function ActivityPage() {
{data.dailyData.map((day) => ( {data.dailyData.map((day) => (
<tr key={day.label} className="border-b last:border-0"> <tr key={day.label} className="border-b last:border-0">
<td className="py-2">{day.label}</td> <td className="py-2">{day.label}</td>
<td className="text-right tabular-nums">{day.chats.toLocaleString()}</td> <td className="text-right tabular-nums">
<td className="text-right tabular-nums">{day.commands.toLocaleString()}</td> {day.chats.toLocaleString()}
</td>
<td className="text-right tabular-nums">
{day.commands.toLocaleString()}
</td>
<td className="text-right tabular-nums">{day.bans}</td> <td className="text-right tabular-nums">{day.bans}</td>
<td className="text-right tabular-nums">{day.registrations}</td> <td className="text-right tabular-nums">
{day.registrations}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -178,11 +193,16 @@ export default async function ActivityPage() {
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{data.topChatters.map((user, i) => ( {data.topChatters.map((user, i) => (
<div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"> <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"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
{i + 1} {i + 1}
</span> </span>
<span className="flex-1 text-sm font-medium">{user.username}</span> <span className="flex-1 text-sm font-medium">
{user.username}
</span>
<span className="text-sm tabular-nums text-muted-foreground"> <span className="text-sm tabular-nums text-muted-foreground">
{user.messages.toLocaleString()} msgs {user.messages.toLocaleString()} msgs
</span> </span>
+34 -11
View File
@@ -8,7 +8,10 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const RevenueChart = dynamic( const RevenueChart = dynamic(
() => import("@/components/admin/revenue-chart").then((m) => ({ default: m.RevenueChart })), () =>
import("@/components/admin/revenue-chart").then((m) => ({
default: m.RevenueChart,
})),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> }, { loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
); );
@@ -35,14 +38,18 @@ async function getEconomyData() {
}); });
// Marketplace stats // Marketplace stats
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } }); const marketplaceActive = await prisma.marketplaceItems.count({
where: { state: 1 },
});
const marketplaceSold = await prisma.marketplaceItems.count({ const marketplaceSold = await prisma.marketplaceItems.count({
where: { state: 2, soldTimestamp: { gte: weekAgo } }, where: { state: 2, soldTimestamp: { gte: weekAgo } },
}); });
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries // Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
const rangeStart = todayStart - 6 * 86400; const rangeStart = todayStart - 6 * 86400;
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>` const revRows = await prisma.$queryRaw<
{ day_bucket: bigint; credits: number; points: number }[]
>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits, COALESCE(SUM(cost_credits), 0) AS credits,
COALESCE(SUM(cost_points), 0) AS points COALESCE(SUM(cost_points), 0) AS points
@@ -51,7 +58,10 @@ async function getEconomyData() {
GROUP BY day_bucket ORDER BY day_bucket GROUP BY day_bucket ORDER BY day_bucket
`; `;
const revMap = new Map( const revMap = new Map(
revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]), revRows.map((r) => [
Number(r.day_bucket),
{ credits: Number(r.credits), points: Number(r.points) },
]),
); );
const revenueData = []; const revenueData = [];
for (let i = 6; i >= 0; i--) { for (let i = 6; i >= 0; i--) {
@@ -75,7 +85,8 @@ async function getEconomyData() {
totalCredits, totalCredits,
totalPixels, totalPixels,
totalPoints, totalPoints,
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0, shopPurchases:
typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0, shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0, shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
marketplaceActive, marketplaceActive,
@@ -97,7 +108,9 @@ export default async function EconomyPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Economy</h1> <h1 className="text-3xl font-bold tracking-tight">Economy</h1>
<p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p> <p className="text-muted-foreground">
Currency circulation, shop revenue and marketplace activity
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -142,11 +155,15 @@ export default async function EconomyPage() {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Credits spent</span> <span className="text-muted-foreground">Credits spent</span>
<span className="font-medium">{data.shopCreditsSpent.toLocaleString()}</span> <span className="font-medium">
{data.shopCreditsSpent.toLocaleString()}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Points spent</span> <span className="text-muted-foreground">Points spent</span>
<span className="font-medium">{data.shopPointsSpent.toLocaleString()}</span> <span className="font-medium">
{data.shopPointsSpent.toLocaleString()}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -176,15 +193,21 @@ export default async function EconomyPage() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Credits</span> <span className="text-muted-foreground">Credits</span>
<span className="font-medium">{data.totalCredits.toLocaleString()}</span> <span className="font-medium">
{data.totalCredits.toLocaleString()}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Pixels</span> <span className="text-muted-foreground">Pixels</span>
<span className="font-medium">{data.totalPixels.toLocaleString()}</span> <span className="font-medium">
{data.totalPixels.toLocaleString()}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Points</span> <span className="text-muted-foreground">Points</span>
<span className="font-medium">{data.totalPoints.toLocaleString()}</span> <span className="font-medium">
{data.totalPoints.toLocaleString()}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+50 -14
View File
@@ -1,4 +1,11 @@
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react"; import {
ArrowLeftRight,
Clock,
DoorOpen,
MessageSquare,
TrendingUp,
Users,
} from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
@@ -8,12 +15,18 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const DashboardChart = dynamic( const DashboardChart = dynamic(
() => import("@/components/admin/dashboard-chart").then((m) => ({ default: m.DashboardChart })), () =>
import("@/components/admin/dashboard-chart").then((m) => ({
default: m.DashboardChart,
})),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> }, { loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
); );
const PeakHoursHeatmap = dynamic( const PeakHoursHeatmap = dynamic(
() => import("@/components/admin/peak-hours-heatmap").then((m) => ({ default: m.PeakHoursHeatmap })), () =>
import("@/components/admin/peak-hours-heatmap").then((m) => ({
default: m.PeakHoursHeatmap,
})),
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> }, { loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
); );
@@ -48,13 +61,17 @@ async function getAnalyticsData() {
]); ]);
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries // Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` const regRows = await prisma.$queryRaw<
{ day_bucket: bigint; count: bigint }[]
>`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users FROM users
WHERE account_created >= ${monthAgo} WHERE account_created >= ${monthAgo}
GROUP BY day_bucket ORDER BY day_bucket GROUP BY day_bucket ORDER BY day_bucket
`; `;
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)])); const regCountMap = new Map(
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
);
const registrationChart = []; const registrationChart = [];
for (let i = 29; i >= 0; i--) { for (let i = 29; i >= 0; i--) {
const dayStart = todayStart - i * 86400; const dayStart = todayStart - i * 86400;
@@ -67,7 +84,9 @@ async function getAnalyticsData() {
} }
// Peak hours — single GROUP BY query instead of fetching all user rows // Peak hours — single GROUP BY query instead of fetching all user rows
const peakRows = await prisma.$queryRaw<{ d: number; h: number; c: bigint }[]>` const peakRows = await prisma.$queryRaw<
{ d: number; h: number; c: bigint }[]
>`
SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d, SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
HOUR(FROM_UNIXTIME(last_online)) AS h, HOUR(FROM_UNIXTIME(last_online)) AS h,
COUNT(*) AS c COUNT(*) AS c
@@ -90,7 +109,9 @@ async function getAnalyticsData() {
monthRegistrations, monthRegistrations,
totalRooms, totalRooms,
activeRooms, activeRooms,
weekChatMessages: Array.isArray(weekChatMessages) ? Number(weekChatMessages[0]?.count ?? 0) : 0, weekChatMessages: Array.isArray(weekChatMessages)
? Number(weekChatMessages[0]?.count ?? 0)
: 0,
weekTrades, weekTrades,
weekBans, weekBans,
registrationChart, registrationChart,
@@ -110,7 +131,9 @@ export default async function AnalyticsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1> <h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">Hotel performance overview and trends</p> <p className="text-muted-foreground">
Hotel performance overview and trends
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -158,8 +181,12 @@ export default async function AnalyticsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold">{data.weekChatMessages.toLocaleString()}</div> <div className="text-3xl font-bold">
<p className="text-sm text-muted-foreground">Messages in the last 7 days</p> {data.weekChatMessages.toLocaleString()}
</div>
<p className="text-sm text-muted-foreground">
Messages in the last 7 days
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -169,8 +196,12 @@ export default async function AnalyticsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold">{data.weekTrades.toLocaleString()}</div> <div className="text-3xl font-bold">
<p className="text-sm text-muted-foreground">Trades in the last 7 days</p> {data.weekTrades.toLocaleString()}
</div>
<p className="text-sm text-muted-foreground">
Trades in the last 7 days
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -181,9 +212,14 @@ export default async function AnalyticsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold"> <div className="text-3xl font-bold">
{data.totalUsers > 0 ? Math.round((data.onlineUsers / data.totalUsers) * 100) : 0}% {data.totalUsers > 0
? Math.round((data.onlineUsers / data.totalUsers) * 100)
: 0}
%
</div> </div>
<p className="text-sm text-muted-foreground">Users online / total</p> <p className="text-sm text-muted-foreground">
Users online / total
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+18 -8
View File
@@ -1,9 +1,9 @@
import Link from "next/link";
import { ClipboardList } from "lucide-react"; import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications"; import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -53,8 +53,12 @@ export default async function AdminApplications() {
<ClipboardList size={20} className="text-[var(--admin-accent)]" /> <ClipboardList size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p> {t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div> </div>
</div> </div>
@@ -68,7 +72,9 @@ export default async function AdminApplications() {
</div> </div>
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">{t("applicationsTitle", { count: applications.length })}</h2> <h2 className="admin-section-title">
{t("applicationsTitle", { count: applications.length })}
</h2>
{applications.length === 0 ? ( {applications.length === 0 ? (
<div className="admin-empty">{t("noApplications")}</div> <div className="admin-empty">{t("noApplications")}</div>
) : ( ) : (
@@ -80,9 +86,11 @@ export default async function AdminApplications() {
<div className="flex items-baseline justify-between gap-2 mb-1"> <div className="flex items-baseline justify-between gap-2 mb-1">
<h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]"> <h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]">
{username ? ( {username ? (
<Link href={`/admin/users/${a.userId}`}>{username}</Link> <Link href={`/admin/users/${a.userId}`}>
{username}
</Link>
) : ( ) : (
<>{t("userNumber", { id: a.userId })}</> t("userNumber", { id: a.userId })
)} )}
</h3> </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)]"> <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)]">
@@ -92,7 +100,9 @@ export default async function AdminApplications() {
<p className="text-xs theme-text-muted dark:theme-text-muted m-0 mb-2"> <p className="text-xs theme-text-muted dark:theme-text-muted m-0 mb-2">
{formatDate(a.createdAt)} {formatDate(a.createdAt)}
</p> </p>
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">{a.content}</p> <p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
{a.content}
</p>
<form action={dismissApplication}> <form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} /> <input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
+13 -4
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { deleteArticle, updateArticle } from "@/actions/admin-articles"; import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -21,7 +21,14 @@ export default async function EditArticle({
const article = await prisma.websiteArticles const article = await prisma.websiteArticles
.findUnique({ .findUnique({
where: { id: BigInt(id) }, where: { id: BigInt(id) },
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true }, select: {
id: true,
slug: true,
title: true,
shortStory: true,
fullStory: true,
image: true,
},
}) })
.catch(() => null); .catch(() => null);
if (!article) notFound(); if (!article) notFound();
@@ -33,7 +40,9 @@ export default async function EditArticle({
<Newspaper size={20} className="text-[var(--admin-accent)]" /> <Newspaper size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("form.title")}</h1> <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"> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/articles">← {t("title")}</Link> <Link href="/admin/articles">← {t("title")}</Link>
</p> </p>
+9 -3
View File
@@ -1,10 +1,14 @@
import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { createArticle } from "@/actions/admin-articles"; import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
export default async function NewArticle({ searchParams }: { searchParams: Promise<{ error?: string }> }) { export default async function NewArticle({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
@@ -15,7 +19,9 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
<Newspaper size={20} className="text-[var(--admin-accent)]" /> <Newspaper size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("form.title")}</h1> <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"> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/articles">← {t("title")}</Link> <Link href="/admin/articles">← {t("title")}</Link>
</p> </p>
+31 -9
View File
@@ -1,13 +1,17 @@
import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { deleteArticle } from "@/actions/admin-articles"; import { deleteArticle } from "@/actions/admin-articles";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminArticles({ searchParams }: { searchParams: Promise<{ error?: string }> }) { export default async function AdminArticles({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles const articles = await prisma.websiteArticles
@@ -18,7 +22,9 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
}) })
.catch(() => []); .catch(() => []);
const latest = articles[0]?.createdAt ? articles[0].createdAt.toISOString().slice(0, 10) : "—"; const latest = articles[0]?.createdAt
? articles[0].createdAt.toISOString().slice(0, 10)
: "—";
return ( return (
<main> <main>
@@ -27,8 +33,12 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
<Newspaper size={20} className="text-[var(--admin-accent)]" /> <Newspaper size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p> {t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div> </div>
<Link href="/admin/articles/new" className="btn btn-primary"> <Link href="/admin/articles/new" className="btn btn-primary">
+ {t("newArticle")} + {t("newArticle")}
@@ -42,8 +52,17 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
) : null} ) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" /> <StatusCard
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentPost")} icon="🕒" /> label={t("articlesShown")}
value={articles.length}
icon="📰"
/>
<StatusCard
label={t("latest")}
value={latest}
hint={t("mostRecentPost")}
icon="🕒"
/>
</div> </div>
<section className="mt-6"> <section className="mt-6">
@@ -74,7 +93,10 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
</td> </td>
<td> <td>
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary"> <Link
href={`/admin/articles/${a.id}`}
className="btn btn-primary"
>
{t("edit")} {t("edit")}
</Link> </Link>
<form action={deleteArticle} className="m-0"> <form action={deleteArticle} className="m-0">
+27 -10
View File
@@ -1,8 +1,8 @@
import { getTranslations } from "next-intl/server";
import { giveBadge } from "@/actions/admin-badges";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { prisma } from "@/lib/prisma";
import { Award } from "lucide-react"; import { Award } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { giveBadge } from "@/actions/admin-badges";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -33,8 +33,12 @@ export default async function AdminBadges({
<Award size={20} className="text-[var(--admin-accent)]" /> <Award size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p> {t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div> </div>
</div> </div>
@@ -51,7 +55,9 @@ export default async function AdminBadges({
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">{t("uploadTitle")}</h2> <h2 className="admin-section-title">{t("uploadTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">{t("uploadSubtitle")}</p> <p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("uploadSubtitle")}
</p>
<form action={uploadBadge} className="admin-card"> <form action={uploadBadge} className="admin-card">
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
<input type="file" name="file" accept=".gif,.png" required /> <input type="file" name="file" accept=".gif,.png" required />
@@ -74,7 +80,13 @@ export default async function AdminBadges({
<h2 className="admin-section-title">{t("giveTitle")}</h2> <h2 className="admin-section-title">{t("giveTitle")}</h2>
<form action={giveBadge} className="admin-card"> <form action={giveBadge} className="admin-card">
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required /> <input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
/>
<input <input
name="code" name="code"
placeholder={t("badgeCodePlaceholder")} placeholder={t("badgeCodePlaceholder")}
@@ -121,9 +133,14 @@ export default async function AdminBadges({
<code>{b.badgeKey}</code> <code>{b.badgeKey}</code>
</td> </td>
<td>{b.badgeName}</td> <td>{b.badgeName}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted">{b.badgeDescription}</td> <td className="text-sm theme-text-muted dark:theme-text-muted">
{b.badgeDescription}
</td>
<td> <td>
<form action={giveBadge} className="flex gap-2 items-center"> <form
action={giveBadge}
className="flex gap-2 items-center"
>
<input type="hidden" name="code" value={b.badgeKey} /> <input type="hidden" name="code" value={b.badgeKey} />
<input <input
name="userId" name="userId"
+63 -17
View File
@@ -79,9 +79,15 @@ export function BannersManager({ banners }: Props) {
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null }; const data = {
...form,
startDate: form.startDate || null,
endDate: form.endDate || null,
};
if (editingId) { if (editingId) {
run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" }); run(() => updateBanner({ id: editingId, ...data }), {
successMessage: "Banner updated!",
});
} else { } else {
run(() => createBanner(data), { successMessage: "Banner created!" }); run(() => createBanner(data), { successMessage: "Banner created!" });
} }
@@ -113,7 +119,9 @@ export function BannersManager({ banners }: Props) {
<Label>Title</Label> <Label>Title</Label>
<Input <Input
value={form.title} value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} onChange={(e) =>
setForm({ ...form, title: e.target.value })
}
required required
/> />
</div> </div>
@@ -121,7 +129,9 @@ export function BannersManager({ banners }: Props) {
<Label>Subtitle</Label> <Label>Subtitle</Label>
<Input <Input
value={form.subtitle} value={form.subtitle}
onChange={(e) => setForm({ ...form, subtitle: e.target.value })} onChange={(e) =>
setForm({ ...form, subtitle: e.target.value })
}
/> />
</div> </div>
</div> </div>
@@ -130,13 +140,18 @@ export function BannersManager({ banners }: Props) {
<Label>Image URL</Label> <Label>Image URL</Label>
<Input <Input
value={form.image} value={form.image}
onChange={(e) => setForm({ ...form, image: e.target.value })} onChange={(e) =>
setForm({ ...form, image: e.target.value })
}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Link URL</Label> <Label>Link URL</Label>
<Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} /> <Input
value={form.link}
onChange={(e) => setForm({ ...form, link: e.target.value })}
/>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -145,14 +160,21 @@ export function BannersManager({ banners }: Props) {
<Input <Input
type="color" type="color"
value={form.color || "#000000"} value={form.color || "#000000"}
onChange={(e) => setForm({ ...form, color: e.target.value })} onChange={(e) =>
setForm({ ...form, color: e.target.value })
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Start Date</Label> <Label>Start Date</Label>
<DateTimePicker <DateTimePicker
value={form.startDate} value={form.startDate}
onChange={(d) => setForm({ ...form, startDate: d ? d.toISOString() : null })} onChange={(d) =>
setForm({
...form,
startDate: d ? d.toISOString() : null,
})
}
placeholder="Select start date" placeholder="Select start date"
/> />
</div> </div>
@@ -160,7 +182,9 @@ export function BannersManager({ banners }: Props) {
<Label>End Date</Label> <Label>End Date</Label>
<DateTimePicker <DateTimePicker
value={form.endDate} value={form.endDate}
onChange={(d) => setForm({ ...form, endDate: d ? d.toISOString() : null })} onChange={(d) =>
setForm({ ...form, endDate: d ? d.toISOString() : null })
}
placeholder="Select end date" placeholder="Select end date"
/> />
</div> </div>
@@ -169,7 +193,9 @@ export function BannersManager({ banners }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
checked={form.isActive === 1} checked={form.isActive === 1}
onCheckedChange={(v) => setForm({ ...form, isActive: v ? 1 : 0 })} onCheckedChange={(v) =>
setForm({ ...form, isActive: v ? 1 : 0 })
}
/> />
<Label>Active</Label> <Label>Active</Label>
</div> </div>
@@ -179,7 +205,9 @@ export function BannersManager({ banners }: Props) {
type="number" type="number"
className="w-20" className="w-20"
value={form.sortOrder} value={form.sortOrder}
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} onChange={(e) =>
setForm({ ...form, sortOrder: Number(e.target.value) })
}
/> />
</div> </div>
</div> </div>
@@ -198,27 +226,43 @@ export function BannersManager({ banners }: Props) {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{banners.length === 0 && ( {banners.length === 0 && (
<p className="text-muted-foreground col-span-full text-center py-8">No banners yet.</p> <p className="text-muted-foreground col-span-full text-center py-8">
No banners yet.
</p>
)} )}
{banners.map((b) => ( {banners.map((b) => (
<Card key={b.id} className="overflow-hidden"> <Card key={b.id} className="overflow-hidden">
{b.image && ( {b.image && (
<div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} /> <div
className="h-32 bg-cover bg-center"
style={{ backgroundImage: `url(${b.image})` }}
/>
)} )}
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 className="font-medium">{b.title}</h3> <h3 className="font-medium">{b.title}</h3>
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>} {b.subtitle && (
<p className="text-xs text-muted-foreground">
{b.subtitle}
</p>
)}
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<Badge variant={b.isActive ? "default" : "outline"}> <Badge variant={b.isActive ? "default" : "outline"}>
{b.isActive ? "Active" : "Inactive"} {b.isActive ? "Active" : "Inactive"}
</Badge> </Badge>
{b.startDate && <Badge variant="secondary">Scheduled</Badge>} {b.startDate && (
<Badge variant="secondary">Scheduled</Badge>
)}
</div> </div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(b)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
@@ -226,7 +270,9 @@ export function BannersManager({ banners }: Props) {
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => onClick={() =>
run(() => deleteBanner({ id: b.id }), { successMessage: "Banner deleted" }) run(() => deleteBanner({ id: b.id }), {
successMessage: "Banner deleted",
})
} }
> >
<Trash2 className="h-3.5 w-3.5 text-destructive" /> <Trash2 className="h-3.5 w-3.5 text-destructive" />
+3 -1
View File
@@ -17,7 +17,9 @@ export default async function BannersPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Banners</h1> <h1 className="text-3xl font-bold tracking-tight">Banners</h1>
<p className="text-muted-foreground">Manage homepage banners and promotional slides</p> <p className="text-muted-foreground">
Manage homepage banners and promotional slides
</p>
</div> </div>
<BannersManager banners={banners} /> <BannersManager banners={banners} />
</div> </div>
+29 -8
View File
@@ -1,8 +1,8 @@
import { Ban } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans"; import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Ban } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -35,8 +35,12 @@ export default async function AdminBans() {
<Ban size={20} className="text-[var(--admin-accent)]" /> <Ban size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p> {t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div> </div>
</div> </div>
@@ -57,17 +61,34 @@ export default async function AdminBans() {
</div> </div>
<form action={createBan} className="admin-card mb-6"> <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> <h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
{t("newBan")}
</h3>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required /> <input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
/>
<select name="type"> <select name="type">
<option value="account">{t("typeAccount")}</option> <option value="account">{t("typeAccount")}</option>
<option value="ip">{t("typeIp")}</option> <option value="ip">{t("typeIp")}</option>
<option value="machine">{t("typeMachine")}</option> <option value="machine">{t("typeMachine")}</option>
<option value="super">{t("typeSuper")}</option> <option value="super">{t("typeSuper")}</option>
</select> </select>
<input name="hours" type="number" min={0} placeholder={t("hoursPlaceholder")} /> <input
<input name="reason" placeholder={t("reasonPlaceholder")} className="flex-1 min-w-[160px]" /> name="hours"
type="number"
min={0}
placeholder={t("hoursPlaceholder")}
/>
<input
name="reason"
placeholder={t("reasonPlaceholder")}
className="flex-1 min-w-[160px]"
/>
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
{t("ban")} {t("ban")}
</button> </button>
+34 -10
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -35,7 +35,11 @@ function fmtDate(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10); return new Date(ts * 1000).toISOString().slice(0, 10);
} }
export default async function AdminCalendarCampaign({ params }: { params: Promise<{ id: string }> }) { export default async function AdminCalendarCampaign({
params,
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.admin.calendar"); const t = await getTranslations("pages.admin.calendar");
const { id } = await params; const { id } = await params;
@@ -97,7 +101,11 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
<div className="admin-card flex items-center gap-5 mb-6"> <div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? ( {campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img className="w-24 h-24 object-cover" src={campaign.image} alt={campaign.name} /> <img
className="w-24 h-24 object-cover"
src={campaign.image}
alt={campaign.name}
/>
) : null} ) : null}
<div> <div>
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1> <h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
@@ -107,8 +115,12 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
days: campaign.totalDays, days: campaign.totalDays,
date: fmtDate(campaign.startTimestamp), date: fmtDate(campaign.startTimestamp),
})} })}
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`} {disabled
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""} ? ` · ${t("detail.disabled")}`
: ` · ${t("detail.enabled")}`}
{campaign.lockExpired === "1"
? ` · ${t("detail.locksExpired")}`
: ""}
</p> </p>
</div> </div>
</div> </div>
@@ -135,7 +147,11 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
<tr key={r.id} className="border-t border-[var(--admin-border)]"> <tr key={r.id} className="border-t border-[var(--admin-border)]">
<td>{r.id}</td> <td>{r.id}</td>
<td> <td>
{r.productName || <span className="text-sm theme-text-muted dark:theme-text-muted">—</span>} {r.productName || (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td> </td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> <td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.itemId > 0 ? r.itemId : "—"} {r.itemId > 0 ? r.itemId : "—"}
@@ -144,28 +160,36 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
{r.credits > 0 ? ( {r.credits > 0 ? (
r.credits r.credits
) : ( ) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span> <span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)} )}
</td> </td>
<td> <td>
{r.pixels > 0 ? ( {r.pixels > 0 ? (
r.pixels r.pixels
) : ( ) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span> <span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)} )}
</td> </td>
<td> <td>
{r.points > 0 ? ( {r.points > 0 ? (
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})` `${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
) : ( ) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span> <span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)} )}
</td> </td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> <td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.badge || "—"} {r.badge || "—"}
</td> </td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> <td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.subscriptionType ? `${r.subscriptionType} · ${r.subscriptionDays}d` : "—"} {r.subscriptionType
? `${r.subscriptionType} · ${r.subscriptionDays}d`
: "—"}
</td> </td>
</tr> </tr>
))} ))}
+14 -4
View File
@@ -1,8 +1,8 @@
import { Calendar } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Calendar } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -63,7 +63,9 @@ export default async function AdminCalendar() {
<Calendar size={20} className="text-[var(--admin-accent)]" /> <Calendar size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5"> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", { count: campaigns.length })} {t("subtitle", { count: campaigns.length })}
</p> </p>
@@ -89,7 +91,11 @@ export default async function AdminCalendar() {
const enabled = c.enabled === "1"; const enabled = c.enabled === "1";
const count = rewardCounts.get(c.id) ?? 0; const count = rewardCounts.get(c.id) ?? 0;
return ( return (
<Link key={c.id} href={`/admin/calendar/${c.id}`} className="admin-card hover article"> <Link
key={c.id}
href={`/admin/calendar/${c.id}`}
className="admin-card hover article"
>
{c.image ? ( {c.image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} /> <img className="article-img" src={c.image} alt={c.name} />
@@ -106,7 +112,11 @@ export default async function AdminCalendar() {
</span> </span>
</div> </div>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0"> <p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("campaignMeta", { id: c.id, days: c.totalDays, rewards: count })} {t("campaignMeta", {
id: c.id,
days: c.totalDays,
rewards: count,
})}
</p> </p>
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1"> <p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
{t("startsAt", { date: fmtDate(c.startTimestamp) })} {t("startsAt", { date: fmtDate(c.startTimestamp) })}
@@ -78,8 +78,7 @@ export function CatalogDetailTabs({
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="translate" className="gap-2"> <TabsTrigger value="translate" className="gap-2">
<Languages className="h-4 w-4" /> <Languages className="h-4 w-4" />
Translate ({baseItems.length}) Translate ({baseItems.length}){(() => {
{(() => {
const furniSet = new Set(furniDataIdList); const furniSet = new Set(furniDataIdList);
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length; const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
return missing > 0 ? ( return missing > 0 ? (
@@ -4,4 +4,7 @@
*/ */
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table"; export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
export { FurniIcon } from "./catalog-items-table/furni-icon"; export { FurniIcon } from "./catalog-items-table/furni-icon";
export type { BaseItemData, CatalogItemData } from "./catalog-items-table/types"; export type {
BaseItemData,
CatalogItemData,
} from "./catalog-items-table/types";
File diff suppressed because it is too large. Load diff
@@ -12,7 +12,11 @@ import {
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -37,7 +41,12 @@ export function ToggleField({
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label> <Label className="text-xs">{label}</Label>
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" /> <Switch
checked={isChecked}
onCheckedChange={onChange}
disabled={disabled}
size="sm"
/>
</div> </div>
); );
} }
@@ -78,7 +87,10 @@ export function InteractionTypeCombobox({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start"> <PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder="Search or type custom..." placeholder="Search or type custom..."
@@ -114,7 +126,12 @@ export function InteractionTypeCombobox({
setInputValue(""); setInputValue("");
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === t ? "opacity-100" : "opacity-0")} /> <Check
className={cn(
"mr-2 h-4 w-4",
value === t ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{t}</span> <span className="truncate">{t}</span>
</CommandItem> </CommandItem>
))} ))}
@@ -144,7 +161,10 @@ export function MovePageCombobox({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId]); const options = useMemo(
() => pages.filter((p) => p.id !== currentPageId),
[pages, currentPageId],
);
const selectedPage = value ? pages.find((p) => p.id === value) : null; const selectedPage = value ? pages.find((p) => p.id === value) : null;
@@ -159,8 +179,14 @@ export function MovePageCombobox({
}, [suggestedPageIds, options]); }, [suggestedPageIds, options]);
// Non-suggested pages (exclude those already in suggestions) // Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]); const suggestedSet = useMemo(
const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]); () => new Set(suggested.map((s) => s.id)),
[suggested],
);
const remaining = useMemo(
() => options.filter((p) => !suggestedSet.has(p.id)),
[options, suggestedSet],
);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -173,7 +199,9 @@ export function MovePageCombobox({
> >
{selectedPage ? ( {selectedPage ? (
<span className="truncate"> <span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{selectedPage.id}</span> <span className="text-muted-foreground font-mono text-xs mr-1.5">
#{selectedPage.id}
</span>
{selectedPage.caption} {selectedPage.caption}
</span> </span>
) : ( ) : (
@@ -182,7 +210,10 @@ export function MovePageCombobox({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start"> <PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command> <Command>
<CommandInput placeholder="Search pages..." /> <CommandInput placeholder="Search pages..." />
<CommandList> <CommandList>
@@ -200,9 +231,16 @@ export function MovePageCombobox({
setOpen(false); setOpen(false);
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} /> <Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" /> <Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span> <span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span> <span className="truncate">{page.caption}</span>
</CommandItem> </CommandItem>
))} ))}
@@ -210,7 +248,9 @@ export function MovePageCombobox({
)} )}
{/* All pages */} {/* All pages */}
<CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}> <CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined}
>
{remaining.map((page) => ( {remaining.map((page) => (
<CommandItem <CommandItem
key={page.id} key={page.id}
@@ -220,8 +260,15 @@ export function MovePageCombobox({
setOpen(false); setOpen(false);
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} /> <Check
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span> className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span> <span className="truncate">{page.caption}</span>
</CommandItem> </CommandItem>
))} ))}
@@ -3,7 +3,13 @@
import { Image as ImageIcon } from "lucide-react"; import { Image as ImageIcon } from "lucide-react";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname"; import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) { export function FurniIcon({
classname,
revision,
}: {
classname: string;
revision: number;
}) {
if (!classname) { if (!classname) {
return ( return (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center"> <div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
@@ -2,7 +2,13 @@
import { Loader2, Music } from "lucide-react"; import { Loader2, Music } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
getCachedSongPickerOptions, getCachedSongPickerOptions,
loadSongPickerOptions, loadSongPickerOptions,
@@ -73,9 +79,15 @@ export function SongPicker({ value, onChange, disabled }: Props) {
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Music className="h-3 w-3 text-muted-foreground" /> <Music className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{opt.name}</span> <span className="font-medium">{opt.name}</span>
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>} {opt.author && (
<span className="text-xs text-muted-foreground">
— {opt.author}
</span>
)}
{opt.length > 0 && ( {opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono">{formatLength(opt.length)}</span> <span className="text-xs text-muted-foreground font-mono">
{formatLength(opt.length)}
</span>
)} )}
</span> </span>
</SelectItem> </SelectItem>
@@ -81,5 +81,7 @@ export const DEFAULT_NEW_ITEM = {
}; };
export function getPointsLabel(type: number) { export function getPointsLabel(type: number) {
return POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }; return (
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
);
} }
@@ -32,13 +32,27 @@ import {
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts"; import {
CATALOG_LAYOUTS,
type CatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface CatalogPageData { interface CatalogPageData {
@@ -69,7 +83,12 @@ interface CatalogPageFormProps {
allPages: { id: number; caption: string }[]; allPages: { id: number; caption: string }[];
} }
export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }: CatalogPageFormProps) { export function CatalogPageForm({
catalogPage,
childPages,
canEdit,
allPages,
}: CatalogPageFormProps) {
const { isPending, run } = useServerAction(); const { isPending, run } = useServerAction();
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
@@ -106,7 +125,10 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]); }, [canEdit]);
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) { function update<K extends keyof typeof form>(
key: K,
value: (typeof form)[K],
) {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
} }
@@ -131,7 +153,8 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
}); });
} }
const layoutColorClass = LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground"; const layoutColorClass =
LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
return ( return (
<form ref={formRef} onSubmit={handleSubmit}> <form ref={formRef} onSubmit={handleSubmit}>
@@ -188,7 +211,8 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
disabled={!canEdit} disabled={!canEdit}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? "Custom layout"} {LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ??
"Custom layout"}
</p> </p>
</div> </div>
@@ -199,7 +223,9 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
id="orderNum" id="orderNum"
type="number" type="number"
value={form.orderNum} value={form.orderNum}
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)} onChange={(e) =>
update("orderNum", parseInt(e.target.value, 10) || 0)
}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -211,7 +237,9 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
min={1} min={1}
max={100} max={100}
value={form.minRank} value={form.minRank}
onChange={(e) => update("minRank", parseInt(e.target.value, 10) || 1)} onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -317,11 +345,14 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
min={0} min={0}
className="w-32" className="w-32"
value={form.iconColor} value={form.iconColor}
onChange={(e) => update("iconColor", parseInt(e.target.value, 10) || 0)} onChange={(e) =>
update("iconColor", parseInt(e.target.value, 10) || 0)
}
disabled={!canEdit} disabled={!canEdit}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Legacy tint value. Leave at default unless you need to match a specific theme. Legacy tint value. Leave at default unless you need to
match a specific theme.
</p> </p>
</div> </div>
</div> </div>
@@ -349,9 +380,13 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
> >
<span> <span>
{child.caption} {child.caption}
<span className="ml-2 text-muted-foreground font-mono text-xs">#{child.id}</span> <span className="ml-2 text-muted-foreground font-mono text-xs">
#{child.id}
</span> </span>
<Badge variant={child.enabled === "1" ? "default" : "outline"}> </span>
<Badge
variant={child.enabled === "1" ? "default" : "outline"}
>
{child.enabled === "1" ? "Enabled" : "Disabled"} {child.enabled === "1" ? "Enabled" : "Disabled"}
</Badge> </Badge>
</Link> </Link>
@@ -375,8 +410,15 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
<CatalogIcon iconImage={form.iconImage} size={36} /> <CatalogIcon iconImage={form.iconImage} size={36} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium">{form.caption || "Untitled"}</p> <p className="truncate text-sm font-medium">
<Badge className={cn("mt-1 h-5 px-1.5 text-[10px] font-mono border-0", layoutColorClass)}> {form.caption || "Untitled"}
</p>
<Badge
className={cn(
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
layoutColorClass,
)}
>
{form.pageLayout} {form.pageLayout}
</Badge> </Badge>
</div> </div>
@@ -443,7 +485,8 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
Delete Page Delete Page
</Button> </Button>
<p className="text-center text-[10px] text-muted-foreground"> <p className="text-center text-[10px] text-muted-foreground">
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to save Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
save
</p> </p>
</div> </div>
)} )}
@@ -480,7 +523,12 @@ function ToggleRow({
</Label> </Label>
<p className="text-[11px] text-muted-foreground">{description}</p> <p className="text-[11px] text-muted-foreground">{description}</p>
</div> </div>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} /> <Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</div> </div>
); );
} }
@@ -580,11 +628,17 @@ function LayoutSelect({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CATALOG_LAYOUTS.map((layout) => { {CATALOG_LAYOUTS.map((layout) => {
const colorClass = LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground"; const colorClass =
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
return ( return (
<SelectItem key={layout} value={layout}> <SelectItem key={layout} value={layout}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className={cn("inline-block h-2 w-2 rounded-full", colorClass)} /> <span
className={cn(
"inline-block h-2 w-2 rounded-full",
colorClass,
)}
/>
{layout} {layout}
</span> </span>
</SelectItem> </SelectItem>
@@ -613,7 +667,10 @@ function ParentCombobox({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const options = pages.filter((p) => p.id !== currentPageId); const options = pages.filter((p) => p.id !== currentPageId);
const selected = value === -1 ? { id: -1, caption: "Root" } : pages.find((p) => p.id === value); const selected =
value === -1
? { id: -1, caption: "Root" }
: pages.find((p) => p.id === value);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -639,7 +696,10 @@ function ParentCombobox({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start"> <PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command> <Command>
<CommandInput placeholder="Search pages..." /> <CommandInput placeholder="Search pages..." />
<CommandList> <CommandList>
@@ -652,8 +712,15 @@ function ParentCombobox({
setOpen(false); setOpen(false);
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === -1 ? "opacity-100" : "opacity-0")} /> <Check
<span className="text-muted-foreground font-mono text-xs mr-1.5">—</span> className={cn(
"mr-2 h-4 w-4",
value === -1 ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
—
</span>
Root Root
</CommandItem> </CommandItem>
{options.map((page) => ( {options.map((page) => (
@@ -665,8 +732,15 @@ function ParentCombobox({
setOpen(false); setOpen(false);
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === page.id ? "opacity-100" : "opacity-0")} /> <Check
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span> className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span> <span className="truncate">{page.caption}</span>
</CommandItem> </CommandItem>
))} ))}
@@ -21,8 +21,20 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { FurniIcon } from "./catalog-items-table"; import { FurniIcon } from "./catalog-items-table";
@@ -58,7 +70,10 @@ export function CatalogTranslateTab({
const [isSuggesting, setIsSuggesting] = useState(false); const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all"); const [filterMode, setFilterMode] = useState<FilterMode>("all");
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]); const furniDataIdSet = useMemo(
() => new Set(furniDataIdList),
[furniDataIdList],
);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
// ── Editable state: names + descriptions ───────────────────────── // ── Editable state: names + descriptions ─────────────────────────
@@ -67,7 +82,9 @@ export function CatalogTranslateTab({
); );
const [editedDescs, setEditedDescs] = useState<Record<number, string>>( const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])), Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
); );
// Track which items have name or description changes // Track which items have name or description changes
@@ -77,12 +94,14 @@ export function CatalogTranslateTab({
); );
const isDescChanged = useCallback( const isDescChanged = useCallback(
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""), (item: BaseItemForTranslation) =>
editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap], [editedDescs, furniDescriptionMap],
); );
const changedItems = useMemo( const changedItems = useMemo(
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)), () =>
baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged], [baseItems, isNameChanged, isDescChanged],
); );
@@ -96,7 +115,9 @@ export function CatalogTranslateTab({
case "missing": case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id)); return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case "modified": case "modified":
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)); return baseItems.filter(
(item) => isNameChanged(item) || isDescChanged(item),
);
default: default:
return baseItems; return baseItems;
} }
@@ -113,7 +134,9 @@ export function CatalogTranslateTab({
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
setSelected((prev) => setSelected((prev) =>
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)), prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
); );
}, [filteredItems]); }, [filteredItems]);
@@ -132,9 +155,13 @@ export function CatalogTranslateTab({
} }
function handleReset() { function handleReset() {
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName]))); setEditedNames(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
setEditedDescs( setEditedDescs(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])), Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
); );
} }
@@ -144,7 +171,9 @@ export function CatalogTranslateTab({
try { try {
// Collect classnames from selected items (or all if none selected) // Collect classnames from selected items (or all if none selected)
const classnames = targetItems.map((item) => item.itemName).filter(Boolean); const classnames = targetItems
.map((item) => item.itemName)
.filter(Boolean);
if (classnames.length === 0) { if (classnames.length === 0) {
toast.info("No classnames found to look up."); toast.info("No classnames found to look up.");
setIsSuggesting(false); setIsSuggesting(false);
@@ -153,7 +182,10 @@ export function CatalogTranslateTab({
// Batch in chunks of 50 to avoid too-long URLs // Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50; const CHUNK_SIZE = 50;
const allSuggestions: Record<string, { name: string; description: string }> = {}; const allSuggestions: Record<
string,
{ name: string; description: string }
> = {};
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) { for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
const chunk = classnames.slice(i, i + CHUNK_SIZE); const chunk = classnames.slice(i, i + CHUNK_SIZE);
@@ -196,7 +228,9 @@ export function CatalogTranslateTab({
const parts = []; const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`); if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`); if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`); toast.success(
`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`,
);
} else { } else {
toast.info( toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`, `HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
@@ -231,10 +265,13 @@ export function CatalogTranslateTab({
parts.push(`${d.namesUpdated} name(s) updated`); parts.push(`${d.namesUpdated} name(s) updated`);
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0) if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`); parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`); if (parts.length === 0)
parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push("FurnitureData.json synced"); if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) { if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`); parts.push(
`${d.furniDataInserted} new item(s) added to FurnitureData.json`,
);
} }
parts.push("emulator cache refreshed"); parts.push("emulator cache refreshed");
toast.success(`${parts.join(" · ")}.`); toast.success(`${parts.join(" · ")}.`);
@@ -250,7 +287,8 @@ export function CatalogTranslateTab({
<div className="min-w-0"> <div className="min-w-0">
<CardTitle>Translate Item Names</CardTitle> <CardTitle>Translate Item Names</CardTitle>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Edit the public name and description of furniture items. Saving will update: Edit the public name and description of furniture items. Saving will
update:
</p> </p>
<div className="flex gap-3 mt-2 flex-wrap"> <div className="flex gap-3 mt-2 flex-wrap">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
@@ -267,7 +305,8 @@ export function CatalogTranslateTab({
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"> <div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
<span> <span>
{missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save {missingItems.length} item(s) missing from FurnitureData.json —
will be auto-inserted on save
</span> </span>
</div> </div>
)} )}
@@ -297,7 +336,11 @@ export function CatalogTranslateTab({
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Reset Reset
</Button> </Button>
<Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}> <Button
size="sm"
onClick={handleSaveAll}
disabled={isPending || changedItems.length === 0}
>
{isPending ? ( {isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
@@ -350,7 +393,9 @@ export function CatalogTranslateTab({
No items to translate in this page. No items to translate in this page.
</p> </p>
) : filteredItems.length === 0 ? ( ) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No items match the current filter.</p> <p className="text-sm text-muted-foreground text-center py-8">
No items match the current filter.
</p>
) : ( ) : (
<TooltipProvider> <TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]"> <div className="rounded-md border overflow-auto max-h-[600px]">
@@ -359,7 +404,10 @@ export function CatalogTranslateTab({
<TableRow> <TableRow>
<TableHead className="w-[40px] px-2"> <TableHead className="w-[40px] px-2">
<Checkbox <Checkbox
checked={filteredItems.length > 0 && selected.size === filteredItems.length} checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
aria-label="Select all" aria-label="Select all"
/> />
@@ -402,16 +450,33 @@ export function CatalogTranslateTab({
{(() => { {(() => {
const revInfo = furniRevisionMap[item.id]; const revInfo = furniRevisionMap[item.id];
return revInfo ? ( return revInfo ? (
<FurniIcon classname={revInfo.classname} revision={revInfo.revision} /> <FurniIcon
classname={revInfo.classname}
revision={revInfo.revision}
/>
) : ( ) : (
<FurniIcon classname={item.itemName} revision={0} /> <FurniIcon
classname={item.itemName}
revision={0}
/>
); );
})()} })()}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell> <TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell> <TableCell>
<Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs"> <Badge
{item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type} variant={
item.type === "s" ? "default" : "secondary"
}
className="text-xs"
>
{item.type === "s"
? "Floor"
: item.type === "i"
? "Wall"
: item.type}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground font-mono"> <TableCell className="text-xs text-muted-foreground font-mono">
@@ -420,11 +485,14 @@ export function CatalogTranslateTab({
<TableCell> <TableCell>
<Input <Input
value={editedNames[item.id] || ""} value={editedNames[item.id] || ""}
onChange={(e) => handleNameChange(item.id, e.target.value)} onChange={(e) =>
handleNameChange(item.id, e.target.value)
}
disabled={!canEdit} disabled={!canEdit}
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`} className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/> />
{currentCatalogName && currentCatalogName !== item.publicName && ( {currentCatalogName &&
currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate"> <p className="text-[11px] text-muted-foreground mt-0.5 truncate">
catalog: {currentCatalogName} catalog: {currentCatalogName}
</p> </p>
@@ -433,7 +501,9 @@ export function CatalogTranslateTab({
<TableCell> <TableCell>
<Input <Input
value={editedDescs[item.id] || ""} value={editedDescs[item.id] || ""}
onChange={(e) => handleDescChange(item.id, e.target.value)} onChange={(e) =>
handleDescChange(item.id, e.target.value)
}
disabled={!canEdit} disabled={!canEdit}
placeholder="No description" placeholder="No description"
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`} className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
@@ -449,7 +519,9 @@ export function CatalogTranslateTab({
Modified Modified
</Badge> </Badge>
) : ( ) : (
<span className="text-xs text-muted-foreground">—</span> <span className="text-xs text-muted-foreground">
—
</span>
)} )}
{isMissing ? ( {isMissing ? (
<Tooltip> <Tooltip>
@@ -468,7 +540,9 @@ export function CatalogTranslateTab({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" /> <CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>In FurnitureData.json</TooltipContent> <TooltipContent>
In FurnitureData.json
</TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
+14 -4
View File
@@ -55,26 +55,36 @@ export default async function CatalogEditPage({
<div className="space-y-6"> <div className="space-y-6">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground"> <nav className="flex items-center gap-1 text-sm text-muted-foreground">
<Link href={"/admin/catalog"} className="hover:text-foreground transition-colors"> <Link
href={"/admin/catalog"}
className="hover:text-foreground transition-colors"
>
Catalog Catalog
</Link> </Link>
{ancestors.map((bc) => ( {ancestors.map((bc) => (
<Fragment key={bc.id}> <Fragment key={bc.id}>
<ChevronRight className="h-3 w-3 flex-shrink-0" /> <ChevronRight className="h-3 w-3 flex-shrink-0" />
<Link href={`/admin/catalog/${bc.id}`} className="hover:text-foreground transition-colors"> <Link
href={`/admin/catalog/${bc.id}`}
className="hover:text-foreground transition-colors"
>
{bc.caption} {bc.caption}
</Link> </Link>
</Fragment> </Fragment>
))} ))}
<ChevronRight className="h-3 w-3 flex-shrink-0" /> <ChevronRight className="h-3 w-3 flex-shrink-0" />
<span className="text-foreground font-medium">{catalogPage.caption}</span> <span className="text-foreground font-medium">
{catalogPage.caption}
</span>
</nav> </nav>
{/* Title */} {/* Title */}
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">
{catalogPage.caption} {catalogPage.caption}
<span className="ml-2 text-base font-normal text-muted-foreground">#{catalogPage.id}</span> <span className="ml-2 text-base font-normal text-muted-foreground">
#{catalogPage.id}
</span>
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items
+108 -24
View File
@@ -1,17 +1,44 @@
"use client"; "use client";
import { ArrowLeft, HardHat, Loader2, Package, Pencil, Plus, Save, Trash2 } from "lucide-react"; import {
ArrowLeft,
HardHat,
Loader2,
Package,
Pencil,
Plus,
Save,
Trash2,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createBcItem, deleteBcItem, updateBcItem, updateBcPage } from "@/actions/catalog-bc"; import {
createBcItem,
deleteBcItem,
updateBcItem,
updateBcPage,
} from "@/actions/catalog-bc";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
@@ -86,7 +113,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]); }, [canEdit]);
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) { function update<K extends keyof typeof form>(
key: K,
value: (typeof form)[K],
) {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
} }
@@ -147,7 +177,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
id="orderNum" id="orderNum"
type="number" type="number"
value={form.orderNum} value={form.orderNum}
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)} onChange={(e) =>
update("orderNum", parseInt(e.target.value, 10) || 0)
}
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
@@ -198,7 +230,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<Label>Details</Label> <Label>Details</Label>
<Textarea <Textarea
value={form.pageTextDetails} value={form.pageTextDetails}
onChange={(e) => update("pageTextDetails", e.target.value)} onChange={(e) =>
update("pageTextDetails", e.target.value)
}
disabled={!canEdit} disabled={!canEdit}
rows={3} rows={3}
/> />
@@ -250,7 +284,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No items in this page.</p> <p className="text-sm text-muted-foreground text-center py-4">
No items in this page.
</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -260,18 +296,28 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
<TableHead className="text-xs">Item IDs</TableHead> <TableHead className="text-xs">Item IDs</TableHead>
<TableHead className="text-xs">Order</TableHead> <TableHead className="text-xs">Order</TableHead>
<TableHead className="text-xs">Extra Data</TableHead> <TableHead className="text-xs">Extra Data</TableHead>
{canEdit && <TableHead className="text-xs w-20">Actions</TableHead>} {canEdit && (
<TableHead className="text-xs w-20">
Actions
</TableHead>
)}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((item) => ( {items.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="text-xs font-mono">{item.id}</TableCell> <TableCell className="text-xs font-mono">
<TableCell className="text-xs font-medium">{item.catalogName}</TableCell> {item.id}
</TableCell>
<TableCell className="text-xs font-medium">
{item.catalogName}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground"> <TableCell className="text-xs font-mono text-muted-foreground">
{item.itemIds} {item.itemIds}
</TableCell> </TableCell>
<TableCell className="text-xs">{item.orderNumber}</TableCell> <TableCell className="text-xs">
{item.orderNumber}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-32 truncate"> <TableCell className="text-xs text-muted-foreground max-w-32 truncate">
{item.extradata || "\u2014"} {item.extradata || "\u2014"}
</TableCell> </TableCell>
@@ -293,7 +339,12 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
size="sm" size="sm"
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]" className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
onClick={() => { onClick={() => {
if (!confirm(`Delete item "${item.catalogName}"?`)) return; if (
!confirm(
`Delete item "${item.catalogName}"?`,
)
)
return;
run(() => deleteBcItem({ id: item.id }), { run(() => deleteBcItem({ id: item.id }), {
successMessage: "Item deleted.", successMessage: "Item deleted.",
}); });
@@ -346,14 +397,20 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
</span> </span>
<span> <span>
Parent:{" "} Parent:{" "}
<span className="font-mono">{page.parentId <= 0 ? "Root" : `#${page.parentId}`}</span> <span className="font-mono">
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
</span>
</span> </span>
</div> </div>
{/* Actions */} {/* Actions */}
{canEdit && ( {canEdit && (
<div className="space-y-2 border-t pt-3"> <div className="space-y-2 border-t pt-3">
<Button type="submit" disabled={isPending} className="w-full"> <Button
type="submit"
disabled={isPending}
className="w-full"
>
{isPending ? ( {isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
@@ -362,7 +419,8 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
Save & Sync Save & Sync
</Button> </Button>
<p className="text-center text-[10px] text-muted-foreground"> <p className="text-center text-[10px] text-muted-foreground">
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to save Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
save
</p> </p>
</div> </div>
)} )}
@@ -439,7 +497,12 @@ function ToggleRow({
</Label> </Label>
<p className="text-[11px] text-muted-foreground">{description}</p> <p className="text-[11px] text-muted-foreground">{description}</p>
</div> </div>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} /> <Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</div> </div>
); );
} }
@@ -457,7 +520,12 @@ function ItemDialog({
mode: "add" | "edit"; mode: "add" | "edit";
isPending: boolean; isPending: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (data: { itemIds: string; catalogName: string; orderNumber: number; extradata: string }) => void; onSubmit: (data: {
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}) => void;
}) { }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
itemIds: item.itemIds, itemIds: item.itemIds,
@@ -470,21 +538,27 @@ function ItemDialog({
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}</DialogTitle> <DialogTitle>
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Catalog Name</Label> <Label>Catalog Name</Label>
<Input <Input
value={form.catalogName} value={form.catalogName}
onChange={(e) => setForm((f) => ({ ...f, catalogName: e.target.value }))} onChange={(e) =>
setForm((f) => ({ ...f, catalogName: e.target.value }))
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label> <Label>Item IDs (semicolon-separated)</Label>
<Input <Input
value={form.itemIds} value={form.itemIds}
onChange={(e) => setForm((f) => ({ ...f, itemIds: e.target.value }))} onChange={(e) =>
setForm((f) => ({ ...f, itemIds: e.target.value }))
}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -493,14 +567,21 @@ function ItemDialog({
<Input <Input
type="number" type="number"
value={form.orderNumber} value={form.orderNumber}
onChange={(e) => setForm((f) => ({ ...f, orderNumber: parseInt(e.target.value, 10) || 0 }))} onChange={(e) =>
setForm((f) => ({
...f,
orderNumber: parseInt(e.target.value, 10) || 0,
}))
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Extra Data</Label> <Label>Extra Data</Label>
<Input <Input
value={form.extradata} value={form.extradata}
onChange={(e) => setForm((f) => ({ ...f, extradata: e.target.value }))} onChange={(e) =>
setForm((f) => ({ ...f, extradata: e.target.value }))
}
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
@@ -510,7 +591,10 @@ function ItemDialog({
<Button variant="outline" onClick={onClose} disabled={isPending}> <Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel Cancel
</Button> </Button>
<Button onClick={() => onSubmit(form)} disabled={isPending || !form.catalogName || !form.itemIds}> <Button
onClick={() => onSubmit(form)}
disabled={isPending || !form.catalogName || !form.itemIds}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === "add" ? "Add Item" : "Save"} {mode === "add" ? "Add Item" : "Save"}
</Button> </Button>
+28 -6
View File
@@ -6,10 +6,22 @@ import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog"; import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc"; import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts"; import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
@@ -17,7 +29,9 @@ interface CatalogActionsProps {
catalogType?: "normal" | "bc"; catalogType?: "normal" | "bc";
} }
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) { export function CatalogActions({
catalogType = "normal",
}: CatalogActionsProps) {
const { run, isPending } = useServerAction(); const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [caption, setCaption] = useState(""); const [caption, setCaption] = useState("");
@@ -37,7 +51,9 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
pageLayout, pageLayout,
}; };
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input); const fn = isBc
? () => createBcPage(input)
: () => createCatalogPage(input);
run(fn, { run(fn, {
successMessage: `Catalog page "${caption.trim()}" created.`, successMessage: `Catalog page "${caption.trim()}" created.`,
@@ -64,7 +80,9 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{isBc ? "Create Builder Club Page" : "Create Catalog Page"}</DialogTitle> <DialogTitle>
{isBc ? "Create Builder Club Page" : "Create Catalog Page"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -101,7 +119,11 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
</Select> </Select>
</div> </div>
<Button onClick={handleCreate} disabled={isPending} className="w-full"> <Button
onClick={handleCreate}
disabled={isPending}
className="w-full"
>
{isPending ? ( {isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
Loaded 100 of 604 files, more files were not shown because too many files have changed in this diff. Show more