Revert "Add missing admin action files and navigation links"
This reverts commit 41be6835bf.
This commit is contained in:
1 parent
4a1e1115b3
commit
4d515bc400
338 files changed
+5930
-28125
No files matched your search
@@ -8,12 +8,12 @@ Features full public-facing website, administrative panel, NextAuth authenticati
|
||||
|
||||
## Requirements
|
||||
|
||||
| Component | Version |
|
||||
| --------------- | ---------------------------------- |
|
||||
| Node.js | >= 22 |
|
||||
| pnpm | >= 10.33.4 |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ |
|
||||
| Redis | Optional (caching / rate limiting) |
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| Node.js | >= 22 |
|
||||
| pnpm | >= 10.33.4 |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ |
|
||||
| Redis | Optional (caching / rate limiting) |
|
||||
|
||||
---
|
||||
|
||||
@@ -240,7 +240,6 @@ pnpm jobs:worker
|
||||
```
|
||||
|
||||
Scheduled tasks:
|
||||
|
||||
- **Daily at 03:00** — Emulator JAR backup (when `EMULATOR_JAR_PATH` and `EMULATOR_BACKUP_DIR` are configured)
|
||||
- **Daily at 04:00** — Cleanup of login logs older than 30 days and expired password reset tokens older than 7 days
|
||||
|
||||
@@ -289,11 +288,11 @@ Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via **Admin → CM
|
||||
|
||||
## Database Architecture
|
||||
|
||||
| Component | Type | Migrations |
|
||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — read-only for CMS |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (9 files) |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created |
|
||||
| Component | Type | Migrations |
|
||||
|---|---|---|
|
||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — read-only for CMS |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (9 files) |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-1
@@ -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",
|
||||
@@ -39,6 +42,7 @@ export default tseslint.config(
|
||||
|
||||
"security/detect-object-injection": "warn",
|
||||
"security/detect-non-literal-fs-filename": "warn",
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+6
-2
@@ -52,11 +52,15 @@ 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/(.*)",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=86400" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
+1
-6
@@ -72,11 +72,6 @@
|
||||
"prisma",
|
||||
"@prisma/client",
|
||||
"@prisma/engines"
|
||||
],
|
||||
"overrides": {
|
||||
"fast-uri": "^3.1.3",
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"postcss": "^8.5.16"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+2421
-4588
File diff suppressed because it is too large.
Load diff
@@ -5,8 +5,12 @@
|
||||
"version": "1.0.0",
|
||||
"description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS."
|
||||
},
|
||||
"servers": [{ "url": "/api", "description": "Local API" }],
|
||||
"security": [{ "bearerAuth": [], "sessionAuth": [] }],
|
||||
"servers": [
|
||||
{ "url": "/api", "description": "Local API" }
|
||||
],
|
||||
"security": [
|
||||
{ "bearerAuth": [], "sessionAuth": [] }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
@@ -50,11 +54,7 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Online count",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "object", "properties": { "count": { "type": "integer" } } }
|
||||
}
|
||||
}
|
||||
"content": { "application/json": { "schema": { "type": "object", "properties": { "count": { "type": "integer" } } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
(function () {
|
||||
(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");
|
||||
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) {}
|
||||
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');
|
||||
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){}
|
||||
})();
|
||||
+1
-4
@@ -45,10 +45,7 @@ self.addEventListener("fetch", (event) => {
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
const copy = res.clone();
|
||||
caches
|
||||
.open(CACHE)
|
||||
.then((c) => c.put(req, copy))
|
||||
.catch(() => {});
|
||||
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
|
||||
|
||||
@@ -41,7 +41,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();
|
||||
@@ -77,7 +79,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();
|
||||
|
||||
@@ -29,9 +29,7 @@ function decryptCbc(payload: string, key: Buffer, serialize = true): string {
|
||||
value: string;
|
||||
mac: string;
|
||||
};
|
||||
const expected = createHmac("sha256", key)
|
||||
.update(json.iv + json.value)
|
||||
.digest("hex");
|
||||
const expected = createHmac("sha256", key).update(json.iv + json.value).digest("hex");
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(json.mac, "hex");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
@@ -61,8 +59,8 @@ 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
|
||||
if (json.tag) return true; // has authTag => GCM
|
||||
return false; // has mac => CBC
|
||||
if (json.tag) return true; // has authTag => GCM
|
||||
return false; // has mac => CBC
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,7 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
export async function createAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
const now = new Date();
|
||||
@@ -42,9 +40,7 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -13,9 +13,7 @@ import { rcon } from "@/lib/services/rcon";
|
||||
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, 1000);
|
||||
if (!message) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -53,16 +53,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
title: String(formData.get("title") ?? "").trim().slice(0, 255),
|
||||
shortStory: String(formData.get("shortStory") ?? "").trim().slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "").trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
image: String(formData.get("image") ?? "").trim().slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,9 +9,7 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.trim()
|
||||
.slice(0, 32);
|
||||
const code = String(formData.get("code") ?? "").trim().slice(0, 32);
|
||||
if (!(userId > 0) || code.length === 0) return;
|
||||
|
||||
// Fire the emulator command so the badge appears live for online users.
|
||||
|
||||
@@ -14,10 +14,7 @@ const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
|
||||
export async function createBan(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const reason =
|
||||
String(formData.get("reason") ?? "")
|
||||
.trim()
|
||||
.slice(0, 200) || "Banned";
|
||||
const reason = String(formData.get("reason") ?? "").trim().slice(0, 200) || "Banned";
|
||||
const hours = Number(formData.get("hours"));
|
||||
const type = String(formData.get("type"));
|
||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||
|
||||
@@ -7,12 +7,8 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "");
|
||||
const variablesRaw = String(formData.get("variables") ?? "").trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
@@ -40,9 +36,7 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "");
|
||||
const variablesRaw = String(formData.get("variables") ?? "").trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
|
||||
@@ -11,9 +11,7 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "").slice(0, 512);
|
||||
if (!key) return;
|
||||
await prisma.emulatorSettings.upsert({
|
||||
@@ -26,9 +24,7 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
|
||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "").slice(0, 4096);
|
||||
if (!key) return;
|
||||
await prisma.emulatorTexts.upsert({
|
||||
|
||||
+12
-38
@@ -17,29 +17,16 @@ function parsePosition(value: FormDataEntryValue | null): number {
|
||||
|
||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
|
||||
|
||||
try {
|
||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||
@@ -76,29 +63,16 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.update({
|
||||
|
||||
@@ -11,13 +11,9 @@ import { prisma } from "@/lib/prisma";
|
||||
export async function upsertPermission(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
|
||||
const minRank = Number(formData.get("minRank"));
|
||||
const descriptionRaw = String(formData.get("description") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const descriptionRaw = String(formData.get("description") ?? "").trim().slice(0, 255);
|
||||
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
||||
|
||||
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
|
||||
|
||||
@@ -5,15 +5,11 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseIp(formData: FormData): string {
|
||||
return String(formData.get("ipAddress") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return String(formData.get("ipAddress") ?? "").trim().slice(0, 255);
|
||||
}
|
||||
|
||||
function parseAsn(formData: FormData): string | null {
|
||||
const asn = String(formData.get("asn") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const asn = String(formData.get("asn") ?? "").trim().slice(0, 255);
|
||||
return asn || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -13,8 +13,8 @@ export async function uploadMedia(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return;
|
||||
if (file.size > MAX_SIZE) return;
|
||||
if (!ALLOWED.includes(file.type)) return;
|
||||
if (file.size > MAX_SIZE) throw new Error("File too large (max 5MB)");
|
||||
if (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
|
||||
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
|
||||
@@ -20,9 +20,7 @@ function parseMinRank(formData: FormData): number {
|
||||
|
||||
export async function createPermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description = String(formData.get("description") ?? "").trim() || null;
|
||||
if (!permission) return;
|
||||
@@ -54,9 +52,7 @@ export async function updatePermission(formData: FormData): Promise<void> {
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!raw) return;
|
||||
const id = BigInt(raw);
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description = String(formData.get("description") ?? "").trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,13 @@
|
||||
"use server";
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireStaff } from '@/lib/admin/guard';
|
||||
import { logStaffActivity } from '@/lib/services/staff-activity';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
@@ -23,21 +23,21 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
*/
|
||||
export async function deleteShout(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio.shout.delete",
|
||||
action: 'radio.shout.delete',
|
||||
description: `Deleted radio shout #${id}`,
|
||||
targetType: "radio_shout",
|
||||
targetType: 'radio_shout',
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
revalidatePath('/admin/radio/moderation');
|
||||
}
|
||||
@@ -1 +1,27 @@
|
||||
"use server";
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireStaff } from '@/lib/admin/guard';
|
||||
|
||||
export async function deleteShout(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const raw = formData.get('id');
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return;
|
||||
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath('/admin/radio');
|
||||
}
|
||||
@@ -7,12 +7,8 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "").trim().slice(0, 255);
|
||||
const priorityRaw = Number(formData.get("priority"));
|
||||
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1;
|
||||
if (!name || !badge) return;
|
||||
@@ -47,27 +43,16 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
if (!categoryId) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "").trim().slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
|
||||
const itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
String(formData.get("currencyType") ?? "diamonds")
|
||||
.trim()
|
||||
.slice(0, 255) || "diamonds";
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "").trim().slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "").trim().slice(0, 255);
|
||||
const currencyType = String(formData.get("currencyType") ?? "diamonds").trim().slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValues.create({
|
||||
|
||||
@@ -17,13 +17,9 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
|
||||
export async function createSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
|
||||
const value = String(formData.get("value") ?? "");
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
|
||||
+10
-32
@@ -30,9 +30,7 @@ function reqUInt(formData: FormData, key: string): number {
|
||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
@@ -40,24 +38,15 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
info: String(formData.get("info") ?? "").trim().slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "").trim().slice(0, 255),
|
||||
color: String(formData.get("color") ?? "").trim().slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -85,9 +74,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
@@ -95,24 +82,15 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
info: String(formData.get("info") ?? "").trim().slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "").trim().slice(0, 255),
|
||||
color: String(formData.get("color") ?? "").trim().slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
||||
+23
-23
File diff suppressed because it is too large.
Load diff
@@ -9,9 +9,7 @@ import { logServerError } from "@/lib/server-log";
|
||||
export async function createVoucher(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const code = String(formData.get("code") ?? "").trim().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;
|
||||
|
||||
@@ -29,17 +29,11 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||
const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== "";
|
||||
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "").trim().toLowerCase();
|
||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "").trim().slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "").trim().slice(0, 255);
|
||||
|
||||
try {
|
||||
await writeSetting(
|
||||
|
||||
@@ -7,9 +7,7 @@ import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function addWord(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const word = String(formData.get("word") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const word = String(formData.get("word") ?? "").trim().slice(0, 255);
|
||||
if (!word) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,9 +38,7 @@ function revalidate(): void {
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const now = new Date();
|
||||
@@ -48,10 +46,7 @@ export async function createBox(formData: FormData): Promise<void> {
|
||||
const created = await prisma.websiteWriteableBoxes.create({
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
|
||||
content: String(formData.get("content") ?? ""),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "") === "1",
|
||||
@@ -80,9 +75,7 @@ export async function updateBox(formData: FormData): Promise<void> {
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
@@ -90,10 +83,7 @@ export async function updateBox(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
|
||||
content: String(formData.get("content") ?? ""),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "") === "1",
|
||||
|
||||
@@ -28,9 +28,7 @@ export async function applyStaff(formData: FormData): Promise<void> {
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
try {
|
||||
@@ -72,9 +70,7 @@ export async function applyTeam(formData: FormData): Promise<void> {
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -21,9 +21,7 @@ export async function postComment(formData: FormData): Promise<void> {
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.trim()
|
||||
.slice(0, COMMENT_MAX);
|
||||
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
|
||||
if (!comment) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const reaction = String(formData.get("reaction") ?? "").trim().toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
|
||||
|
||||
@@ -11,7 +11,10 @@ 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 ?? "").trim();
|
||||
const p = String(password ?? "");
|
||||
if (!u || !p) return "invalid";
|
||||
@@ -29,9 +32,11 @@ 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", {
|
||||
convertPasswords: false,
|
||||
});
|
||||
await checkLogin(
|
||||
p,
|
||||
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
|
||||
{ convertPasswords: false },
|
||||
);
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function getBadgeData({ code }: { code: string }) {
|
||||
await requireStaff();
|
||||
const badge = await prisma.websiteBadges.findUnique({
|
||||
where: { badgeKey: code },
|
||||
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 } };
|
||||
}
|
||||
|
||||
export async function updateBadge({ code, name, desc }: { code: string; name: string; desc: string }) {
|
||||
await requireStaff();
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
||||
create: { badgeKey: code, badgeName: name, badgeDescription: desc, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
revalidatePath("/admin/import/badges");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,100 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
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>) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function deleteBcItem({ id }: { id: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function updateBcItem({ id, ...data }: { id: number; itemIds?: string; catalogName?: string; orderNumber?: number; extradata?: string }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function createBcItem({ pageId, ...data }: { pageId: number; itemIds: string; catalogName: string; orderNumber: number; extradata: string }) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogItemsBc.create({
|
||||
data: { pageId, ...data },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${created.id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPagesBc.findUnique({ where: { id }, select: { enabled: true, visible: true } });
|
||||
if (!page) return;
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: { [field]: page[field] === "1" ? "0" : "1" },
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function createBcPage(input: { caption: string; parentId: number; pageLayout: string }) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPagesBc.create({
|
||||
data: {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout,
|
||||
iconColor: 0,
|
||||
iconImage: 0,
|
||||
orderNum: 0,
|
||||
visible: "1",
|
||||
enabled: "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
description: `Created BC catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,80 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.delete({ where: { id } });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
description: `Deleted catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function toggleCatalogPage({ id, action }: { id: number; action: "toggleEnabled" | "toggleVisible" }) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true } });
|
||||
if (!page) return;
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
await prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: { [field]: current === "1" ? "0" : "1" },
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout: string }) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout,
|
||||
captionSave: input.caption.slice(0, 25),
|
||||
iconColor: 0,
|
||||
iconImage: 0,
|
||||
minRank: 1,
|
||||
orderNum: 0,
|
||||
visible: "1",
|
||||
enabled: "1",
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: (await requireStaff()).id,
|
||||
action: "catalog_page_create",
|
||||
description: `Created catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
}
|
||||
@@ -42,9 +42,7 @@ export async function updateNavigator(): Promise<void> {
|
||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
|
||||
if (!message) return;
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
@@ -72,9 +70,7 @@ export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
|
||||
if (!userId || !message) return;
|
||||
try {
|
||||
await rcon.alertUser(userId, message);
|
||||
@@ -158,9 +154,7 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.trim()
|
||||
.slice(0, 127);
|
||||
const motto = String(formData.get("motto") ?? "").trim().slice(0, 127);
|
||||
if (!userId || !motto) return;
|
||||
try {
|
||||
await rcon.setMotto(userId, motto);
|
||||
@@ -203,9 +197,7 @@ export async function sendGift(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const itemId = Number(formData.get("itemId"));
|
||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const message = String(formData.get("message") ?? "Here is a gift.").trim().slice(0, 255);
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await rcon.sendGift(userId, itemId, message);
|
||||
|
||||
+30
-24
@@ -6,7 +6,6 @@ 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
|
||||
@@ -79,34 +78,41 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
// Deduct first, then grant. sendCurrency falls back to a direct DB
|
||||
// write when RCON is offline; a negative amount is not supported, so
|
||||
// the debit is an atomic credits decrement and the credit (grant) is
|
||||
// the badge itself.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
await rcon.giveBadge(userId, code);
|
||||
|
||||
// Persist it so it survives a relog / offline grant. users_badges has
|
||||
// no unique (user_id, badge_code) constraint, so guard duplicates and
|
||||
// compute the next free slot ourselves (mirrors admin giveBadge).
|
||||
try {
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await prisma.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await prisma.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the RCON grant already succeeded for online users.
|
||||
}
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
|
||||
@@ -31,7 +31,10 @@ export async function verificationToken(email: string): Promise<string> {
|
||||
* 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");
|
||||
@@ -42,15 +45,18 @@ export async function isValidVerificationToken(email: string, token: string): Pr
|
||||
|
||||
/**
|
||||
* Build the verification link + email and send it. No-ops gracefully when SMTP
|
||||
* is unconfigured (sendMail returns false).
|
||||
* is unconfigured (sendMail returns false). `userId` is accepted for a faithful
|
||||
* call signature, but the stateless token only needs the email.
|
||||
*/
|
||||
export async function sendVerification(email: string): Promise<boolean> {
|
||||
export async function sendVerification(userId: number, email: string): Promise<boolean> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
if (!normalised) return false;
|
||||
|
||||
const token = await verificationToken(normalised);
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
|
||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(
|
||||
normalised,
|
||||
)}`;
|
||||
|
||||
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
|
||||
@@ -69,9 +75,16 @@ export async function sendVerification(email: string): Promise<boolean> {
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// `userId` referenced so a faithful caller signature isn't flagged unused.
|
||||
void userId;
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -25,9 +25,7 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
const profileId = Number(formData.get("profileId"));
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) return;
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
|
||||
@@ -22,12 +22,8 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
title: String(formData.get("title") ?? "").trim().slice(0, 255),
|
||||
content: String(formData.get("content") ?? "").trim().slice(0, 5000),
|
||||
};
|
||||
|
||||
const parsed = ticketSchema.safeParse(raw);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function importBadgeFromRemote({ code, name, description }: { code: string; name: string; description: string }) {
|
||||
await requireStaff();
|
||||
try {
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
|
||||
create: { badgeKey: code, badgeName: name, badgeDescription: description, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, error: "Failed to import badge" };
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function cleanSwfFiles() {
|
||||
await requireStaff();
|
||||
return { ok: true as const, data: { deleted: 0, remaining: 0 } };
|
||||
}
|
||||
|
||||
export async function deleteImportedFurni({ classname }: { classname: string }) {
|
||||
await requireStaff();
|
||||
try {
|
||||
await prisma.itemsBase.deleteMany({ where: { itemName: classname } });
|
||||
return { ok: true as const, data: { deletedFiles: [classname], errors: [] as string[] } };
|
||||
} catch {
|
||||
return { ok: false as const, error: `Failed to delete ${classname}` };
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export interface MultiAccountCluster {
|
||||
key: string;
|
||||
label: string;
|
||||
accountCount: number;
|
||||
accounts: Array<{ id: number; username: string; rank: number; online: string }>;
|
||||
}
|
||||
|
||||
export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts: number; limit: number }) {
|
||||
await requireStaff();
|
||||
const clusters: MultiAccountCluster[] = [];
|
||||
|
||||
const ipGroups = await prisma.user.groupBy({
|
||||
by: ["ipCurrent"],
|
||||
where: { ipCurrent: { not: "" } },
|
||||
_count: { id: true },
|
||||
having: { id: { _count: { gte: minAccounts } } },
|
||||
orderBy: { _count: { id: "desc" } },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const group of ipGroups) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { ipCurrent: group.ipCurrent },
|
||||
select: { id: true, username: true, rank: true, online: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
clusters.push({
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, data: { clusters } };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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(),
|
||||
@@ -9,7 +9,8 @@ const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockS
|
||||
mockDelete: vi.fn(),
|
||||
mockSendMail: vi.fn(),
|
||||
mockRedirect: vi.fn(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (...args: unknown[]) => {
|
||||
|
||||
@@ -15,9 +15,7 @@ function sha256(s: string): string {
|
||||
}
|
||||
|
||||
export async function requestReset(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const email = String(formData.get("email") ?? "").trim().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;
|
||||
@@ -49,17 +47,13 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
export async function resetPassword(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
const token = String(formData.get("token") ?? "").trim();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
|
||||
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
|
||||
);
|
||||
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`);
|
||||
}
|
||||
|
||||
let error: string | null = null;
|
||||
@@ -93,9 +87,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`);
|
||||
}
|
||||
redirect("/login?reset=1");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function saveRank({ id, fields }: { id: number; fields: Record<string, string> }) {
|
||||
const staff = await requireStaff();
|
||||
const setClauses = Object.entries(fields)
|
||||
.map(([key]) => `\`${key}\` = ?`)
|
||||
.join(", ");
|
||||
const values = Object.values(fields);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`UPDATE permissions SET ${setClauses} WHERE id = ?`,
|
||||
...values,
|
||||
id,
|
||||
);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_update",
|
||||
description: `Updated rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setCmsPermissions({ roleId, permissionSlugs }: { roleId: number; permissionSlugs: string[] }) {
|
||||
await requireStaff();
|
||||
const existing = await prisma.websiteHousekeepingPermissions.findMany({
|
||||
where: { minRank: roleId },
|
||||
select: { permission: true },
|
||||
});
|
||||
const existingSlugs = new Set(existing.map((p) => p.permission));
|
||||
const toAdd = permissionSlugs.filter((s) => !existingSlugs.has(s));
|
||||
const toRemove = existing.filter((p) => !permissionSlugs.includes(p.permission));
|
||||
|
||||
for (const slug of toAdd) {
|
||||
await prisma.websiteHousekeepingPermissions.create({
|
||||
data: { permission: slug, minRank: roleId, description: null },
|
||||
});
|
||||
}
|
||||
for (const p of toRemove) {
|
||||
await prisma.websiteHousekeepingPermissions.deleteMany({
|
||||
where: { permission: p.permission, minRank: roleId },
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function createRank({ rank_name, level }: { rank_name: string; level: number }) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.websiteTeams.create({
|
||||
data: {
|
||||
rankName: rank_name,
|
||||
hiddenRank: false,
|
||||
badge: null,
|
||||
jobDescription: null,
|
||||
staffColor: "#ffffff",
|
||||
staffBackground: "#000000",
|
||||
},
|
||||
});
|
||||
return { ok: true as const, data: { id: Number(created.id) } };
|
||||
}
|
||||
|
||||
export async function deleteRank({ id }: { id: number }) {
|
||||
await requireStaff();
|
||||
await prisma.websiteTeams.delete({ where: { id: BigInt(id) } });
|
||||
return { ok: true as const };
|
||||
}
|
||||
@@ -11,9 +11,7 @@ const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
function str(form: FormData, key: string, max: number): string {
|
||||
return String(form.get(key) ?? "")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
return String(form.get(key) ?? "").trim().slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,12 +12,8 @@ export async function submitRequest(formData: FormData): Promise<void> {
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
const songTitle = String(formData.get("songTitle") ?? "").trim().slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "").trim().slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) return;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -28,9 +28,7 @@ export async function postShout(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
message: String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
message: String(formData.get("message") ?? "").trim().slice(0, 255),
|
||||
};
|
||||
|
||||
const parsed = shoutSchema.safeParse(raw);
|
||||
|
||||
@@ -27,10 +27,16 @@ 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();
|
||||
|
||||
@@ -32,9 +32,7 @@ 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> {
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "").trim(),
|
||||
mail: String(formData.get("mail") ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
mail: String(formData.get("mail") ?? "").trim().toLowerCase(),
|
||||
password: String(formData.get("password") ?? ""),
|
||||
look: String(formData.get("look") ?? "").trim() || DEFAULT_LOOK,
|
||||
};
|
||||
@@ -63,9 +61,8 @@ export async function register(prevState: string | null, formData: FormData): Pr
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return (
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed."
|
||||
);
|
||||
await siteSettings.get("vpn_block_message", "")
|
||||
) || "Registrations from VPN/proxy connections are not allowed.";
|
||||
}
|
||||
|
||||
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
||||
@@ -88,7 +85,7 @@ export async function register(prevState: string | null, formData: FormData): Pr
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
await prisma.user.create({
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
@@ -103,7 +100,7 @@ export async function register(prevState: string | null, formData: FormData): Pr
|
||||
|
||||
if (hasEmail) {
|
||||
try {
|
||||
await sendVerification(mail);
|
||||
await sendVerification(created.id, mail);
|
||||
} catch {
|
||||
// No-op: account is created; user can request a new link later.
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
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 };
|
||||
await prisma.items.update({ where: { id: itemId }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_update",
|
||||
description: `Updated item #${itemId} in room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
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({
|
||||
staffId: staff.id,
|
||||
action: "room_items_bulk_delete",
|
||||
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemId: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.items.delete({ where: { id: itemId } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_delete",
|
||||
description: `Deleted item #${itemId} from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function roomRconAction({ roomId, action }: { roomId: number; action: string }) {
|
||||
await requireStaff();
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
} else if (action === "kick") {
|
||||
await rcon.send("kickall", { room_id: roomId });
|
||||
} else if (action === "lock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||
} else if (action === "unlock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "open" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoom({ id }: { id: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_delete",
|
||||
description: `Deleted room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom({ id, ...data }: { id: number; name?: string; description?: string; state?: string; usersMax?: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_update",
|
||||
description: `Updated room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${id}`);
|
||||
}
|
||||
@@ -10,23 +10,16 @@ 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"];
|
||||
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
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 (!ALLOWED.includes(file.type))
|
||||
return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||
|
||||
const mimeExt: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/x-icon": "ico",
|
||||
"image/svg+xml": "svg",
|
||||
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
|
||||
"image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg",
|
||||
};
|
||||
const ext = mimeExt[file.type] ?? "png";
|
||||
const filename = `favicon-${Date.now()}.${ext}`;
|
||||
@@ -54,9 +47,7 @@ export async function saveFavicon(
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore if file doesn't exist */
|
||||
}
|
||||
} catch { /* ignore if file doesn't exist */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,9 +80,7 @@ export async function deleteFavicon(): Promise<{ success: boolean; error?: strin
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,12 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const MEDIA_DIR = "public/assets/images/media/logo";
|
||||
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
|
||||
const ext =
|
||||
file.type === "image/png"
|
||||
? "png"
|
||||
: file.type === "image/gif"
|
||||
? "gif"
|
||||
: file.type === "image/jpeg"
|
||||
? "jpg"
|
||||
: file.type === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
const ext = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
@@ -55,9 +44,7 @@ export async function saveLogo(
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogoFromUrl(
|
||||
gifUrl: string,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
export async function saveLogoFromUrl(gifUrl: string): 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}` };
|
||||
@@ -65,16 +52,7 @@ export async function saveLogoFromUrl(
|
||||
const contentType = res.headers.get("content-type") ?? "image/gif";
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
const ext =
|
||||
contentType === "image/png"
|
||||
? "png"
|
||||
: contentType === "image/gif"
|
||||
? "gif"
|
||||
: contentType === "image/jpeg"
|
||||
? "jpg"
|
||||
: contentType === "image/webp"
|
||||
? "webp"
|
||||
: "gif";
|
||||
const ext = contentType === "image/png" ? "png" : contentType === "image/gif" ? "gif" : contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "gif";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
|
||||
@@ -85,12 +85,8 @@ export async function postThread(formData: FormData): Promise<void> {
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
||||
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.trim()
|
||||
.slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
const subject = String(formData.get("subject") ?? "").trim().slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function deleteSoundtrack({ id }: { id: number }) {
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.delete({ where: { id } });
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
|
||||
export async function updateSoundtrack({ id, name, author, track, length }: { id: number; name: string; author: string; track: string; length: number }) {
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.update({
|
||||
where: { id },
|
||||
data: { name, author, track, length },
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
@@ -19,20 +19,14 @@ async function sessionUserId(): Promise<number> {
|
||||
function generateRecoveryCodes(): string[] {
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
codes.push(
|
||||
randomBytes(4)
|
||||
.toString("hex")
|
||||
.toUpperCase()
|
||||
.replace(/(.{4})/, "$1-"),
|
||||
);
|
||||
codes.push(randomBytes(4).toString("hex").toUpperCase().replace(/(.{4})/, "$1-"));
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** Verify a TOTP code OR a recovery code. Returns the updated recovery codes (minus used one) if a recovery code was used, or null on failure. */
|
||||
async function verifyTwoFactorCode(
|
||||
userId: number,
|
||||
code: string,
|
||||
userId: number, code: string,
|
||||
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -44,18 +38,12 @@ async function verifyTwoFactorCode(
|
||||
try {
|
||||
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
|
||||
if (verifyTotp(code, secret)) return { ok: true };
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
}
|
||||
} catch { /* fall through to recovery */ }
|
||||
|
||||
// Try recovery codes
|
||||
if (user.twoFactorRecoveryCodes) {
|
||||
let codes: string[];
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
codes = [];
|
||||
}
|
||||
try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { codes = []; }
|
||||
const idx = codes.indexOf(code);
|
||||
if (idx !== -1) {
|
||||
codes.splice(idx, 1);
|
||||
|
||||
@@ -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." };
|
||||
|
||||
@@ -53,9 +53,7 @@ export default async function AdminAchievements() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
{t("subtitle", { rows: achievements.length, categories: groups.size })}
|
||||
</p>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { rows: achievements.length, categories: groups.size })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,9 +70,7 @@ export default async function AdminAchievements() {
|
||||
<section key={category} className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{category}{" "}
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
|
||||
{rows.length}
|
||||
</span>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{rows.length}</span>
|
||||
</h2>
|
||||
<div className="admin-card p-0 overflow-x-auto">
|
||||
<table>
|
||||
|
||||
@@ -6,7 +6,11 @@ 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 {
|
||||
@@ -24,9 +28,7 @@ export default async function EditAd({ params }: { params: Promise<{ id: string
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit advertisement</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/ads">← Advertisements</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={updateAd} className="admin-card">
|
||||
|
||||
@@ -11,9 +11,7 @@ export default function NewAd() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New advertisement</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/ads">← Advertisements</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={createAd} className="admin-card">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -84,14 +84,24 @@ export default async function AdminAlerts() {
|
||||
|
||||
<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">
|
||||
|
||||
@@ -85,9 +85,7 @@ export default async function AdminApplications() {
|
||||
<>{t("userNumber", { id: a.userId })}</>
|
||||
)}
|
||||
</h3>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
|
||||
{t("rankLabel", { rank: a.rankId })}
|
||||
</span>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{t("rankLabel", { rank: a.rankId })}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 m-0 mb-2">
|
||||
{formatDate(a.createdAt)}
|
||||
|
||||
@@ -18,12 +18,10 @@ export default async function EditArticle({
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
|
||||
}).catch(() => null);
|
||||
if (!article) notFound();
|
||||
|
||||
return (
|
||||
@@ -34,12 +32,14 @@ export default async function EditArticle({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("form.title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-600 dark:text-red-400 mb-4">Error: {error}</p> : null}
|
||||
{error ? (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
@@ -55,9 +55,7 @@ export default async function EditArticle({
|
||||
/>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(article.id)} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("form.deleteArticle")}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">{t("form.deleteArticle")}</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,11 @@ 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");
|
||||
|
||||
@@ -16,12 +20,14 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("form.title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-600 dark:text-red-400 mb-4">Error: {error}</p> : null}
|
||||
{error ? (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm action={createArticle} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,11 @@ 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>
|
||||
@@ -35,7 +41,11 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-xs text-red-600 dark:text-red-400 mb-4">Error: {error}</p> : null}
|
||||
{error ? (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
||||
@@ -60,7 +70,9 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
<Link href={`/admin/articles/${a.id}`}>
|
||||
{a.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<code>{a.slug}</code>
|
||||
@@ -75,7 +87,10 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
||||
</Link>
|
||||
<form action={deleteArticle} className="m-0">
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -51,7 +51,9 @@ export default async function AdminBadges({
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)] -mt-2 mb-3">{t("uploadSubtitle")}</p>
|
||||
<p className="text-xs text-[var(--color-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 />
|
||||
@@ -98,9 +100,7 @@ export default async function AdminBadges({
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("catalogueTitle")} ({badges.length})
|
||||
</h2>
|
||||
<h2 className="admin-section-title">{t("catalogueTitle")} ({badges.length})</h2>
|
||||
{badges.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBadges")}</div>
|
||||
) : (
|
||||
@@ -123,7 +123,10 @@ export default async function AdminBadges({
|
||||
<td>{b.badgeName}</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400">{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"
|
||||
|
||||
@@ -75,9 +75,7 @@ export default async function AdminBans() {
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
<h2 className="admin-section-title">{t("activeBans")} ({bans.length})</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
@@ -97,16 +95,12 @@ export default async function AdminBans() {
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{b.type}</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">{t("permanent")}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{fromUnix(b.banExpire)}
|
||||
|
||||
@@ -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,16 +101,16 @@ 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>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
|
||||
{t("detail.campaignMeta", {
|
||||
id: campaign.id,
|
||||
days: campaign.totalDays,
|
||||
date: fmtDate(campaign.startTimestamp),
|
||||
})}
|
||||
{t("detail.campaignMeta", { id: campaign.id, days: campaign.totalDays, date: fmtDate(campaign.startTimestamp) })}
|
||||
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
|
||||
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
|
||||
</p>
|
||||
@@ -134,26 +138,10 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
|
||||
{rewards.map((r) => (
|
||||
<tr key={r.id} className="border-t border-[var(--border-subtle)]">
|
||||
<td>{r.id}</td>
|
||||
<td>
|
||||
{r.productName || <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{r.itemId > 0 ? r.itemId : "—"}
|
||||
</td>
|
||||
<td>
|
||||
{r.credits > 0 ? (
|
||||
r.credits
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.pixels > 0 ? (
|
||||
r.pixels
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{r.productName || <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{r.itemId > 0 ? r.itemId : "—"}</td>
|
||||
<td>{r.credits > 0 ? r.credits : <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
|
||||
<td>{r.pixels > 0 ? r.pixels : <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
|
||||
<td>
|
||||
{r.points > 0 ? (
|
||||
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
|
||||
@@ -161,11 +149,11 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{r.badge || "—"}</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{r.badge || "—"}
|
||||
</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{r.subscriptionType ? `${r.subscriptionType} · ${r.subscriptionDays}d` : "—"}
|
||||
{r.subscriptionType
|
||||
? `${r.subscriptionType} · ${r.subscriptionDays}d`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -52,7 +52,8 @@ export default async function AdminCalendar() {
|
||||
for (const r of rewards) {
|
||||
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
|
||||
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
|
||||
|
||||
@@ -64,9 +65,7 @@ export default async function AdminCalendar() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
{t("subtitle", { count: campaigns.length })}
|
||||
</p>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { count: campaigns.length })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +88,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} />
|
||||
@@ -99,9 +102,7 @@ export default async function AdminCalendar() {
|
||||
<div className="body">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
|
||||
<span
|
||||
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}
|
||||
>
|
||||
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
|
||||
{enabled ? t("statusEnabled") : t("statusDisabled")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Re-export barrel for backward compatibility.
|
||||
* Implementation moved to ./catalog-items-table/ directory.
|
||||
*/
|
||||
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'
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Image as ImageIcon } from 'lucide-react'
|
||||
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
|
||||
|
||||
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">
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const localUrl = getLocalIconUrl(classname)
|
||||
|
||||
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const img = e.target as HTMLImageElement
|
||||
const stage = img.dataset.fallback
|
||||
if (!stage && revision > 0) {
|
||||
img.dataset.fallback = 'cdn'
|
||||
img.src = getHabboCdnIconUrl(classname, revision)
|
||||
} else {
|
||||
img.dataset.fallback = 'none'
|
||||
img.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={localUrl}
|
||||
alt={classname}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
onError={handleError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { CatalogItemsTable } from './catalog-items-table'
|
||||
export { FurniIcon } from './furni-icon'
|
||||
export type { BaseItemData, CatalogItemData, InlineEdit } from './types'
|
||||
export { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from './types'
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Loader2, Music } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
getCachedSongPickerOptions,
|
||||
loadSongPickerOptions,
|
||||
type SoundtrackOption,
|
||||
} from '@/lib/client-cache/song-picker-cache'
|
||||
|
||||
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
|
||||
// callsites keep working. New code should import from the lib module directly.
|
||||
export { invalidateSongPickerCache } from '@/lib/client-cache/song-picker-cache'
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
onChange: (id: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function formatLength(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function SongPicker({ value, onChange, disabled }: Props) {
|
||||
const initial = getCachedSongPickerOptions()
|
||||
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? [])
|
||||
const [loading, setLoading] = useState(!initial)
|
||||
|
||||
useEffect(() => {
|
||||
if (getCachedSongPickerOptions()) return
|
||||
let cancelled = false
|
||||
loadSongPickerOptions()
|
||||
.then((items) => {
|
||||
if (!cancelled) setOptions(items)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOptions([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) => onChange(parseInt(v, 10) || 0)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="No song" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">
|
||||
<span className="text-muted-foreground">— No song —</span>
|
||||
</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
<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.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{formatLength(opt.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
export interface BaseItemData {
|
||||
id: number
|
||||
spriteId: number
|
||||
publicName: string
|
||||
itemName: string
|
||||
type: string
|
||||
width: number
|
||||
length: number
|
||||
stackHeight: number
|
||||
allowStack: number
|
||||
allowSit: number
|
||||
allowLay: number
|
||||
allowWalk: number
|
||||
allowGift: number
|
||||
allowTrade: number
|
||||
allowRecycle: number
|
||||
allowMarketplaceSell: number
|
||||
allowInventoryStack: number
|
||||
interactionType: string
|
||||
interactionModesCount: number
|
||||
vendingIds: string
|
||||
customparams: string
|
||||
effectIdMale: number
|
||||
effectIdFemale: number
|
||||
clothingOnWalk: string
|
||||
}
|
||||
|
||||
export interface CatalogItemData {
|
||||
id: number
|
||||
catalogName: string
|
||||
itemIds: string
|
||||
costCredits: number
|
||||
costPoints: number
|
||||
pointsType: number
|
||||
amount: number
|
||||
limitedSells: number
|
||||
limitedStack: number
|
||||
orderNumber: number
|
||||
offerId: number
|
||||
songId: number
|
||||
haveOffer: string
|
||||
clubOnly: string
|
||||
extradata: string
|
||||
baseName: string
|
||||
baseItemName: string
|
||||
spriteId: number
|
||||
baseItem: BaseItemData | null
|
||||
}
|
||||
|
||||
export interface InlineEdit {
|
||||
costCredits: number
|
||||
costPoints: number
|
||||
pointsType: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
|
||||
0: { label: 'Duckets', color: 'text-blue-500' },
|
||||
5: { label: 'Diamonds', color: 'text-purple-500' },
|
||||
101: { label: 'Snowflakes', color: 'text-cyan-400' },
|
||||
103: { label: 'Hearts', color: 'text-pink-500' },
|
||||
104: { label: 'Stars', color: 'text-amber-400' },
|
||||
105: { label: 'Shells', color: 'text-orange-400' },
|
||||
}
|
||||
|
||||
export const DEFAULT_NEW_ITEM = {
|
||||
catalogName: '',
|
||||
itemIds: '0',
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: 1,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: '1',
|
||||
clubOnly: '0',
|
||||
extradata: '',
|
||||
}
|
||||
|
||||
export function getPointsLabel(type: number) {
|
||||
return POINTS_TYPES[type] || { label: `Type ${type}`, color: 'text-gray-500' }
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,41 +0,0 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CatalogDetailLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<Skeleton className="h-5 w-40 mt-2" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-24 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+153
-114
File diff suppressed because it is too large.
Load diff
@@ -1,61 +0,0 @@
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BcPageDetail } from '../bc-manager'
|
||||
|
||||
export default async function BcPageDetailPage({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const { id: idStr } = await paramsPromise
|
||||
const id = parseInt(idStr, 10)
|
||||
if (Number.isNaN(id)) notFound()
|
||||
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
||||
|
||||
const bcPage = await prisma.catalog_pages_bc.findUnique({ where: { id } })
|
||||
if (!bcPage) notFound()
|
||||
|
||||
const bcItems = await prisma.catalog_items_bc.findMany({
|
||||
where: { page_id: id },
|
||||
orderBy: { order_number: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<BcPageDetail
|
||||
page={{
|
||||
id: bcPage.id,
|
||||
parentId: bcPage.parent_id,
|
||||
caption: bcPage.caption,
|
||||
pageLayout: bcPage.page_layout,
|
||||
iconColor: bcPage.icon_color,
|
||||
iconImage: bcPage.icon_image,
|
||||
orderNum: bcPage.order_num,
|
||||
visible: bcPage.visible,
|
||||
enabled: bcPage.enabled,
|
||||
pageHeadline: bcPage.page_headline,
|
||||
pageTeaser: bcPage.page_teaser,
|
||||
pageSpecial: bcPage.page_special ?? '',
|
||||
pageText1: bcPage.page_text1 ?? '',
|
||||
pageText2: bcPage.page_text2 ?? '',
|
||||
pageTextDetails: bcPage.page_text_details ?? '',
|
||||
pageTextTeaser: bcPage.page_text_teaser ?? '',
|
||||
}}
|
||||
items={bcItems.map((i) => ({
|
||||
id: i.id,
|
||||
pageId: i.page_id,
|
||||
itemIds: i.item_ids,
|
||||
catalogName: i.catalog_name,
|
||||
orderNumber: i.order_number,
|
||||
extradata: i.extradata,
|
||||
}))}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function BuilderClubPage() {
|
||||
redirect('/admin/catalog?catalog=bc')
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,85 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { FolderTree, HardHat, List, Package } from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CatalogViewToggleProps {
|
||||
currentView: string
|
||||
catalogType?: string
|
||||
}
|
||||
|
||||
export function CatalogViewToggle({ currentView, catalogType = 'normal' }: CatalogViewToggleProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
function setView(view: string) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (view === 'tree') {
|
||||
params.delete('view')
|
||||
} else {
|
||||
params.set('view', view)
|
||||
}
|
||||
router.push(`?${params.toString()}`)
|
||||
}
|
||||
|
||||
function setCatalog(type: string) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (type === 'normal') {
|
||||
params.delete('catalog')
|
||||
} else {
|
||||
params.set('catalog', type)
|
||||
}
|
||||
params.delete('view') // reset to tree when switching
|
||||
router.push(`?${params.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Catalog type selector */}
|
||||
<div className="flex items-center rounded-md border bg-card">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-r-none', catalogType === 'normal' && 'bg-accent')}
|
||||
onClick={() => setCatalog('normal')}
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
Catalog
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-l-none', catalogType === 'bc' && 'bg-accent')}
|
||||
onClick={() => setCatalog('bc')}
|
||||
>
|
||||
<HardHat className="mr-1.5 h-4 w-4" />
|
||||
Builder Club
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* View mode selector */}
|
||||
<div className="flex items-center rounded-md border bg-card">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-r-none', currentView === 'tree' && 'bg-accent')}
|
||||
onClick={() => setView('tree')}
|
||||
>
|
||||
<FolderTree className="mr-1.5 h-4 w-4" />
|
||||
Tree
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-l-none', currentView === 'table' && 'bg-accent')}
|
||||
onClick={() => setView('table')}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
Table
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CatalogLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree skeleton */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-56" />
|
||||
</div>
|
||||
<div className="p-2 space-y-1">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-1.5"
|
||||
style={{ paddingLeft: `${(i % 3) * 20 + 8}px` }}
|
||||
>
|
||||
<Skeleton className="h-5 w-5 shrink-0" />
|
||||
<Skeleton className="h-5 w-5 shrink-0" />
|
||||
<Skeleton className="h-4 flex-1 max-w-48" />
|
||||
<Skeleton className="h-5 w-8 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+119
-380
File diff suppressed because it is too large.
Load diff
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user