This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
604 files changed
+19636
-12185
No files matched your search
+34
@@ -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
@@ -1,10 +1,10 @@
|
||||
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 security from "eslint-plugin-security";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
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(
|
||||
js.configs.recommended,
|
||||
@@ -25,7 +25,10 @@ export default tseslint.config(
|
||||
|
||||
"no-console": "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/consistent-type-imports": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
|
||||
+16
-3
@@ -3,7 +3,10 @@ import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const securityHeaders = [
|
||||
{ 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-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
@@ -30,7 +33,12 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
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: true,
|
||||
@@ -52,7 +60,12 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
source: "/assets/(.*)",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/images/(.*)",
|
||||
|
||||
+8
-12
@@ -12,8 +12,9 @@
|
||||
"start": "next start",
|
||||
"prisma:generate": "prisma generate",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 200",
|
||||
"format": "prettier --write .",
|
||||
"biome:check": "biome check --write .",
|
||||
"biome:lint": "biome lint .",
|
||||
"biome:format": "biome format --write .",
|
||||
"test": "vitest run",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
@@ -57,8 +58,7 @@
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@next/eslint-plugin-next": "^16.2.10",
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
@@ -70,18 +70,11 @@
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@vitalets/google-translate-api": "^9.2.1",
|
||||
"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",
|
||||
"prettier": "^3.9.5",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tsx": "^4.22.5",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -89,7 +82,10 @@
|
||||
"esbuild",
|
||||
"prisma",
|
||||
"@prisma/client",
|
||||
"@prisma/engines"
|
||||
"@prisma/engines",
|
||||
"sharp",
|
||||
"@parcel/watcher",
|
||||
"@swc/core"
|
||||
],
|
||||
"overrides": {
|
||||
"fast-uri": "^3.1.3",
|
||||
|
||||
Generated
+2862
-7547
File diff suppressed because it is too large.
Load diff
+17
-4
@@ -33,13 +33,23 @@
|
||||
"get": {
|
||||
"summary": "List published articles",
|
||||
"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": {
|
||||
"200": {
|
||||
"description": "Article list",
|
||||
"content": { "application/json": { "schema": { "type": "object" } } }
|
||||
"content": {
|
||||
"application/json": { "schema": { "type": "object" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +62,10 @@
|
||||
"description": "Online count",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "object", "properties": { "count": { "type": "integer" } } }
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": { "count": { "type": "integer" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
(function () {
|
||||
(() => {
|
||||
try {
|
||||
var s = localStorage.getItem("theme");
|
||||
var dd = document.querySelector('meta[name="theme-default-dark"]');
|
||||
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"),
|
||||
nt = localStorage.getItem("navbarTextColor");
|
||||
if (nc) document.documentElement.style.setProperty("--color-navbar", nc);
|
||||
if (nt) document.documentElement.style.setProperty("--color-navbar-text", nt);
|
||||
} catch (e) {}
|
||||
if (nt)
|
||||
document.documentElement.style.setProperty("--color-navbar-text", nt);
|
||||
} catch (_e) {}
|
||||
})();
|
||||
+9
-2
@@ -11,7 +11,11 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.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()),
|
||||
);
|
||||
});
|
||||
@@ -23,7 +27,10 @@ self.addEventListener("fetch", (event) => {
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// 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(
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.match(req).then(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import "dotenv/config";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
import { mysqlConnectionUrl } from "./db-url";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
|
||||
@@ -19,7 +19,8 @@ function getDbConfig(): { url: string; database: string } {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is required");
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -45,7 +46,9 @@ async function getApplied(): Promise<Set<string>> {
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
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));
|
||||
} catch {
|
||||
return new Set();
|
||||
@@ -78,7 +81,10 @@ async function apply(migration: MigrationFile): Promise<void> {
|
||||
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}`);
|
||||
} finally {
|
||||
await conn.end();
|
||||
@@ -96,7 +102,9 @@ async function main() {
|
||||
console.log("\nMigration status:\n");
|
||||
for (const m of all) {
|
||||
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));
|
||||
|
||||
@@ -7,6 +7,7 @@ if (missing.length === 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).`);
|
||||
process.exitCode = 1;
|
||||
+6
-2
@@ -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 {
|
||||
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();
|
||||
}
|
||||
+20
-6
@@ -5,11 +5,15 @@ import { prisma } from "../src/lib/prisma";
|
||||
async function backupEmulatorJar(): Promise<void> {
|
||||
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 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)) {
|
||||
mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true });
|
||||
@@ -38,7 +42,9 @@ async function backupEmulatorJar(): Promise<void> {
|
||||
async function cleanupOldLogs(): Promise<void> {
|
||||
try {
|
||||
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");
|
||||
} catch (err) {
|
||||
console.error("[jobs] Log cleanup failed:", err);
|
||||
@@ -48,7 +54,9 @@ async function cleanupOldLogs(): Promise<void> {
|
||||
async function cleanupOldSessions(): Promise<void> {
|
||||
try {
|
||||
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");
|
||||
} catch (err) {
|
||||
console.error("[jobs] Session cleanup failed:", err);
|
||||
@@ -61,7 +69,9 @@ async function main() {
|
||||
// JAR backup — daily at 03:00
|
||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||
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)");
|
||||
}
|
||||
@@ -75,7 +85,11 @@ async function main() {
|
||||
console.log("[jobs] Scheduled: old data cleanup (daily 04:00)");
|
||||
|
||||
// Run once on startup
|
||||
await Promise.all([backupEmulatorJar(), cleanupOldLogs(), cleanupOldSessions()]);
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
cleanupOldSessions(),
|
||||
]);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
||||
*/
|
||||
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";
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -48,7 +55,8 @@ function encryptGcm(plaintext: string, key: Buffer, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(plaintext) : plaintext;
|
||||
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 ivB64 = iv.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. */
|
||||
function isAlreadyGcm(payload: string, key: Buffer): boolean {
|
||||
function isAlreadyGcm(payload: string, _key: Buffer): boolean {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("radio columns migration", () => {
|
||||
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) ?? [];
|
||||
expect(additions).toEqual([]);
|
||||
expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
.split(";")
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
// table; it only stores an image URL rendered in the site layout/widgets.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
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++}`;
|
||||
}
|
||||
return slug;
|
||||
@@ -49,7 +53,9 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
});
|
||||
} catch {
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import path from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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
|
||||
// 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 {
|
||||
// Unique name collision or DB error — re-render unchanged with error.
|
||||
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");
|
||||
redirect("/admin/help-questions");
|
||||
|
||||
@@ -44,7 +44,9 @@ export async function deletePermission(formData: FormData): Promise<void> {
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } });
|
||||
await prisma.websiteHousekeepingPermissions.delete({
|
||||
where: { id: BigInt(raw) },
|
||||
});
|
||||
} catch {
|
||||
// Already gone / invalid id.
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ const KEY_MIN_RANK = "min_maintenance_login_rank";
|
||||
|
||||
const COMMENTS: Record<string, string> = {
|
||||
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
|
||||
[KEY_MESSAGE]: "The maintenance message displayed to users while maintenance is activated",
|
||||
[KEY_MIN_RANK]: "The minimum rank required to login to the hotel during maintenance",
|
||||
[KEY_MESSAGE]:
|
||||
"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> {
|
||||
@@ -47,7 +49,8 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
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_MESSAGE, message);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
|
||||
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> {
|
||||
await requireStaff();
|
||||
const { unlink } = await import("fs/promises");
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||
@@ -47,7 +47,9 @@ export async function deleteMedia(name: string): Promise<void> {
|
||||
revalidatePath("/admin/media");
|
||||
}
|
||||
|
||||
export async function uploadMediaAndReturn(formData: FormData): Promise<string> {
|
||||
export async function uploadMediaAndReturn(
|
||||
formData: FormData,
|
||||
): Promise<string> {
|
||||
await requireStaff();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return "";
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// website_permissions (model WebsitePermissions) is the CMS-owned permission ->
|
||||
// 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({
|
||||
where: { permission },
|
||||
update: { minRank, description, updatedAt: now },
|
||||
create: { permission, minRank, description, createdAt: now, updatedAt: now },
|
||||
create: {
|
||||
permission,
|
||||
minRank,
|
||||
description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -49,7 +55,10 @@ export async function createPermission(formData: FormData): Promise<void> {
|
||||
targetType: "permission",
|
||||
});
|
||||
} 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");
|
||||
// ignore (e.g. constraint failure) — page re-renders current state
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
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)).
|
||||
const key = randomBytes(24).toString("hex");
|
||||
|
||||
@@ -97,7 +97,9 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
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 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 description = str(formData.get("description")).trim();
|
||||
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"));
|
||||
if (!imagePath) return;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
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. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Radio listener-points settings (website_settings radio_points_* keys).
|
||||
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
|
||||
@@ -21,7 +21,12 @@ const POINTS_KEYS = [
|
||||
"radio_points_min_listeners",
|
||||
] 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 {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
@@ -43,15 +48,21 @@ function intStr(raw: FormDataEntryValue | null): string {
|
||||
export async function savePoints(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase();
|
||||
const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits";
|
||||
const currencyRaw = str(formData.get("radio_points_currency"))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
|
||||
? currencyRaw
|
||||
: "credits";
|
||||
|
||||
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||
radio_points_currency: currency,
|
||||
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 {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
@@ -16,7 +16,10 @@ export async function createCategory(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -60,7 +63,8 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
if (!name || !furnitureIcon) return;
|
||||
|
||||
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") ?? "")
|
||||
.normalize("NFC")
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Website store packages (website_shop_articles). This CMS-owned table backs
|
||||
// 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),
|
||||
});
|
||||
} 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.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
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 {
|
||||
deleteCustomThemeStore,
|
||||
getCustomTheme,
|
||||
snapshotCurrentTheme,
|
||||
upsertCustomTheme,
|
||||
} 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).
|
||||
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");
|
||||
|
||||
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);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -7,7 +7,12 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
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> {
|
||||
const staff = await requireStaffRateLimited();
|
||||
@@ -15,7 +20,12 @@ export async function giveCurrency(formData: FormData): Promise<void> {
|
||||
const type = String(formData.get("type"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
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({
|
||||
staffId: staff.id,
|
||||
action: "give_currency",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
export async function createVoucher(formData: FormData): Promise<void> {
|
||||
@@ -15,7 +15,8 @@ export async function createVoucher(formData: FormData): Promise<void> {
|
||||
.slice(0, 255);
|
||||
const amount = Number(formData.get("amount"));
|
||||
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;
|
||||
|
||||
@@ -60,7 +61,9 @@ export async function deleteVoucher(formData: FormData): Promise<void> {
|
||||
try {
|
||||
await prisma.websiteShopVouchers.delete({ where: { id } });
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_delete_failed", error, { voucherId: String(id) });
|
||||
logServerError("admin.voucher_delete_failed", error, {
|
||||
voucherId: String(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
||||
|
||||
/** 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({
|
||||
where: { key },
|
||||
update: { value },
|
||||
@@ -58,7 +62,11 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
||||
provider,
|
||||
"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(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
|
||||
@@ -60,7 +60,8 @@ export async function createBox(formData: FormData): Promise<void> {
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
isActive:
|
||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
@@ -104,7 +105,8 @@ export async function updateBox(formData: FormData): Promise<void> {
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
isActive:
|
||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { env } from "@/env";
|
||||
|
||||
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
|
||||
* 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 ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const p = String(password ?? "");
|
||||
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;
|
||||
try {
|
||||
@@ -31,9 +35,13 @@ export async function precheckLogin(username: string, password: string): Promise
|
||||
}
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
await checkLogin(p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", {
|
||||
await checkLogin(
|
||||
p,
|
||||
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
|
||||
{
|
||||
convertPasswords: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -11,10 +11,21 @@ export async function getBadgeData({ code }: { code: string }) {
|
||||
select: { badgeName: true, badgeDescription: true },
|
||||
});
|
||||
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 prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
|
||||
@@ -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(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||
|
||||
+33
-10
@@ -1,11 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export async function bulkUnban({
|
||||
userIds,
|
||||
@@ -13,14 +12,19 @@ export async function bulkUnban({
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
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({
|
||||
staffId: staff.id,
|
||||
action: "bulk_unban",
|
||||
description: `Unbanned ${result.count} user(s)`,
|
||||
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({
|
||||
@@ -74,7 +78,11 @@ export async function bulkGiveCurrency({
|
||||
amount: number;
|
||||
type: "credits" | "pixels" | "points";
|
||||
}): 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();
|
||||
let given = 0;
|
||||
@@ -83,7 +91,10 @@ export async function bulkGiveCurrency({
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
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);
|
||||
} else if (type === "pixels") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
@@ -112,7 +123,10 @@ export async function bulkGiveCurrency({
|
||||
description: `Gave ${amount} ${type} to ${given} user(s)`,
|
||||
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({
|
||||
@@ -122,7 +136,11 @@ export async function bulkGiveBadge({
|
||||
userIds: number[];
|
||||
badgeCode: string;
|
||||
}): 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();
|
||||
let given = 0;
|
||||
@@ -140,7 +158,9 @@ export async function bulkGiveBadge({
|
||||
_max: { slotId: true },
|
||||
});
|
||||
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);
|
||||
}
|
||||
given++;
|
||||
@@ -155,5 +175,8 @@ export async function bulkGiveBadge({
|
||||
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { given, total: userIds.length, failedIds },
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
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();
|
||||
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
@@ -79,7 +82,13 @@ export async function createBcItem({
|
||||
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();
|
||||
const page = await prisma.catalogPagesBc.findUnique({
|
||||
where: { id },
|
||||
@@ -93,7 +102,11 @@ export async function toggleBcPage({ id, field }: { id: number; field: "enabled"
|
||||
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 created = await prisma.catalogPagesBc.create({
|
||||
data: {
|
||||
|
||||
@@ -51,7 +51,13 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||
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 prisma.catalogItems.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
@@ -86,9 +92,15 @@ export async function updateCatalogItem({
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
}) {
|
||||
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) {
|
||||
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 logStaffActivity({
|
||||
@@ -111,7 +123,7 @@ export async function translateCatalogItems({
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
let furniDataUpdated = 0;
|
||||
let furniDataInserted = 0;
|
||||
const _furniDataInserted = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const existing = await prisma.catalogItems.findUnique({
|
||||
|
||||
+13
-4
@@ -3,9 +3,9 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
@@ -105,13 +105,19 @@ export async function createCatalogPage(input: {
|
||||
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 prisma.catalogPages.update({
|
||||
where: { id: input.pageId },
|
||||
data: {
|
||||
orderNum: input.newOrderNum,
|
||||
...(input.newParentId === undefined ? {} : { parentId: input.newParentId }),
|
||||
...(input.newParentId === undefined
|
||||
? {}
|
||||
: { parentId: input.newParentId }),
|
||||
},
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
@@ -119,7 +125,10 @@ export async function reorderTreePage(input: { pageId: number; newParentId?: num
|
||||
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();
|
||||
if (input.mode === "cascade") {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
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
|
||||
@@ -64,7 +64,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
});
|
||||
|
||||
if (!badge || !badge.published) {
|
||||
if (!badge?.published) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const code = badgeCodeFromPath(badge.badgePath);
|
||||
|
||||
@@ -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. */
|
||||
function verifySecret(): string {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Compute the verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
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`.
|
||||
* 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;
|
||||
const expected = await verificationToken(email);
|
||||
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 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 = `
|
||||
<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 {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
+14
-4
@@ -41,7 +41,9 @@ export const updateEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||
async (ctx) => {
|
||||
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");
|
||||
|
||||
await prisma.websiteEventType.update({ where: { id }, data });
|
||||
@@ -64,7 +66,9 @@ const deleteEventTypeInput = z.object({
|
||||
export const deleteEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||
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");
|
||||
|
||||
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
|
||||
@@ -132,7 +136,9 @@ const deleteEventInput = z.object({
|
||||
export const deleteEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||
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");
|
||||
|
||||
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
|
||||
@@ -178,7 +184,11 @@ export const addEventWinner = adminAction(
|
||||
action: "event_winner_add",
|
||||
target: "WebsiteEventWinner",
|
||||
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 });
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
|
||||
@@ -16,7 +16,11 @@ export async function importBadgeFromRemote({
|
||||
try {
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
|
||||
update: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
|
||||
@@ -4,17 +4,28 @@ import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
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();
|
||||
return actionOk(result as unknown as Record<string, unknown>);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
||||
|
||||
export const deleteImportedFurni = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||
async (ctx) =>
|
||||
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
|
||||
actionOk(
|
||||
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
),
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
const session = await auth();
|
||||
@@ -17,7 +17,12 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
|
||||
try {
|
||||
const existing = await prisma.socialAccounts.findUnique({
|
||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: discordIdClean,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (existing && Number(existing.userId) !== userId) {
|
||||
@@ -29,7 +34,12 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
|
||||
try {
|
||||
await prisma.socialAccounts.upsert({
|
||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: discordIdClean,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: BigInt(userId),
|
||||
provider: "discord",
|
||||
@@ -47,7 +57,10 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
|
||||
return null;
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
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 { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -15,7 +15,9 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
||||
export const assignCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
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);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
@@ -42,7 +44,9 @@ const cfhStateSchema = z.object({
|
||||
export const updateCfhState = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||
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);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
|
||||
@@ -7,10 +7,21 @@ export interface MultiAccountCluster {
|
||||
key: string;
|
||||
label: string;
|
||||
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();
|
||||
const clusters: MultiAccountCluster[] = [];
|
||||
|
||||
@@ -33,7 +44,12 @@ export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts:
|
||||
key: group.ipCurrent,
|
||||
label: `IP: ${group.ipCurrent}`,
|
||||
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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } =
|
||||
vi.hoisted(() => ({
|
||||
const {
|
||||
mockFindFirst,
|
||||
mockUpsert,
|
||||
mockFindUnique,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockSendMail,
|
||||
mockRedirect,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindFirst: vi.fn(),
|
||||
mockUpsert: vi.fn(),
|
||||
mockFindUnique: vi.fn(),
|
||||
@@ -21,7 +28,11 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: mockFindFirst, update: mockUpdate },
|
||||
passwordReset: { upsert: mockUpsert, findUnique: mockFindUnique, delete: mockDelete },
|
||||
passwordReset: {
|
||||
upsert: mockUpsert,
|
||||
findUnique: mockFindUnique,
|
||||
delete: mockDelete,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
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 { 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
|
||||
|
||||
@@ -21,12 +21,16 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
.toLowerCase();
|
||||
|
||||
// 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.
|
||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
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) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
await prisma.passwordReset.upsert({
|
||||
@@ -72,15 +76,21 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
if (!error) {
|
||||
try {
|
||||
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 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) {
|
||||
error = "This reset link is invalid or has expired";
|
||||
} 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) {
|
||||
error = "Account not found";
|
||||
} else {
|
||||
@@ -88,7 +98,9 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
where: { id: user.id },
|
||||
data: { password: await hashPassword(password) },
|
||||
});
|
||||
await prisma.passwordReset.delete({ where: { email } }).catch(() => {});
|
||||
await prisma.passwordReset
|
||||
.delete({ where: { email } })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -6,7 +6,11 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
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 { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -47,12 +51,17 @@ export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } });
|
||||
if (users > 0)
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
});
|
||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||
if (role) {
|
||||
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.aclRole.delete({ where: { id: role.id } }),
|
||||
]);
|
||||
@@ -116,7 +125,9 @@ export const setCmsPermissions = adminAction(
|
||||
select: { id: true },
|
||||
});
|
||||
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) {
|
||||
await tx.aclModelPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
|
||||
+11
-3
@@ -6,7 +6,11 @@ import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionSchema,
|
||||
updatePollSchema,
|
||||
} from "@/lib/validators/poll";
|
||||
|
||||
// ── Polls ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,7 +60,9 @@ const deletePollInput = z.object({
|
||||
export const deletePoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
||||
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");
|
||||
|
||||
await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
|
||||
@@ -76,7 +82,9 @@ export const deletePoll = adminAction(
|
||||
export const addPollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
||||
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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
|
||||
const shoutSchema = z.object({
|
||||
message: z.string().min(1, "Message is required").max(255),
|
||||
|
||||
+28
-6
@@ -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
|
||||
* 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> {
|
||||
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error";
|
||||
let outcome:
|
||||
| "claimed"
|
||||
| "not_enough"
|
||||
| "no_referrals"
|
||||
| "bad_config"
|
||||
| "error" = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
@@ -47,16 +57,26 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } })
|
||||
.findUnique({
|
||||
where: { key: "referrals_needed" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } })
|
||||
.findUnique({
|
||||
where: { key: "referral_reward_amount" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } },
|
||||
where: {
|
||||
key: {
|
||||
in: ["referral_reward_currency_type", "referral_reward_currency"],
|
||||
},
|
||||
},
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
@@ -64,7 +84,9 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
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).
|
||||
const referrals = await prisma.userReferrals
|
||||
|
||||
+16
-5
@@ -16,7 +16,11 @@ const registerSchema = z.object({
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(25, "Username must be at most 25 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
|
||||
.string()
|
||||
.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.
|
||||
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 = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -63,7 +70,8 @@ export async function register(prevState: string | null, formData: FormData): Pr
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider !== "none") {
|
||||
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).
|
||||
@@ -77,8 +85,11 @@ export async function register(prevState: string | null, formData: FormData): Pr
|
||||
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
||||
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
|
||||
if (max > 0) {
|
||||
const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0);
|
||||
if (count >= max) return "You have reached the maximum number of accounts for your connection.";
|
||||
const count = await prisma.user
|
||||
.count({ where: { ipRegister: ip } })
|
||||
.catch(() => 0);
|
||||
if (count >= max)
|
||||
return "You have reached the maximum number of accounts for your connection.";
|
||||
}
|
||||
|
||||
// Uniqueness check.
|
||||
|
||||
+26
-4
@@ -8,7 +8,11 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
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 logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -20,7 +24,13 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
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();
|
||||
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } });
|
||||
await logStaffActivity({
|
||||
@@ -32,7 +42,13 @@ export async function bulkDeleteRoomItems({ roomId, itemIds }: { roomId: number;
|
||||
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();
|
||||
await prisma.items.delete({ where: { id: itemId } });
|
||||
await logStaffActivity({
|
||||
@@ -45,7 +61,13 @@ export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemI
|
||||
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();
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
|
||||
+35
-12
@@ -1,24 +1,36 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { mkdir, writeFile, unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const FAVICON_DIR = "public/assets/images/media/favicon";
|
||||
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(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!file || file.size === 0)
|
||||
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))
|
||||
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> = {
|
||||
"image/png": "png",
|
||||
@@ -46,7 +58,7 @@ export async function saveFavicon(
|
||||
|
||||
// Remove old favicon file if it exists
|
||||
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/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
@@ -73,14 +85,20 @@ export async function saveFavicon(
|
||||
|
||||
return { success: true, url };
|
||||
} 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 {
|
||||
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 oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
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();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -51,7 +51,10 @@ export async function saveLogo(
|
||||
|
||||
return { success: true, url };
|
||||
} 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 }> {
|
||||
try {
|
||||
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 buffer = Buffer.from(await res.arrayBuffer());
|
||||
@@ -100,6 +104,9 @@ export async function saveLogoFromUrl(
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||
async (ctx) => {
|
||||
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");
|
||||
await prisma.websiteTicketTemplate.update({ where: { id }, data });
|
||||
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(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||
|
||||
+16
-7
@@ -16,7 +16,9 @@ import {
|
||||
|
||||
// ── 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({
|
||||
data: {
|
||||
subject: ctx.data.subject,
|
||||
@@ -43,15 +45,19 @@ export const createTicket = authAction({ schema: createTicketSchema }, async (ct
|
||||
});
|
||||
|
||||
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({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await prisma.websiteTicketMessage.create({
|
||||
@@ -72,7 +78,8 @@ export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (
|
||||
}
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const closeTicketByUser = authAction(
|
||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||
@@ -82,8 +89,10 @@ export const closeTicketByUser = authAction(
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed") throw new ActionError("Ticket is already closed");
|
||||
if (ticket.creatorId !== ctx.session.user.id)
|
||||
throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed")
|
||||
throw new ActionError("Ticket is already closed");
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
|
||||
+41
-11
@@ -4,7 +4,10 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
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 { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
@@ -37,21 +40,36 @@ const saveTranslationsSchema = z.object({
|
||||
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");
|
||||
|
||||
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
|
||||
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"messages",
|
||||
`${ctx.data.locale}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(ctx.data.data, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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()),
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
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
|
||||
// Turbopack's static tracer can't prove that — without the hint it
|
||||
// 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");
|
||||
|
||||
if (file.format === "json") {
|
||||
// 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[] });
|
||||
}
|
||||
|
||||
@@ -91,6 +116,11 @@ export const saveClientTranslations = adminAction({ schema: saveClientTranslatio
|
||||
// At least one key could not be patched surgically (e.g. unusual
|
||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
||||
// and warn the caller that comments were lost.
|
||||
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8");
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
JSON5.stringify(ctx.data.data, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return actionOk({ commentsLost: true, unpatchedKeys });
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,14 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
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 { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { env } from "@/env";
|
||||
|
||||
async function sessionUserId(): Promise<number> {
|
||||
const session = await auth();
|
||||
@@ -42,7 +42,9 @@ async function verifyTwoFactorCode(
|
||||
|
||||
// Try TOTP first
|
||||
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 };
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
@@ -90,7 +92,8 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
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") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -99,7 +102,10 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -107,7 +113,8 @@ export async function disableTwoFactor(formData: FormData): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
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") ?? "")
|
||||
.normalize("NFC")
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
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 { rcon } from "@/lib/services/rcon";
|
||||
import { authAction, actionOk } from "@/lib/foundation/action";
|
||||
import { DatabaseError } from "@/lib/foundation/errors";
|
||||
|
||||
const MOTTO_MAX = 127;
|
||||
|
||||
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) => {
|
||||
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 {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
|
||||
+66
-23
@@ -11,9 +11,15 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
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(
|
||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||
@@ -74,10 +80,15 @@ export const createUser = adminAction(
|
||||
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
} 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[]) ?? [];
|
||||
if (target.includes("username")) throw new ActionError("Username already taken");
|
||||
if (target.includes("mail")) throw new ActionError("Email already registered");
|
||||
if (target.includes("username"))
|
||||
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 err;
|
||||
@@ -96,7 +107,11 @@ export const updateUser = adminAction(
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -122,7 +137,11 @@ export const updateUser = adminAction(
|
||||
action: "user_edit",
|
||||
target: "User",
|
||||
targetId: id,
|
||||
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
|
||||
before: {
|
||||
username: targetUser.username,
|
||||
mail: targetUser.mail,
|
||||
rank: targetUser.rank,
|
||||
},
|
||||
after: userData,
|
||||
});
|
||||
|
||||
@@ -139,7 +158,9 @@ export const updateUser = adminAction(
|
||||
|
||||
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 targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
@@ -178,11 +199,14 @@ export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInp
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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 targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
@@ -203,7 +227,8 @@ export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unba
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
||||
@@ -212,7 +237,9 @@ export const giveBadge = adminAction(
|
||||
|
||||
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");
|
||||
|
||||
await prisma.usersBadges.create({ data: { userId, badgeCode } });
|
||||
@@ -236,7 +263,9 @@ export const removeBadge = adminAction(
|
||||
|
||||
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");
|
||||
|
||||
await prisma.usersBadges.delete({ where: { id: existing.id } });
|
||||
@@ -271,7 +300,10 @@ export const resetPassword = adminAction(
|
||||
async (ctx) => {
|
||||
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);
|
||||
|
||||
await prisma.user.update({
|
||||
@@ -308,7 +340,8 @@ export const disconnectUser = adminAction(
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
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({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -338,7 +371,8 @@ export const alertUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
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();
|
||||
},
|
||||
);
|
||||
@@ -350,10 +384,13 @@ const muteSchema = z.object({
|
||||
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 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({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -364,7 +401,8 @@ export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: mute
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ── Unmute User ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -372,10 +410,13 @@ const unmuteSchema = z.object({
|
||||
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);
|
||||
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({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -385,7 +426,8 @@ export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: un
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ── Send Credits via RCON ───────────────────────────────────────────
|
||||
|
||||
@@ -399,7 +441,8 @@ export const sendCredits = adminAction(
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
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({
|
||||
userId: ctx.session.user.id,
|
||||
|
||||
+33
-8
@@ -23,7 +23,10 @@ export type RedeemState = { ok: boolean; message: string } | null;
|
||||
* voucher schema carries a single `amount`, granted as the website credits
|
||||
* 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();
|
||||
if (!session?.user?.id) {
|
||||
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);
|
||||
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") ?? "")
|
||||
@@ -52,15 +58,30 @@ export async function redeem(_prev: RedeemState, formData: FormData): Promise<Re
|
||||
try {
|
||||
voucher = await prisma.websiteShopVouchers.findUnique({
|
||||
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 {
|
||||
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).
|
||||
if (!voucher || (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())) {
|
||||
return { ok: false, message: "No active voucher with the given code was found." };
|
||||
if (
|
||||
!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.
|
||||
@@ -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." };
|
||||
}
|
||||
} 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.
|
||||
@@ -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.
|
||||
return {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,16 +52,29 @@ export default async function AdminAchievements() {
|
||||
<Award size={20} className="text-[var(--admin-accent)]" />
|
||||
</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">
|
||||
{t("subtitle", { rows: achievements.length, categories: groups.size })}
|
||||
{t("subtitle", {
|
||||
rows: achievements.length,
|
||||
categories: groups.size,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("achievementRows")} value={achievements.length} icon="🏆" />
|
||||
<StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" />
|
||||
<StatusCard
|
||||
label={t("achievementRows")}
|
||||
value={achievements.length}
|
||||
icon="🏆"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("distinctAchievements")}
|
||||
value={distinctNames}
|
||||
icon="🎖️"
|
||||
/>
|
||||
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteAd, updateAd } from "@/actions/admin-ads";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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;
|
||||
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
||||
try {
|
||||
@@ -23,7 +27,9 @@ export default async function EditAd({ params }: { params: Promise<{ id: string
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</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">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
@@ -31,7 +37,13 @@ export default async function EditAd({ params }: { params: Promise<{ id: string
|
||||
</div>
|
||||
<form action={updateAd} className="admin-card">
|
||||
<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">
|
||||
Save
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { createAd } from "@/actions/admin-ads";
|
||||
|
||||
export default function NewAd() {
|
||||
@@ -10,7 +10,9 @@ export default function NewAd() {
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">New advertisement</h1>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
New advertisement
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -18,7 +18,9 @@ export default async function AdminAds() {
|
||||
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 (
|
||||
<main>
|
||||
@@ -27,8 +29,12 @@ export default async function AdminAds() {
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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">{t("subtitle")}</p>
|
||||
<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">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/ads/new" className="btn btn-primary">
|
||||
+ {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">
|
||||
<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>
|
||||
|
||||
<section className="mt-6">
|
||||
@@ -59,13 +70,19 @@ export default async function AdminAds() {
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* 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 className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
{ad.createdAt
|
||||
? ad.createdAt.toISOString().slice(0, 10)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Bell } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { sendHotelAlert } from "@/actions/admin-alerts";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -66,8 +66,12 @@ export default async function AdminAlerts() {
|
||||
alerts = [];
|
||||
}
|
||||
|
||||
const critical = alerts.filter((a) => CRITICAL.has((a.severity || "").toLowerCase())).length;
|
||||
const warnings = alerts.filter((a) => WARNING.has((a.severity || "").toLowerCase())).length;
|
||||
const critical = alerts.filter((a) =>
|
||||
CRITICAL.has((a.severity || "").toLowerCase()),
|
||||
).length;
|
||||
const warnings = alerts.filter((a) =>
|
||||
WARNING.has((a.severity || "").toLowerCase()),
|
||||
).length;
|
||||
const unread = alerts.filter((a) => !a.isRead).length;
|
||||
|
||||
return (
|
||||
@@ -77,21 +81,35 @@ export default async function AdminAlerts() {
|
||||
<Bell size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<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">{t("subtitle")}</p>
|
||||
<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">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
|
||||
<StatusCard label={t("critical")} value={critical} state={critical > 0 ? "danger" : "ok"} icon="🚨" />
|
||||
<StatusCard
|
||||
label={t("critical")}
|
||||
value={critical}
|
||||
state={critical > 0 ? "danger" : "ok"}
|
||||
icon="🚨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("warnings")}
|
||||
value={warnings}
|
||||
state={warnings > 0 ? "warn" : "neutral"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
<StatusCard label={t("unread")} value={unread} state={unread > 0 ? "warn" : "ok"} icon="📨" />
|
||||
<StatusCard
|
||||
label={t("unread")}
|
||||
value={unread}
|
||||
state={unread > 0 ? "warn" : "ok"}
|
||||
icon="📨"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form action={sendHotelAlert} className="admin-card mb-6">
|
||||
@@ -138,10 +156,16 @@ export default async function AdminAlerts() {
|
||||
{fromDate(a.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<Badge label={a.severity || "—"} color={severityColor(a.severity)} />
|
||||
<Badge
|
||||
label={a.severity || "—"}
|
||||
color={severityColor(a.severity)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Badge label={a.type || "—"} color="var(--admin-text-muted)" />
|
||||
<Badge
|
||||
label={a.type || "—"}
|
||||
color="var(--admin-text-muted)"
|
||||
/>
|
||||
</td>
|
||||
<td>{a.message}</td>
|
||||
</tr>
|
||||
|
||||
@@ -39,7 +39,11 @@ async function getActivityData() {
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
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,
|
||||
commands: cmdMap.get(bucket) ?? 0,
|
||||
bans: banMap.get(bucket) ?? 0,
|
||||
@@ -48,7 +52,8 @@ async function getActivityData() {
|
||||
}
|
||||
|
||||
// Totals
|
||||
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
|
||||
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] =
|
||||
await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
@@ -62,7 +67,9 @@ async function getActivityData() {
|
||||
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
|
||||
|
||||
// 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
|
||||
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
|
||||
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>
|
||||
<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 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) => (
|
||||
<tr key={day.label} className="border-b last:border-0">
|
||||
<td className="py-2">{day.label}</td>
|
||||
<td className="text-right tabular-nums">{day.chats.toLocaleString()}</td>
|
||||
<td className="text-right tabular-nums">{day.commands.toLocaleString()}</td>
|
||||
<td className="text-right tabular-nums">
|
||||
{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.registrations}</td>
|
||||
<td className="text-right tabular-nums">
|
||||
{day.registrations}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -178,11 +193,16 @@ export default async function ActivityPage() {
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{data.topChatters.map((user, i) => (
|
||||
<div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<div
|
||||
key={user.userId}
|
||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-medium">{user.username}</span>
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{user.username}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{user.messages.toLocaleString()} msgs
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,10 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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" /> },
|
||||
);
|
||||
|
||||
@@ -35,14 +38,18 @@ async function getEconomyData() {
|
||||
});
|
||||
|
||||
// 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({
|
||||
where: { state: 2, soldTimestamp: { gte: weekAgo } },
|
||||
});
|
||||
|
||||
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
|
||||
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,
|
||||
COALESCE(SUM(cost_credits), 0) AS credits,
|
||||
COALESCE(SUM(cost_points), 0) AS points
|
||||
@@ -51,7 +58,10 @@ async function getEconomyData() {
|
||||
GROUP BY day_bucket ORDER BY day_bucket
|
||||
`;
|
||||
const revMap = new Map(
|
||||
revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]),
|
||||
revRows.map((r) => [
|
||||
Number(r.day_bucket),
|
||||
{ credits: Number(r.credits), points: Number(r.points) },
|
||||
]),
|
||||
);
|
||||
const revenueData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
@@ -75,7 +85,8 @@ async function getEconomyData() {
|
||||
totalCredits,
|
||||
totalPixels,
|
||||
totalPoints,
|
||||
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
|
||||
shopPurchases:
|
||||
typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
|
||||
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
|
||||
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
|
||||
marketplaceActive,
|
||||
@@ -97,7 +108,9 @@ export default async function EconomyPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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 className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -142,11 +155,15 @@ export default async function EconomyPage() {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<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>
|
||||
</CardContent>
|
||||
@@ -176,15 +193,21 @@ export default async function EconomyPage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<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>
|
||||
</CardContent>
|
||||
|
||||
@@ -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 { redirect } from "next/navigation";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
@@ -8,12 +15,18 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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" /> },
|
||||
);
|
||||
|
||||
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" /> },
|
||||
);
|
||||
|
||||
@@ -48,13 +61,17 @@ async function getAnalyticsData() {
|
||||
]);
|
||||
|
||||
// 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
|
||||
FROM users
|
||||
WHERE account_created >= ${monthAgo}
|
||||
GROUP BY day_bucket ORDER BY day_bucket
|
||||
`;
|
||||
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||
const regCountMap = new Map(
|
||||
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
|
||||
);
|
||||
const registrationChart = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
@@ -67,7 +84,9 @@ async function getAnalyticsData() {
|
||||
}
|
||||
|
||||
// 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,
|
||||
HOUR(FROM_UNIXTIME(last_online)) AS h,
|
||||
COUNT(*) AS c
|
||||
@@ -90,7 +109,9 @@ async function getAnalyticsData() {
|
||||
monthRegistrations,
|
||||
totalRooms,
|
||||
activeRooms,
|
||||
weekChatMessages: Array.isArray(weekChatMessages) ? Number(weekChatMessages[0]?.count ?? 0) : 0,
|
||||
weekChatMessages: Array.isArray(weekChatMessages)
|
||||
? Number(weekChatMessages[0]?.count ?? 0)
|
||||
: 0,
|
||||
weekTrades,
|
||||
weekBans,
|
||||
registrationChart,
|
||||
@@ -110,7 +131,9 @@ export default async function AnalyticsPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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 className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -158,8 +181,12 @@ export default async function AnalyticsPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{data.weekChatMessages.toLocaleString()}</div>
|
||||
<p className="text-sm text-muted-foreground">Messages in the last 7 days</p>
|
||||
<div className="text-3xl font-bold">
|
||||
{data.weekChatMessages.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Messages in the last 7 days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -169,8 +196,12 @@ export default async function AnalyticsPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{data.weekTrades.toLocaleString()}</div>
|
||||
<p className="text-sm text-muted-foreground">Trades in the last 7 days</p>
|
||||
<div className="text-3xl font-bold">
|
||||
{data.weekTrades.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Trades in the last 7 days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -181,9 +212,14 @@ export default async function AnalyticsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">Users online / total</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users online / total
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { dismissApplication } from "@/actions/admin-applications";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -53,8 +53,12 @@ export default async function AdminApplications() {
|
||||
<ClipboardList size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<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">{t("subtitle")}</p>
|
||||
<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">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +72,9 @@ export default async function AdminApplications() {
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<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">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]">
|
||||
{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>
|
||||
<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">
|
||||
{formatDate(a.createdAt)}
|
||||
</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}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -21,7 +21,14 @@ export default async function EditArticle({
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({
|
||||
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);
|
||||
if (!article) notFound();
|
||||
@@ -33,7 +40,9 @@ export default async function EditArticle({
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("form.title")}</h1>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("form.title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { createArticle } from "@/actions/admin-articles";
|
||||
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 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)]" />
|
||||
</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">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { deleteArticle } from "@/actions/admin-articles";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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 t = await getTranslations("pages.admin.articles");
|
||||
const articles = await prisma.websiteArticles
|
||||
@@ -18,7 +22,9 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
})
|
||||
.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 (
|
||||
<main>
|
||||
@@ -27,8 +33,12 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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">{t("subtitle")}</p>
|
||||
<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">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||
+ {t("newArticle")}
|
||||
@@ -42,8 +52,17 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
||||
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentPost")} icon="🕒" />
|
||||
<StatusCard
|
||||
label={t("articlesShown")}
|
||||
value={articles.length}
|
||||
icon="📰"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentPost")}
|
||||
icon="🕒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
@@ -74,7 +93,10 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
</td>
|
||||
<td>
|
||||
<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")}
|
||||
</Link>
|
||||
<form action={deleteArticle} className="m-0">
|
||||
|
||||
@@ -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 { 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";
|
||||
|
||||
@@ -33,8 +33,12 @@ export default async function AdminBadges({
|
||||
<Award size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<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">{t("subtitle")}</p>
|
||||
<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">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +55,9 @@ export default async function AdminBadges({
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">{t("uploadSubtitle")}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("uploadSubtitle")}
|
||||
</p>
|
||||
<form action={uploadBadge} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<input type="file" name="file" accept=".gif,.png" required />
|
||||
@@ -74,7 +80,13 @@ export default async function AdminBadges({
|
||||
<h2 className="admin-section-title">{t("giveTitle")}</h2>
|
||||
<form action={giveBadge} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required />
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="code"
|
||||
placeholder={t("badgeCodePlaceholder")}
|
||||
@@ -121,9 +133,14 @@ export default async function AdminBadges({
|
||||
<code>{b.badgeKey}</code>
|
||||
</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>
|
||||
<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
|
||||
name="userId"
|
||||
|
||||
@@ -79,9 +79,15 @@ export function BannersManager({ banners }: Props) {
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
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) {
|
||||
run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" });
|
||||
run(() => updateBanner({ id: editingId, ...data }), {
|
||||
successMessage: "Banner updated!",
|
||||
});
|
||||
} else {
|
||||
run(() => createBanner(data), { successMessage: "Banner created!" });
|
||||
}
|
||||
@@ -113,7 +119,9 @@ export function BannersManager({ banners }: Props) {
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, title: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -121,7 +129,9 @@ export function BannersManager({ banners }: Props) {
|
||||
<Label>Subtitle</Label>
|
||||
<Input
|
||||
value={form.subtitle}
|
||||
onChange={(e) => setForm({ ...form, subtitle: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, subtitle: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,13 +140,18 @@ export function BannersManager({ banners }: Props) {
|
||||
<Label>Image URL</Label>
|
||||
<Input
|
||||
value={form.image}
|
||||
onChange={(e) => setForm({ ...form, image: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, image: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link URL</Label>
|
||||
<Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} />
|
||||
<Input
|
||||
value={form.link}
|
||||
onChange={(e) => setForm({ ...form, link: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@@ -145,14 +160,21 @@ export function BannersManager({ banners }: Props) {
|
||||
<Input
|
||||
type="color"
|
||||
value={form.color || "#000000"}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, color: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<DateTimePicker
|
||||
value={form.startDate}
|
||||
onChange={(d) => setForm({ ...form, startDate: d ? d.toISOString() : null })}
|
||||
onChange={(d) =>
|
||||
setForm({
|
||||
...form,
|
||||
startDate: d ? d.toISOString() : null,
|
||||
})
|
||||
}
|
||||
placeholder="Select start date"
|
||||
/>
|
||||
</div>
|
||||
@@ -160,7 +182,9 @@ export function BannersManager({ banners }: Props) {
|
||||
<Label>End Date</Label>
|
||||
<DateTimePicker
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,7 +193,9 @@ export function BannersManager({ banners }: Props) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.isActive === 1}
|
||||
onCheckedChange={(v) => setForm({ ...form, isActive: v ? 1 : 0 })}
|
||||
onCheckedChange={(v) =>
|
||||
setForm({ ...form, isActive: v ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
@@ -179,7 +205,9 @@ export function BannersManager({ banners }: Props) {
|
||||
type="number"
|
||||
className="w-20"
|
||||
value={form.sortOrder}
|
||||
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, sortOrder: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,27 +226,43 @@ export function BannersManager({ banners }: Props) {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{banners.length === 0 && (
|
||||
<p className="text-muted-foreground col-span-full text-center py-8">No banners yet.</p>
|
||||
<p className="text-muted-foreground col-span-full text-center py-8">
|
||||
No banners yet.
|
||||
</p>
|
||||
)}
|
||||
{banners.map((b) => (
|
||||
<Card key={b.id} className="overflow-hidden">
|
||||
{b.image && (
|
||||
<div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} />
|
||||
<div
|
||||
className="h-32 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${b.image})` }}
|
||||
/>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{b.title}</h3>
|
||||
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
|
||||
{b.subtitle && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Badge variant={b.isActive ? "default" : "outline"}>
|
||||
{b.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{b.startDate && <Badge variant="secondary">Scheduled</Badge>}
|
||||
{b.startDate && (
|
||||
<Badge variant="secondary">Scheduled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(b)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -226,7 +270,9 @@ export function BannersManager({ banners }: Props) {
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
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" />
|
||||
|
||||
@@ -17,7 +17,9 @@ export default async function BannersPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
<BannersManager banners={banners} />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Ban } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Ban } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -35,8 +35,12 @@ export default async function AdminBans() {
|
||||
<Ban size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<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">{t("subtitle")}</p>
|
||||
<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">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,17 +61,34 @@ export default async function AdminBans() {
|
||||
</div>
|
||||
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">{t("newBan")}</h3>
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||
{t("newBan")}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required />
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input name="hours" type="number" min={0} placeholder={t("hoursPlaceholder")} />
|
||||
<input name="reason" placeholder={t("reasonPlaceholder")} className="flex-1 min-w-[160px]" />
|
||||
<input
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("hoursPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("ban")}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -35,7 +35,11 @@ function fmtDate(ts: number): string {
|
||||
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 { 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">
|
||||
{campaign.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="w-24 h-24 object-cover" src={campaign.image} alt={campaign.name} />
|
||||
<img
|
||||
className="w-24 h-24 object-cover"
|
||||
src={campaign.image}
|
||||
alt={campaign.name}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
|
||||
@@ -107,8 +115,12 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
|
||||
days: campaign.totalDays,
|
||||
date: fmtDate(campaign.startTimestamp),
|
||||
})}
|
||||
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
|
||||
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
|
||||
{disabled
|
||||
? ` · ${t("detail.disabled")}`
|
||||
: ` · ${t("detail.enabled")}`}
|
||||
{campaign.lockExpired === "1"
|
||||
? ` · ${t("detail.locksExpired")}`
|
||||
: ""}
|
||||
</p>
|
||||
</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)]">
|
||||
<td>{r.id}</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 className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.itemId > 0 ? r.itemId : "—"}
|
||||
@@ -144,28 +160,36 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
|
||||
{r.credits > 0 ? (
|
||||
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>
|
||||
{r.pixels > 0 ? (
|
||||
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>
|
||||
{r.points > 0 ? (
|
||||
`${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 className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.badge || "—"}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.subscriptionType ? `${r.subscriptionType} · ${r.subscriptionDays}d` : "—"}
|
||||
{r.subscriptionType
|
||||
? `${r.subscriptionType} · ${r.subscriptionDays}d`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Calendar } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -63,7 +63,9 @@ export default async function AdminCalendar() {
|
||||
<Calendar size={20} className="text-[var(--admin-accent)]" />
|
||||
</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">
|
||||
{t("subtitle", { count: campaigns.length })}
|
||||
</p>
|
||||
@@ -89,7 +91,11 @@ export default async function AdminCalendar() {
|
||||
const enabled = c.enabled === "1";
|
||||
const count = rewardCounts.get(c.id) ?? 0;
|
||||
return (
|
||||
<Link key={c.id} href={`/admin/calendar/${c.id}`} className="admin-card hover article">
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/admin/calendar/${c.id}`}
|
||||
className="admin-card hover article"
|
||||
>
|
||||
{c.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="article-img" src={c.image} alt={c.name} />
|
||||
@@ -106,7 +112,11 @@ export default async function AdminCalendar() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
{t("campaignMeta", { id: c.id, days: c.totalDays, rewards: count })}
|
||||
{t("campaignMeta", {
|
||||
id: c.id,
|
||||
days: c.totalDays,
|
||||
rewards: count,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
|
||||
{t("startsAt", { date: fmtDate(c.startTimestamp) })}
|
||||
|
||||
@@ -78,8 +78,7 @@ export function CatalogDetailTabs({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-2">
|
||||
<Languages className="h-4 w-4" />
|
||||
Translate ({baseItems.length})
|
||||
{(() => {
|
||||
Translate ({baseItems.length}){(() => {
|
||||
const furniSet = new Set(furniDataIdList);
|
||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
||||
return missing > 0 ? (
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
*/
|
||||
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
|
||||
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,
|
||||
} from "@/components/ui/command";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
@@ -37,7 +41,12 @@ export function ToggleField({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +87,10 @@ export function InteractionTypeCombobox({
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</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}>
|
||||
<CommandInput
|
||||
placeholder="Search or type custom..."
|
||||
@@ -114,7 +126,12 @@ export function InteractionTypeCombobox({
|
||||
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>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -144,7 +161,10 @@ export function MovePageCombobox({
|
||||
}) {
|
||||
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;
|
||||
|
||||
@@ -159,8 +179,14 @@ export function MovePageCombobox({
|
||||
}, [suggestedPageIds, options]);
|
||||
|
||||
// Non-suggested pages (exclude those already in suggestions)
|
||||
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]);
|
||||
const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]);
|
||||
const suggestedSet = useMemo(
|
||||
() => new Set(suggested.map((s) => s.id)),
|
||||
[suggested],
|
||||
);
|
||||
const remaining = useMemo(
|
||||
() => options.filter((p) => !suggestedSet.has(p.id)),
|
||||
[options, suggestedSet],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -173,7 +199,9 @@ export function MovePageCombobox({
|
||||
>
|
||||
{selectedPage ? (
|
||||
<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}
|
||||
</span>
|
||||
) : (
|
||||
@@ -182,7 +210,10 @@ export function MovePageCombobox({
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search pages..." />
|
||||
<CommandList>
|
||||
@@ -200,9 +231,16 @@ export function MovePageCombobox({
|
||||
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)]" />
|
||||
<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>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -210,7 +248,9 @@ export function MovePageCombobox({
|
||||
)}
|
||||
|
||||
{/* All pages */}
|
||||
<CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}>
|
||||
<CommandGroup
|
||||
heading={suggested.length > 0 ? "All pages" : undefined}
|
||||
>
|
||||
{remaining.map((page) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
@@ -220,8 +260,15 @@ export function MovePageCombobox({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check 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>
|
||||
<Check
|
||||
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>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
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) {
|
||||
return (
|
||||
<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 { 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 {
|
||||
getCachedSongPickerOptions,
|
||||
loadSongPickerOptions,
|
||||
@@ -73,9 +79,15 @@ export function SongPicker({ value, onChange, disabled }: Props) {
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Music className="h-3 w-3 text-muted-foreground" />
|
||||
<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 && (
|
||||
<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>
|
||||
</SelectItem>
|
||||
|
||||
@@ -81,5 +81,7 @@ export const DEFAULT_NEW_ITEM = {
|
||||
};
|
||||
|
||||
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";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
|
||||
import {
|
||||
CATALOG_LAYOUTS,
|
||||
type CatalogLayout,
|
||||
LAYOUT_DESCRIPTIONS,
|
||||
} from "@/lib/catalog-layouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CatalogPageData {
|
||||
@@ -69,7 +83,12 @@ interface CatalogPageFormProps {
|
||||
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 formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -106,7 +125,10 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [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 }));
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
@@ -188,7 +211,8 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ?? "Custom layout"}
|
||||
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ??
|
||||
"Custom layout"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +223,9 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
id="orderNum"
|
||||
type="number"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -211,7 +237,9 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
min={1}
|
||||
max={100}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -317,11 +345,14 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
min={0}
|
||||
className="w-32"
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,9 +380,13 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
>
|
||||
<span>
|
||||
{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>
|
||||
<Badge variant={child.enabled === "1" ? "default" : "outline"}>
|
||||
</span>
|
||||
<Badge
|
||||
variant={child.enabled === "1" ? "default" : "outline"}
|
||||
>
|
||||
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</Link>
|
||||
@@ -375,8 +410,15 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{form.caption || "Untitled"}</p>
|
||||
<Badge className={cn("mt-1 h-5 px-1.5 text-[10px] font-mono border-0", layoutColorClass)}>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{form.caption || "Untitled"}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
|
||||
layoutColorClass,
|
||||
)}
|
||||
>
|
||||
{form.pageLayout}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -443,7 +485,8 @@ export function CatalogPageForm({ catalogPage, childPages, canEdit, allPages }:
|
||||
Delete Page
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -480,7 +523,12 @@ function ToggleRow({
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -580,11 +628,17 @@ function LayoutSelect({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 (
|
||||
<SelectItem key={layout} value={layout}>
|
||||
<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}
|
||||
</span>
|
||||
</SelectItem>
|
||||
@@ -613,7 +667,10 @@ function ParentCombobox({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -639,7 +696,10 @@ function ParentCombobox({
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search pages..." />
|
||||
<CommandList>
|
||||
@@ -652,8 +712,15 @@ function ParentCombobox({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === -1 ? "opacity-100" : "opacity-0")} />
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">—</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === -1 ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
—
|
||||
</span>
|
||||
Root
|
||||
</CommandItem>
|
||||
{options.map((page) => (
|
||||
@@ -665,8 +732,15 @@ function ParentCombobox({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check 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>
|
||||
<Check
|
||||
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>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -21,8 +21,20 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
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 { FurniIcon } from "./catalog-items-table";
|
||||
|
||||
@@ -58,7 +70,10 @@ export function CatalogTranslateTab({
|
||||
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||
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());
|
||||
|
||||
// ── Editable state: names + descriptions ─────────────────────────
|
||||
@@ -67,7 +82,9 @@ export function CatalogTranslateTab({
|
||||
);
|
||||
|
||||
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
|
||||
@@ -77,12 +94,14 @@ export function CatalogTranslateTab({
|
||||
);
|
||||
|
||||
const isDescChanged = useCallback(
|
||||
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
|
||||
(item: BaseItemForTranslation) =>
|
||||
editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
|
||||
[editedDescs, furniDescriptionMap],
|
||||
);
|
||||
|
||||
const changedItems = useMemo(
|
||||
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
|
||||
() =>
|
||||
baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
|
||||
[baseItems, isNameChanged, isDescChanged],
|
||||
);
|
||||
|
||||
@@ -96,7 +115,9 @@ export function CatalogTranslateTab({
|
||||
case "missing":
|
||||
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
|
||||
case "modified":
|
||||
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item));
|
||||
return baseItems.filter(
|
||||
(item) => isNameChanged(item) || isDescChanged(item),
|
||||
);
|
||||
default:
|
||||
return baseItems;
|
||||
}
|
||||
@@ -113,7 +134,9 @@ export function CatalogTranslateTab({
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
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]);
|
||||
|
||||
@@ -132,9 +155,13 @@ export function CatalogTranslateTab({
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])));
|
||||
setEditedNames(
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
|
||||
);
|
||||
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 {
|
||||
// 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) {
|
||||
toast.info("No classnames found to look up.");
|
||||
setIsSuggesting(false);
|
||||
@@ -153,7 +182,10 @@ export function CatalogTranslateTab({
|
||||
|
||||
// Batch in chunks of 50 to avoid too-long URLs
|
||||
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) {
|
||||
const chunk = classnames.slice(i, i + CHUNK_SIZE);
|
||||
@@ -196,7 +228,9 @@ export function CatalogTranslateTab({
|
||||
const parts = [];
|
||||
if (nameCount > 0) parts.push(`${nameCount} name(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 {
|
||||
toast.info(
|
||||
`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`);
|
||||
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
|
||||
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.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");
|
||||
toast.success(`${parts.join(" · ")}.`);
|
||||
@@ -250,7 +287,8 @@ export function CatalogTranslateTab({
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Translate Item Names</CardTitle>
|
||||
<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>
|
||||
<div className="flex gap-3 mt-2 flex-wrap">
|
||||
<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)]">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -297,7 +336,11 @@ export function CatalogTranslateTab({
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAll}
|
||||
disabled={isPending || changedItems.length === 0}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -350,7 +393,9 @@ export function CatalogTranslateTab({
|
||||
No items to translate in this page.
|
||||
</p>
|
||||
) : 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>
|
||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
||||
@@ -359,7 +404,10 @@ export function CatalogTranslateTab({
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] px-2">
|
||||
<Checkbox
|
||||
checked={filteredItems.length > 0 && selected.size === filteredItems.length}
|
||||
checked={
|
||||
filteredItems.length > 0 &&
|
||||
selected.size === filteredItems.length
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
@@ -402,16 +450,33 @@ export function CatalogTranslateTab({
|
||||
{(() => {
|
||||
const revInfo = furniRevisionMap[item.id];
|
||||
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 className="font-mono text-xs">{item.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{item.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs">
|
||||
{item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type}
|
||||
<Badge
|
||||
variant={
|
||||
item.type === "s" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{item.type === "s"
|
||||
? "Floor"
|
||||
: item.type === "i"
|
||||
? "Wall"
|
||||
: item.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||
@@ -420,11 +485,14 @@ export function CatalogTranslateTab({
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editedNames[item.id] || ""}
|
||||
onChange={(e) => handleNameChange(item.id, e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleNameChange(item.id, e.target.value)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
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">
|
||||
catalog: {currentCatalogName}
|
||||
</p>
|
||||
@@ -433,7 +501,9 @@ export function CatalogTranslateTab({
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editedDescs[item.id] || ""}
|
||||
onChange={(e) => handleDescChange(item.id, e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleDescChange(item.id, e.target.value)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
placeholder="No description"
|
||||
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
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
{isMissing ? (
|
||||
<Tooltip>
|
||||
@@ -468,7 +540,9 @@ export function CatalogTranslateTab({
|
||||
<TooltipTrigger asChild>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>In FurnitureData.json</TooltipContent>
|
||||
<TooltipContent>
|
||||
In FurnitureData.json
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -55,26 +55,36 @@ export default async function CatalogEditPage({
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Link href={"/admin/catalog"} className="hover:text-foreground transition-colors">
|
||||
<Link
|
||||
href={"/admin/catalog"}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Catalog
|
||||
</Link>
|
||||
{ancestors.map((bc) => (
|
||||
<Fragment key={bc.id}>
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
||||
<Link href={`/admin/catalog/${bc.id}`} className="hover:text-foreground transition-colors">
|
||||
<Link
|
||||
href={`/admin/catalog/${bc.id}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{bc.caption}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-foreground font-medium">{catalogPage.caption}</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{catalogPage.caption}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{catalogPage.caption}
|
||||
<span className="ml-2 text-base font-normal text-muted-foreground">#{catalogPage.id}</span>
|
||||
<span className="ml-2 text-base font-normal text-muted-foreground">
|
||||
#{catalogPage.id}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Layout: {catalogPage.pageLayout} · {catalogItems.length} items
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
"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 { 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 { Button } from "@/components/ui/button";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
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 { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
@@ -86,7 +113,10 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [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 }));
|
||||
}
|
||||
|
||||
@@ -147,7 +177,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
id="orderNum"
|
||||
type="number"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -198,7 +230,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<Label>Details</Label>
|
||||
<Textarea
|
||||
value={form.pageTextDetails}
|
||||
onChange={(e) => update("pageTextDetails", e.target.value)}
|
||||
onChange={(e) =>
|
||||
update("pageTextDetails", e.target.value)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
@@ -250,7 +284,9 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
<TableHeader>
|
||||
@@ -260,18 +296,28 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
<TableHead className="text-xs">Item IDs</TableHead>
|
||||
<TableHead className="text-xs">Order</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-xs font-mono">{item.id}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.catalogName}</TableCell>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{item.id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">
|
||||
{item.catalogName}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{item.itemIds}
|
||||
</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">
|
||||
{item.extradata || "\u2014"}
|
||||
</TableCell>
|
||||
@@ -293,7 +339,12 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
||||
onClick={() => {
|
||||
if (!confirm(`Delete item "${item.catalogName}"?`)) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Delete item "${item.catalogName}"?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
run(() => deleteBcItem({ id: item.id }), {
|
||||
successMessage: "Item deleted.",
|
||||
});
|
||||
@@ -346,14 +397,20 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
</span>
|
||||
<span>
|
||||
Parent:{" "}
|
||||
<span className="font-mono">{page.parentId <= 0 ? "Root" : `#${page.parentId}`}</span>
|
||||
<span className="font-mono">
|
||||
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<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 ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -362,7 +419,8 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
Save & Sync
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -439,7 +497,12 @@ function ToggleRow({
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -457,7 +520,12 @@ function ItemDialog({
|
||||
mode: "add" | "edit";
|
||||
isPending: boolean;
|
||||
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({
|
||||
itemIds: item.itemIds,
|
||||
@@ -470,21 +538,27 @@ function ItemDialog({
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Catalog Name</Label>
|
||||
<Input
|
||||
value={form.catalogName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, catalogName: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, catalogName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Item IDs (semicolon-separated)</Label>
|
||||
<Input
|
||||
value={form.itemIds}
|
||||
onChange={(e) => setForm((f) => ({ ...f, itemIds: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, itemIds: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -493,14 +567,21 @@ function ItemDialog({
|
||||
<Input
|
||||
type="number"
|
||||
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 className="space-y-2">
|
||||
<Label>Extra Data</Label>
|
||||
<Input
|
||||
value={form.extradata}
|
||||
onChange={(e) => setForm((f) => ({ ...f, extradata: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, extradata: e.target.value }))
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
@@ -510,7 +591,10 @@ function ItemDialog({
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</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" />}
|
||||
{mode === "add" ? "Add Item" : "Save"}
|
||||
</Button>
|
||||
|
||||
@@ -6,10 +6,22 @@ import { toast } from "sonner";
|
||||
import { createCatalogPage } from "@/actions/catalog";
|
||||
import { createBcPage } from "@/actions/catalog-bc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
|
||||
|
||||
@@ -17,7 +29,9 @@ interface CatalogActionsProps {
|
||||
catalogType?: "normal" | "bc";
|
||||
}
|
||||
|
||||
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
|
||||
export function CatalogActions({
|
||||
catalogType = "normal",
|
||||
}: CatalogActionsProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [caption, setCaption] = useState("");
|
||||
@@ -37,7 +51,9 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
|
||||
pageLayout,
|
||||
};
|
||||
|
||||
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
|
||||
const fn = isBc
|
||||
? () => createBcPage(input)
|
||||
: () => createCatalogPage(input);
|
||||
|
||||
run(fn, {
|
||||
successMessage: `Catalog page "${caption.trim()}" created.`,
|
||||
@@ -64,7 +80,9 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isBc ? "Create Builder Club Page" : "Create Catalog Page"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isBc ? "Create Builder Club Page" : "Create Catalog Page"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -101,7 +119,11 @@ export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps)
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreate} disabled={isPending} className="w-full">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<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
Reference in new issue
Block a user