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

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

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+8 -5
View File
@@ -1,10 +1,10 @@
import js from "@eslint/js";
import 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
View File
@@ -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
View File
@@ -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",
+2862 -7547
View File
File diff suppressed because it is too large. Load diff
+17 -4
View File
@@ -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" } }
}
}
}
}
+6 -4
View File
@@ -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
View File
@@ -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(
+15 -7
View File
@@ -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));
+2 -1
View File
@@ -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
View File
@@ -1,7 +1,11 @@
const MYSQL2_UNSUPPORTED_OPTIONS = ["connection_limit", "pool_timeout"] as const;
const MYSQL2_UNSUPPORTED_OPTIONS = [
"connection_limit",
"pool_timeout",
] as const;
export function mysqlConnectionUrl(value: string): string {
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
View File
@@ -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) => {
+15 -5
View File
@@ -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 -1
View File
@@ -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`");
+3 -1
View File
@@ -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(";")
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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();
+10 -4
View File
@@ -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 -1
View File
@@ -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";
+9 -3
View File
@@ -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 },
+1 -1
View File
@@ -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();
+4 -2
View File
@@ -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");
+3 -1
View File
@@ -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.
}
+6 -3
View File
@@ -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);
+6 -4
View File
@@ -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 "";
+13 -4
View File
@@ -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
}
+2 -1
View File
@@ -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");
+6 -2
View File
@@ -97,7 +97,9 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
const title = str(formData.get("title")).trim().slice(0, 255);
const 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;
+1 -1
View File
@@ -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 {
+16 -5
View File
@@ -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 {
+7 -3
View File
@@ -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")
+6 -3
View File
@@ -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;
}
+4 -3
View File
@@ -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({
+12 -2
View File
@@ -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",
+6 -3
View File
@@ -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;
}
+10 -2
View File
@@ -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,
+4 -2
View File
@@ -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(),
},
});
+13 -5
View File
@@ -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
View File
@@ -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 },
+3 -1
View File
@@ -34,7 +34,9 @@ export const createBanner = adminAction(
},
);
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
const updateBannerInput = bannerSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
+33 -10
View File
@@ -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 },
};
}
+16 -3
View File
@@ -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: {
+16 -4
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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);
+18 -5
View File
@@ -17,21 +17,29 @@ import { siteSettings } from "@/lib/services/site-settings";
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+14 -4
View File
@@ -41,7 +41,9 @@ export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
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 });
},
+1 -1
View File
@@ -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),
+5 -1
View File
@@ -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,
+15 -4
View File
@@ -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
>,
),
);
+17 -4
View File
@@ -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";
}
}
+8 -4
View File
@@ -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({
+19 -3
View File
@@ -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,
})),
});
}
+14 -3
View File
@@ -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,
},
},
}));
+22 -10
View File
@@ -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 {
+16 -5
View File
@@ -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
View File
@@ -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 });
},
);
+1 -1
View File
@@ -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
View File
@@ -27,10 +27,20 @@ import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
* 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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
};
}
}
+12 -5
View File
@@ -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",
};
}
}
+9 -3
View File
@@ -21,20 +21,26 @@ export const createTemplate = adminAction(
},
);
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() });
const updateTemplateInput = templateSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateTemplate = adminAction(
{ 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
View File
@@ -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
View File
@@ -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 });
});
},
);
+14 -7
View File
@@ -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")
+10 -5
View File
@@ -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
View File
@@ -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
View File
@@ -23,7 +23,10 @@ export type RedeemState = { ok: boolean; message: string } | null;
* voucher schema carries a single `amount`, granted as the website credits
* 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.",
};
}
+17 -4
View File
@@ -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>
+16 -4
View File
@@ -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>
+4 -2
View File
@@ -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>
+24 -7
View File
@@ -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>
+33 -9
View File
@@ -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>
+29 -9
View File
@@ -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>
+34 -11
View File
@@ -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>
+50 -14
View File
@@ -1,4 +1,11 @@
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
import {
ArrowLeftRight,
Clock,
DoorOpen,
MessageSquare,
TrendingUp,
Users,
} from "lucide-react";
import dynamic from "next/dynamic";
import { 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>
+18 -8
View File
@@ -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">
+13 -4
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import 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>
+9 -3
View File
@@ -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>
+31 -9
View File
@@ -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">
+27 -10
View File
@@ -1,8 +1,8 @@
import { getTranslations } from "next-intl/server";
import { giveBadge } from "@/actions/admin-badges";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { prisma } from "@/lib/prisma";
import { Award } from "lucide-react";
import { 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"
+63 -17
View File
@@ -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" />
+3 -1
View File
@@ -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>
+29 -8
View File
@@ -1,8 +1,8 @@
import { Ban } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans";
import { 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>
+34 -10
View File
@@ -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>
))}
+14 -4
View File
@@ -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>
+14 -4
View File
@@ -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} &middot; {catalogItems.length} items
+108 -24
View File
@@ -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>
+28 -6
View File
@@ -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