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
+5949
-28144
No files matched your search
@@ -8,12 +8,12 @@ Features full public-facing website, administrative panel, NextAuth authenticati
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| Component | Version |
|
| Component | Version |
|
||||||
| --------------- | ---------------------------------- |
|
|---|---|
|
||||||
| Node.js | >= 22 |
|
| Node.js | >= 22 |
|
||||||
| pnpm | >= 10.33.4 |
|
| pnpm | >= 10.33.4 |
|
||||||
| MySQL / MariaDB | 8.0+ / 10.6+ |
|
| MySQL / MariaDB | 8.0+ / 10.6+ |
|
||||||
| Redis | Optional (caching / rate limiting) |
|
| Redis | Optional (caching / rate limiting) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,7 +240,6 @@ pnpm jobs:worker
|
|||||||
```
|
```
|
||||||
|
|
||||||
Scheduled tasks:
|
Scheduled tasks:
|
||||||
|
|
||||||
- **Daily at 03:00** — Emulator JAR backup (when `EMULATOR_JAR_PATH` and `EMULATOR_BACKUP_DIR` are configured)
|
- **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
|
- **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
|
## Database Architecture
|
||||||
|
|
||||||
| Component | Type | Migrations |
|
| Component | Type | Migrations |
|
||||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
|---|---|---|
|
||||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — read-only for CMS |
|
| 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) |
|
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (9 files) |
|
||||||
| Migration tracking | `cms_migrations` table | Auto-created |
|
| Migration tracking | `cms_migrations` table | Auto-created |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -25,7 +25,10 @@ export default tseslint.config(
|
|||||||
|
|
||||||
"no-console": "off",
|
"no-console": "off",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"@typescript-eslint/consistent-type-imports": "error",
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||||
@@ -39,6 +42,7 @@ export default tseslint.config(
|
|||||||
|
|
||||||
"security/detect-object-injection": "warn",
|
"security/detect-object-injection": "warn",
|
||||||
"security/detect-non-literal-fs-filename": "warn",
|
"security/detect-non-literal-fs-filename": "warn",
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-3
@@ -52,11 +52,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/assets/(.*)",
|
source: "/assets/(.*)",
|
||||||
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
|
headers: [
|
||||||
|
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/images/(.*)",
|
source: "/images/(.*)",
|
||||||
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
|
headers: [
|
||||||
|
{ key: "Cache-Control", value: "public, max-age=86400" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
@@ -66,4 +70,4 @@ const nextConfig: NextConfig = {
|
|||||||
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
+1
-6
@@ -72,11 +72,6 @@
|
|||||||
"prisma",
|
"prisma",
|
||||||
"@prisma/client",
|
"@prisma/client",
|
||||||
"@prisma/engines"
|
"@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",
|
"version": "1.0.0",
|
||||||
"description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS."
|
"description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS."
|
||||||
},
|
},
|
||||||
"servers": [{ "url": "/api", "description": "Local API" }],
|
"servers": [
|
||||||
"security": [{ "bearerAuth": [], "sessionAuth": [] }],
|
{ "url": "/api", "description": "Local API" }
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{ "bearerAuth": [], "sessionAuth": [] }
|
||||||
|
],
|
||||||
"components": {
|
"components": {
|
||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
"bearerAuth": {
|
"bearerAuth": {
|
||||||
@@ -50,11 +54,7 @@
|
|||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Online count",
|
"description": "Online count",
|
||||||
"content": {
|
"content": { "application/json": { "schema": { "type": "object", "properties": { "count": { "type": "integer" } } } } }
|
||||||
"application/json": {
|
|
||||||
"schema": { "type": "object", "properties": { "count": { "type": "integer" } } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
(function () {
|
(function(){
|
||||||
try {
|
try {
|
||||||
var s = localStorage.getItem("theme");
|
var s=localStorage.getItem('theme');
|
||||||
var dd = document.querySelector('meta[name="theme-default-dark"]');
|
var dd=document.querySelector('meta[name="theme-default-dark"]');
|
||||||
var defaultDark = dd ? dd.getAttribute("content") === "true" : false;
|
var defaultDark=dd?dd.getAttribute('content')==='true':false;
|
||||||
if (s === "dark" || (!s && defaultDark)) document.documentElement.classList.add("dark");
|
if(s==='dark'||(!s&&defaultDark))document.documentElement.classList.add('dark');
|
||||||
var nc = localStorage.getItem("navbarColor"),
|
var nc=localStorage.getItem('navbarColor'),
|
||||||
nt = localStorage.getItem("navbarTextColor");
|
nt=localStorage.getItem('navbarTextColor');
|
||||||
if (nc) document.documentElement.style.setProperty("--color-navbar", nc);
|
if(nc)document.documentElement.style.setProperty('--color-navbar',nc);
|
||||||
if (nt) document.documentElement.style.setProperty("--color-navbar-text", nt);
|
if(nt)document.documentElement.style.setProperty('--color-navbar-text',nt);
|
||||||
} catch (e) {}
|
}catch(e){}
|
||||||
})();
|
})();
|
||||||
+1
-4
@@ -45,10 +45,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
fetch(req)
|
fetch(req)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const copy = res.clone();
|
const copy = res.clone();
|
||||||
caches
|
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
||||||
.open(CACHE)
|
|
||||||
.then((c) => c.put(req, copy))
|
|
||||||
.catch(() => {});
|
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
|
.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 mysql = await import("mysql2/promise");
|
||||||
const conn = await mysql.createConnection(url);
|
const conn = await mysql.createConnection(url);
|
||||||
try {
|
try {
|
||||||
const [rows] = await conn.execute(`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`);
|
const [rows] = await conn.execute(
|
||||||
|
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
||||||
|
);
|
||||||
return new Set((rows as { migration: string }[]).map((r) => r.migration));
|
return new Set((rows as { migration: string }[]).map((r) => r.migration));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
@@ -77,7 +79,10 @@ async function apply(migration: MigrationFile): Promise<void> {
|
|||||||
await conn.execute(stmt);
|
await conn.execute(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.execute(`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`, [migration.id]);
|
await conn.execute(
|
||||||
|
`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`,
|
||||||
|
[migration.id],
|
||||||
|
);
|
||||||
console.log(`[migrate] Applied: ${migration.name}`);
|
console.log(`[migrate] Applied: ${migration.name}`);
|
||||||
} finally {
|
} finally {
|
||||||
await conn.end();
|
await conn.end();
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ function decryptCbc(payload: string, key: Buffer, serialize = true): string {
|
|||||||
value: string;
|
value: string;
|
||||||
mac: string;
|
mac: string;
|
||||||
};
|
};
|
||||||
const expected = createHmac("sha256", key)
|
const expected = createHmac("sha256", key).update(json.iv + json.value).digest("hex");
|
||||||
.update(json.iv + json.value)
|
|
||||||
.digest("hex");
|
|
||||||
const a = Buffer.from(expected, "hex");
|
const a = Buffer.from(expected, "hex");
|
||||||
const b = Buffer.from(json.mac, "hex");
|
const b = Buffer.from(json.mac, "hex");
|
||||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
@@ -61,8 +59,8 @@ function isAlreadyGcm(payload: string, key: Buffer): boolean {
|
|||||||
try {
|
try {
|
||||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||||
if (!json.tag && !json.mac) return false; // can't determine format
|
if (!json.tag && !json.mac) return false; // can't determine format
|
||||||
if (json.tag) return true; // has authTag => GCM
|
if (json.tag) return true; // has authTag => GCM
|
||||||
return false; // has mac => CBC
|
return false; // has mac => CBC
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
|||||||
|
|
||||||
export async function createAd(formData: FormData): Promise<void> {
|
export async function createAd(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const image = String(formData.get("image") ?? "")
|
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -42,9 +40,7 @@ export async function updateAd(formData: FormData): Promise<void> {
|
|||||||
const raw = String(formData.get("id") ?? "");
|
const raw = String(formData.get("id") ?? "");
|
||||||
if (!/^\d+$/.test(raw)) return;
|
if (!/^\d+$/.test(raw)) return;
|
||||||
const id = BigInt(raw);
|
const id = BigInt(raw);
|
||||||
const image = String(formData.get("image") ?? "")
|
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import { rcon } from "@/lib/services/rcon";
|
|||||||
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
|
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = String(formData.get("message") ?? "").trim().slice(0, 1000);
|
||||||
.trim()
|
|
||||||
.slice(0, 1000);
|
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -53,16 +53,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
await prisma.websiteArticles.update({
|
await prisma.websiteArticles.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
title: String(formData.get("title") ?? "")
|
title: String(formData.get("title") ?? "").trim().slice(0, 255),
|
||||||
.trim()
|
shortStory: String(formData.get("shortStory") ?? "").trim().slice(0, 255),
|
||||||
.slice(0, 255),
|
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
fullStory: String(formData.get("fullStory") ?? "").trim(),
|
fullStory: String(formData.get("fullStory") ?? "").trim(),
|
||||||
image: String(formData.get("image") ?? "")
|
image: String(formData.get("image") ?? "").trim().slice(0, 255),
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
|||||||
await requireStaff();
|
await requireStaff();
|
||||||
|
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const code = String(formData.get("code") ?? "")
|
const code = String(formData.get("code") ?? "").trim().slice(0, 32);
|
||||||
.trim()
|
|
||||||
.slice(0, 32);
|
|
||||||
if (!(userId > 0) || code.length === 0) return;
|
if (!(userId > 0) || code.length === 0) return;
|
||||||
|
|
||||||
// Fire the emulator command so the badge appears live for online users.
|
// 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> {
|
export async function createBan(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const reason =
|
const reason = String(formData.get("reason") ?? "").trim().slice(0, 200) || "Banned";
|
||||||
String(formData.get("reason") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 200) || "Banned";
|
|
||||||
const hours = Number(formData.get("hours"));
|
const hours = Number(formData.get("hours"));
|
||||||
const type = String(formData.get("type"));
|
const type = String(formData.get("type"));
|
||||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
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> {
|
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
|
||||||
const subject = String(formData.get("subject") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const body = String(formData.get("body") ?? "");
|
const body = String(formData.get("body") ?? "");
|
||||||
const variablesRaw = String(formData.get("variables") ?? "").trim();
|
const variablesRaw = String(formData.get("variables") ?? "").trim();
|
||||||
const isActive = formData.get("isActive") != null;
|
const isActive = formData.get("isActive") != null;
|
||||||
@@ -40,9 +36,7 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const subject = String(formData.get("subject") ?? "")
|
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const body = String(formData.get("body") ?? "");
|
const body = String(formData.get("body") ?? "");
|
||||||
const variablesRaw = String(formData.get("variables") ?? "").trim();
|
const variablesRaw = String(formData.get("variables") ?? "").trim();
|
||||||
const isActive = formData.get("isActive") != null;
|
const isActive = formData.get("isActive") != null;
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const key = String(formData.get("key") ?? "")
|
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
|
||||||
.trim()
|
|
||||||
.slice(0, 100);
|
|
||||||
const value = String(formData.get("value") ?? "").slice(0, 512);
|
const value = String(formData.get("value") ?? "").slice(0, 512);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.emulatorSettings.upsert({
|
await prisma.emulatorSettings.upsert({
|
||||||
@@ -26,9 +24,7 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const key = String(formData.get("key") ?? "")
|
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
|
||||||
.trim()
|
|
||||||
.slice(0, 100);
|
|
||||||
const value = String(formData.get("value") ?? "").slice(0, 4096);
|
const value = String(formData.get("value") ?? "").slice(0, 4096);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.emulatorTexts.upsert({
|
await prisma.emulatorTexts.upsert({
|
||||||
|
|||||||
+12
-38
@@ -17,29 +17,16 @@ function parsePosition(value: FormDataEntryValue | null): number {
|
|||||||
|
|
||||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const content = String(formData.get("content") ?? "").trim();
|
const content = String(formData.get("content") ?? "").trim();
|
||||||
if (!name || !content) return;
|
if (!name || !content) return;
|
||||||
|
|
||||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
|
||||||
const buttonText = String(formData.get("buttonText") ?? "")
|
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
|
||||||
.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 =
|
const buttonBorderColor =
|
||||||
String(formData.get("buttonBorderColor") ?? "")
|
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
|
||||||
.trim()
|
|
||||||
.slice(0, 16) || "#facc15";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||||
@@ -76,29 +63,16 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const content = String(formData.get("content") ?? "").trim();
|
const content = String(formData.get("content") ?? "").trim();
|
||||||
if (!name || !content) return;
|
if (!name || !content) return;
|
||||||
|
|
||||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
|
||||||
const buttonText = String(formData.get("buttonText") ?? "")
|
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
|
||||||
.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 =
|
const buttonBorderColor =
|
||||||
String(formData.get("buttonBorderColor") ?? "")
|
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
|
||||||
.trim()
|
|
||||||
.slice(0, 16) || "#facc15";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.update({
|
await prisma.websiteHelpCenterCategories.update({
|
||||||
|
|||||||
@@ -11,13 +11,9 @@ import { prisma } from "@/lib/prisma";
|
|||||||
export async function upsertPermission(formData: FormData): Promise<void> {
|
export async function upsertPermission(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
|
|
||||||
const permission = String(formData.get("permission") ?? "")
|
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const minRank = Number(formData.get("minRank"));
|
const minRank = Number(formData.get("minRank"));
|
||||||
const descriptionRaw = String(formData.get("description") ?? "")
|
const descriptionRaw = String(formData.get("description") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
||||||
|
|
||||||
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
|
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ import { requireStaff } from "@/lib/admin/guard";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
function parseIp(formData: FormData): string {
|
function parseIp(formData: FormData): string {
|
||||||
return String(formData.get("ipAddress") ?? "")
|
return String(formData.get("ipAddress") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAsn(formData: FormData): string | null {
|
function parseAsn(formData: FormData): string | null {
|
||||||
const asn = String(formData.get("asn") ?? "")
|
const asn = String(formData.get("asn") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
return asn || null;
|
return asn || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ const KEY_MIN_RANK = "min_maintenance_login_rank";
|
|||||||
|
|
||||||
const COMMENTS: Record<string, string> = {
|
const COMMENTS: Record<string, string> = {
|
||||||
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
|
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
|
||||||
[KEY_MESSAGE]: "The maintenance message displayed to users while maintenance is activated",
|
[KEY_MESSAGE]:
|
||||||
[KEY_MIN_RANK]: "The minimum rank required to login to the hotel during maintenance",
|
"The maintenance message displayed to users while maintenance is activated",
|
||||||
|
[KEY_MIN_RANK]:
|
||||||
|
"The minimum rank required to login to the hotel during maintenance",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export async function uploadMedia(formData: FormData): Promise<void> {
|
|||||||
await requireStaff();
|
await requireStaff();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file || file.size === 0) return;
|
if (!file || file.size === 0) return;
|
||||||
if (file.size > MAX_SIZE) return;
|
if (file.size > MAX_SIZE) throw new Error("File too large (max 5MB)");
|
||||||
if (!ALLOWED.includes(file.type)) return;
|
if (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
|
||||||
|
|
||||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// 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> {
|
export async function createPermission(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const permission = String(formData.get("permission") ?? "")
|
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const minRank = parseMinRank(formData);
|
const minRank = parseMinRank(formData);
|
||||||
const description = String(formData.get("description") ?? "").trim() || null;
|
const description = String(formData.get("description") ?? "").trim() || null;
|
||||||
if (!permission) return;
|
if (!permission) return;
|
||||||
@@ -54,9 +52,7 @@ export async function updatePermission(formData: FormData): Promise<void> {
|
|||||||
const raw = String(formData.get("id") ?? "");
|
const raw = String(formData.get("id") ?? "");
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const id = BigInt(raw);
|
const id = BigInt(raw);
|
||||||
const permission = String(formData.get("permission") ?? "")
|
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const minRank = parseMinRank(formData);
|
const minRank = parseMinRank(formData);
|
||||||
const description = String(formData.get("description") ?? "").trim() || null;
|
const description = String(formData.get("description") ?? "").trim() || null;
|
||||||
if (!permission) return;
|
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 { revalidatePath } from 'next/cache';
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from '@/lib/admin/guard';
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from '@/lib/services/staff-activity';
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||||
try {
|
try {
|
||||||
const id = BigInt(raw.trim());
|
const id = BigInt(raw.trim());
|
||||||
return id > 0n ? id : null;
|
return id > 0n ? id : null;
|
||||||
@@ -23,21 +23,21 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
|
|||||||
*/
|
*/
|
||||||
export async function deleteShout(formData: FormData): Promise<void> {
|
export async function deleteShout(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const id = parseId(formData.get("id"));
|
const id = parseId(formData.get('id'));
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioShouts.delete({ where: { id } });
|
await prisma.radioShouts.delete({ where: { id } });
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio.shout.delete",
|
action: 'radio.shout.delete',
|
||||||
description: `Deleted radio shout #${id}`,
|
description: `Deleted radio shout #${id}`,
|
||||||
targetType: "radio_shout",
|
targetType: 'radio_shout',
|
||||||
targetId: Number(id),
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Row may already be gone; ignore so the action does not throw.
|
// 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> {
|
export async function createCategory(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const badge = String(formData.get("badge") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
|
||||||
const badge = String(formData.get("badge") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const priorityRaw = Number(formData.get("priority"));
|
const priorityRaw = Number(formData.get("priority"));
|
||||||
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1;
|
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1;
|
||||||
if (!name || !badge) return;
|
if (!name || !badge) return;
|
||||||
@@ -47,27 +43,16 @@ export async function createValue(formData: FormData): Promise<void> {
|
|||||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||||
if (!categoryId) return;
|
if (!categoryId) return;
|
||||||
|
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const furnitureIcon = String(formData.get("furnitureIcon") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
|
||||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!name || !furnitureIcon) return;
|
if (!name || !furnitureIcon) return;
|
||||||
|
|
||||||
const itemIdRaw = Number(formData.get("itemId"));
|
const itemIdRaw = Number(formData.get("itemId"));
|
||||||
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||||
|
|
||||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
const creditValueRaw = String(formData.get("creditValue") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const currencyValueRaw = String(formData.get("currencyValue") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
const currencyType = String(formData.get("currencyType") ?? "diamonds").trim().slice(0, 255) || "diamonds";
|
||||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const currencyType =
|
|
||||||
String(formData.get("currencyType") ?? "diamonds")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || "diamonds";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteRareValues.create({
|
await prisma.websiteRareValues.create({
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
export async function createSetting(formData: FormData): Promise<void> {
|
export async function createSetting(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const key = String(formData.get("key") ?? "")
|
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const value = String(formData.get("value") ?? "");
|
const value = String(formData.get("value") ?? "");
|
||||||
const comment = String(formData.get("comment") ?? "")
|
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.websiteSetting.upsert({
|
await prisma.websiteSetting.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
|
|||||||
+10
-32
@@ -30,9 +30,7 @@ function reqUInt(formData: FormData, key: string): number {
|
|||||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
|
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -40,24 +38,15 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
|||||||
const created = await prisma.websiteShopArticles.create({
|
const created = await prisma.websiteShopArticles.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
info: String(formData.get("info") ?? "")
|
info: String(formData.get("info") ?? "").trim().slice(0, 255),
|
||||||
.trim()
|
iconUrl: String(formData.get("icon") ?? "").trim().slice(0, 255),
|
||||||
.slice(0, 255),
|
color: String(formData.get("color") ?? "").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"),
|
costs: reqUInt(formData, "costs"),
|
||||||
giveRank: optUInt(formData, "giveRank"),
|
giveRank: optUInt(formData, "giveRank"),
|
||||||
credits: optUInt(formData, "credits"),
|
credits: optUInt(formData, "credits"),
|
||||||
duckets: optUInt(formData, "duckets"),
|
duckets: optUInt(formData, "duckets"),
|
||||||
diamonds: optUInt(formData, "diamonds"),
|
diamonds: optUInt(formData, "diamonds"),
|
||||||
badges:
|
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
|
||||||
String(formData.get("badges") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
position: reqUInt(formData, "position"),
|
position: reqUInt(formData, "position"),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -85,9 +74,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
|||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -95,24 +82,15 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
info: String(formData.get("info") ?? "")
|
info: String(formData.get("info") ?? "").trim().slice(0, 255),
|
||||||
.trim()
|
iconUrl: String(formData.get("icon") ?? "").trim().slice(0, 255),
|
||||||
.slice(0, 255),
|
color: String(formData.get("color") ?? "").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"),
|
costs: reqUInt(formData, "costs"),
|
||||||
giveRank: optUInt(formData, "giveRank"),
|
giveRank: optUInt(formData, "giveRank"),
|
||||||
credits: optUInt(formData, "credits"),
|
credits: optUInt(formData, "credits"),
|
||||||
duckets: optUInt(formData, "duckets"),
|
duckets: optUInt(formData, "duckets"),
|
||||||
diamonds: optUInt(formData, "diamonds"),
|
diamonds: optUInt(formData, "diamonds"),
|
||||||
badges:
|
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
|
||||||
String(formData.get("badges") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
position: reqUInt(formData, "position"),
|
position: reqUInt(formData, "position"),
|
||||||
updatedAt: new Date(),
|
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> {
|
export async function createVoucher(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
|
|
||||||
const code = String(formData.get("code") ?? "")
|
const code = String(formData.get("code") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const amount = Number(formData.get("amount"));
|
const amount = Number(formData.get("amount"));
|
||||||
const maxUsesRaw = Number(formData.get("maxUses"));
|
const maxUsesRaw = Number(formData.get("maxUses"));
|
||||||
const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||||
|
|||||||
@@ -29,17 +29,11 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
|||||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||||
const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== "";
|
const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== "";
|
||||||
|
|
||||||
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
const providerRaw = String(formData.get("vpn_provider") ?? "").trim().toLowerCase();
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||||
|
|
||||||
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
const apiKey = String(formData.get("vpn_api_key") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
const blockMessage = String(formData.get("vpn_block_message") ?? "").trim().slice(0, 255);
|
||||||
.slice(0, 255);
|
|
||||||
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeSetting(
|
await writeSetting(
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import { rcon } from "@/lib/services/rcon";
|
|||||||
|
|
||||||
export async function addWord(formData: FormData): Promise<void> {
|
export async function addWord(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const word = String(formData.get("word") ?? "")
|
const word = String(formData.get("word") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!word) return;
|
if (!word) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ function revalidate(): void {
|
|||||||
export async function createBox(formData: FormData): Promise<void> {
|
export async function createBox(formData: FormData): Promise<void> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
|
|
||||||
const title = String(formData.get("title") ?? "")
|
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -48,10 +46,7 @@ export async function createBox(formData: FormData): Promise<void> {
|
|||||||
const created = await prisma.websiteWriteableBoxes.create({
|
const created = await prisma.websiteWriteableBoxes.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
icon:
|
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
|
||||||
String(formData.get("icon") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
content: String(formData.get("content") ?? ""),
|
content: String(formData.get("content") ?? ""),
|
||||||
position: reqInt(formData, "position"),
|
position: reqInt(formData, "position"),
|
||||||
isActive: String(formData.get("isActive") ?? "") === "1",
|
isActive: String(formData.get("isActive") ?? "") === "1",
|
||||||
@@ -80,9 +75,7 @@ export async function updateBox(formData: FormData): Promise<void> {
|
|||||||
const id = parseId(formData);
|
const id = parseId(formData);
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
|
||||||
const title = String(formData.get("title") ?? "")
|
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,10 +83,7 @@ export async function updateBox(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
icon:
|
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
|
||||||
String(formData.get("icon") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
content: String(formData.get("content") ?? ""),
|
content: String(formData.get("content") ?? ""),
|
||||||
position: reqInt(formData, "position"),
|
position: reqInt(formData, "position"),
|
||||||
isActive: String(formData.get("isActive") ?? "") === "1",
|
isActive: String(formData.get("isActive") ?? "") === "1",
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ export async function applyStaff(formData: FormData): Promise<void> {
|
|||||||
const rankId = Number(formData.get("rankId"));
|
const rankId = Number(formData.get("rankId"));
|
||||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||||
|
|
||||||
const content = String(formData.get("content") ?? "")
|
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
|
||||||
.trim()
|
|
||||||
.slice(0, CONTENT_MAX);
|
|
||||||
if (content.length < CONTENT_MIN) return;
|
if (content.length < CONTENT_MIN) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -72,9 +70,7 @@ export async function applyTeam(formData: FormData): Promise<void> {
|
|||||||
const rankId = Number(formData.get("teamId"));
|
const rankId = Number(formData.get("teamId"));
|
||||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||||
|
|
||||||
const content = String(formData.get("content") ?? "")
|
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
|
||||||
.trim()
|
|
||||||
.slice(0, CONTENT_MAX);
|
|
||||||
if (content.length < CONTENT_MIN) return;
|
if (content.length < CONTENT_MIN) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ export async function postComment(formData: FormData): Promise<void> {
|
|||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
if (!Number.isFinite(userId)) return;
|
if (!Number.isFinite(userId)) return;
|
||||||
|
|
||||||
const comment = String(formData.get("comment") ?? "")
|
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
|
||||||
.trim()
|
|
||||||
.slice(0, COMMENT_MAX);
|
|
||||||
if (!comment) return;
|
if (!comment) return;
|
||||||
|
|
||||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
// 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);
|
const userId = Number(session.user.id);
|
||||||
if (!Number.isFinite(userId)) return;
|
if (!Number.isFinite(userId)) return;
|
||||||
|
|
||||||
const reaction = String(formData.get("reaction") ?? "")
|
const reaction = String(formData.get("reaction") ?? "").trim().toLowerCase();
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||||
|
|
||||||
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
|
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
|
* Validates username+password WITHOUT creating a session, and reports whether a
|
||||||
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
|
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
|
||||||
*/
|
*/
|
||||||
export async function precheckLogin(username: string, password: string): Promise<PrecheckResult> {
|
export async function precheckLogin(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<PrecheckResult> {
|
||||||
const u = String(username ?? "").trim();
|
const u = String(username ?? "").trim();
|
||||||
const p = String(password ?? "");
|
const p = String(password ?? "");
|
||||||
if (!u || !p) return "invalid";
|
if (!u || !p) return "invalid";
|
||||||
@@ -29,9 +32,11 @@ export async function precheckLogin(username: string, password: string): Promise
|
|||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||||
await checkLogin(p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", {
|
await checkLogin(
|
||||||
convertPasswords: false,
|
p,
|
||||||
});
|
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
|
||||||
|
{ convertPasswords: false },
|
||||||
|
);
|
||||||
return "invalid";
|
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). */
|
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
export async function hotelAlert(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
|
||||||
.trim()
|
|
||||||
.slice(0, 512);
|
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
try {
|
try {
|
||||||
await rcon.send("hotelalert", { message });
|
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> {
|
export async function alertUser(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
|
||||||
.trim()
|
|
||||||
.slice(0, 512);
|
|
||||||
if (!userId || !message) return;
|
if (!userId || !message) return;
|
||||||
try {
|
try {
|
||||||
await rcon.alertUser(userId, message);
|
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> {
|
export async function setMotto(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const motto = String(formData.get("motto") ?? "")
|
const motto = String(formData.get("motto") ?? "").trim().slice(0, 127);
|
||||||
.trim()
|
|
||||||
.slice(0, 127);
|
|
||||||
if (!userId || !motto) return;
|
if (!userId || !motto) return;
|
||||||
try {
|
try {
|
||||||
await rcon.setMotto(userId, motto);
|
await rcon.setMotto(userId, motto);
|
||||||
@@ -203,9 +197,7 @@ export async function sendGift(formData: FormData): Promise<void> {
|
|||||||
await requireStaff();
|
await requireStaff();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const itemId = Number(formData.get("itemId"));
|
const itemId = Number(formData.get("itemId"));
|
||||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
const message = String(formData.get("message") ?? "Here is a gift.").trim().slice(0, 255);
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!userId || !itemId) return;
|
if (!userId || !itemId) return;
|
||||||
try {
|
try {
|
||||||
await rcon.sendGift(userId, itemId, message);
|
await rcon.sendGift(userId, itemId, message);
|
||||||
|
|||||||
+30
-24
@@ -6,7 +6,6 @@ import { auth } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
|
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
|
||||||
@@ -79,34 +78,41 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
if (!buyer || buyer.credits < price) {
|
if (!buyer || buyer.credits < price) {
|
||||||
outcome = "credits";
|
outcome = "credits";
|
||||||
} else {
|
} else {
|
||||||
// Atomically deduct credits and persist the badge so a failure
|
// Deduct first, then grant. sendCurrency falls back to a direct DB
|
||||||
// between the two operations cannot orphan the user.
|
// 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) {
|
if (price > 0) {
|
||||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
await prisma.user.update({
|
||||||
await tx.user.update({
|
where: { id: userId },
|
||||||
where: { id: userId },
|
data: { credits: { decrement: price } },
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant the badge live so it appears immediately for online users.
|
// 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";
|
outcome = "bought";
|
||||||
boughtCode = code;
|
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`.
|
* Constant-time check that `token` matches the expected digest for `email`.
|
||||||
* Returns false on any length/format mismatch rather than throwing.
|
* Returns false on any length/format mismatch rather than throwing.
|
||||||
*/
|
*/
|
||||||
export async function isValidVerificationToken(email: string, token: string): Promise<boolean> {
|
export async function isValidVerificationToken(
|
||||||
|
email: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<boolean> {
|
||||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
||||||
const expected = await verificationToken(email);
|
const expected = await verificationToken(email);
|
||||||
const a = Buffer.from(expected, "utf8");
|
const a = Buffer.from(expected, "utf8");
|
||||||
@@ -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
|
* 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();
|
const normalised = email.trim().toLowerCase();
|
||||||
if (!normalised) return false;
|
if (!normalised) return false;
|
||||||
|
|
||||||
const token = await verificationToken(normalised);
|
const token = await verificationToken(normalised);
|
||||||
const base = env.APP_URL.replace(/\/+$/, "");
|
const base = env.APP_URL.replace(/\/+$/, "");
|
||||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
|
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(
|
||||||
|
normalised,
|
||||||
|
)}`;
|
||||||
|
|
||||||
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||||
|
|
||||||
@@ -69,9 +75,16 @@ export async function sendVerification(email: string): Promise<boolean> {
|
|||||||
</div>
|
</div>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
// `userId` referenced so a faithful caller signature isn't flagged unused.
|
||||||
|
void userId;
|
||||||
|
|
||||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
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"));
|
const profileId = Number(formData.get("profileId"));
|
||||||
if (!Number.isInteger(profileId) || profileId <= 0) return;
|
if (!Number.isInteger(profileId) || profileId <= 0) return;
|
||||||
|
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||||
.trim()
|
|
||||||
.slice(0, MESSAGE_MAX);
|
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
// 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;
|
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
||||||
|
|
||||||
const raw = {
|
const raw = {
|
||||||
title: String(formData.get("title") ?? "")
|
title: String(formData.get("title") ?? "").trim().slice(0, 255),
|
||||||
.trim()
|
content: String(formData.get("content") ?? "").trim().slice(0, 5000),
|
||||||
.slice(0, 255),
|
|
||||||
content: String(formData.get("content") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 5000),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = ticketSchema.safeParse(raw);
|
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";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } =
|
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } = vi.hoisted(
|
||||||
vi.hoisted(() => ({
|
() => ({
|
||||||
mockFindFirst: vi.fn(),
|
mockFindFirst: vi.fn(),
|
||||||
mockUpsert: vi.fn(),
|
mockUpsert: vi.fn(),
|
||||||
mockFindUnique: vi.fn(),
|
mockFindUnique: vi.fn(),
|
||||||
@@ -9,7 +9,8 @@ const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockS
|
|||||||
mockDelete: vi.fn(),
|
mockDelete: vi.fn(),
|
||||||
mockSendMail: vi.fn(),
|
mockSendMail: vi.fn(),
|
||||||
mockRedirect: vi.fn(),
|
mockRedirect: vi.fn(),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
redirect: (...args: unknown[]) => {
|
redirect: (...args: unknown[]) => {
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ function sha256(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requestReset(formData: FormData): Promise<void> {
|
export async function requestReset(formData: FormData): Promise<void> {
|
||||||
const email = String(formData.get("email") ?? "")
|
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
|
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
|
||||||
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok;
|
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok;
|
||||||
@@ -49,17 +47,13 @@ export async function requestReset(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resetPassword(formData: FormData): Promise<void> {
|
export async function resetPassword(formData: FormData): Promise<void> {
|
||||||
const email = String(formData.get("email") ?? "")
|
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const token = String(formData.get("token") ?? "").trim();
|
const token = String(formData.get("token") ?? "").trim();
|
||||||
const password = String(formData.get("password") ?? "");
|
const password = String(formData.get("password") ?? "");
|
||||||
|
|
||||||
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
|
// 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) {
|
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
|
||||||
redirect(
|
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`);
|
||||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
@@ -93,9 +87,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
redirect(
|
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`);
|
||||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
redirect("/login?reset=1");
|
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;
|
const STYLE_MAX = 5000;
|
||||||
|
|
||||||
function str(form: FormData, key: string, max: number): string {
|
function str(form: FormData, key: string, max: number): string {
|
||||||
return String(form.get(key) ?? "")
|
return String(form.get(key) ?? "").trim().slice(0, max);
|
||||||
.trim()
|
|
||||||
.slice(0, max);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,12 +12,8 @@ export async function submitRequest(formData: FormData): Promise<void> {
|
|||||||
const userId = Number(session?.user?.id);
|
const userId = Number(session?.user?.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||||
|
|
||||||
const songTitle = String(formData.get("songTitle") ?? "")
|
const songTitle = String(formData.get("songTitle") ?? "").trim().slice(0, SONG_MAX);
|
||||||
.trim()
|
const artist = String(formData.get("artist") ?? "").trim().slice(0, ARTIST_MAX);
|
||||||
.slice(0, SONG_MAX);
|
|
||||||
const artist = String(formData.get("artist") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, ARTIST_MAX);
|
|
||||||
if (!songTitle && !artist) return;
|
if (!songTitle && !artist) return;
|
||||||
|
|
||||||
const now = new Date();
|
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;
|
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
||||||
|
|
||||||
const raw = {
|
const raw = {
|
||||||
message: String(formData.get("message") ?? "")
|
message: String(formData.get("message") ?? "").trim().slice(0, 255),
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = shoutSchema.safeParse(raw);
|
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
|
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
|
||||||
* internal control-flow throw is never swallowed.
|
* internal control-flow throw is never swallowed.
|
||||||
*/
|
*/
|
||||||
const VALID_CURRENCIES = new Set<CurrencyName>(["credits", "duckets", "diamonds", "points"]);
|
const VALID_CURRENCIES = new Set<CurrencyName>([
|
||||||
|
"credits",
|
||||||
|
"duckets",
|
||||||
|
"diamonds",
|
||||||
|
"points",
|
||||||
|
]);
|
||||||
|
|
||||||
export async function claimReferral(_formData: FormData): Promise<void> {
|
export async function claimReferral(_formData: FormData): Promise<void> {
|
||||||
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error";
|
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" =
|
||||||
|
"error";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|||||||
@@ -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> {
|
export async function register(prevState: string | null, formData: FormData): Promise<string | null> {
|
||||||
const raw = {
|
const raw = {
|
||||||
username: String(formData.get("username") ?? "").trim(),
|
username: String(formData.get("username") ?? "").trim(),
|
||||||
mail: String(formData.get("mail") ?? "")
|
mail: String(formData.get("mail") ?? "").trim().toLowerCase(),
|
||||||
.trim()
|
|
||||||
.toLowerCase(),
|
|
||||||
password: String(formData.get("password") ?? ""),
|
password: String(formData.get("password") ?? ""),
|
||||||
look: String(formData.get("look") ?? "").trim() || DEFAULT_LOOK,
|
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).
|
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||||
if ((await checkVpn(ip)).blocked) {
|
if ((await checkVpn(ip)).blocked) {
|
||||||
return (
|
return (
|
||||||
(await siteSettings.get("vpn_block_message", "")) ||
|
await siteSettings.get("vpn_block_message", "")
|
||||||
"Registrations from VPN/proxy connections are not allowed."
|
) || "Registrations from VPN/proxy connections are not allowed.";
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
// 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);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
try {
|
try {
|
||||||
await prisma.user.create({
|
const created = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
password: await hashPassword(password),
|
password: await hashPassword(password),
|
||||||
@@ -103,7 +100,7 @@ export async function register(prevState: string | null, formData: FormData): Pr
|
|||||||
|
|
||||||
if (hasEmail) {
|
if (hasEmail) {
|
||||||
try {
|
try {
|
||||||
await sendVerification(mail);
|
await sendVerification(created.id, mail);
|
||||||
} catch {
|
} catch {
|
||||||
// No-op: account is created; user can request a new link later.
|
// 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 MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
||||||
|
|
||||||
export async function saveFavicon(
|
export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
formData: FormData,
|
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
|
||||||
try {
|
try {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||||
if (!ALLOWED.includes(file.type))
|
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||||
return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
|
||||||
|
|
||||||
const mimeExt: Record<string, string> = {
|
const mimeExt: Record<string, string> = {
|
||||||
"image/png": "png",
|
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
|
||||||
"image/jpeg": "jpg",
|
"image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg",
|
||||||
"image/gif": "gif",
|
|
||||||
"image/webp": "webp",
|
|
||||||
"image/x-icon": "ico",
|
|
||||||
"image/svg+xml": "svg",
|
|
||||||
};
|
};
|
||||||
const ext = mimeExt[file.type] ?? "png";
|
const ext = mimeExt[file.type] ?? "png";
|
||||||
const filename = `favicon-${Date.now()}.${ext}`;
|
const filename = `favicon-${Date.now()}.${ext}`;
|
||||||
@@ -54,9 +47,7 @@ export async function saveFavicon(
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
await unlink(oldPath);
|
await unlink(oldPath);
|
||||||
} catch {
|
} catch { /* ignore if file doesn't exist */ }
|
||||||
/* ignore if file doesn't exist */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,9 +80,7 @@ export async function deleteFavicon(): Promise<{ success: boolean; error?: strin
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
await unlink(oldPath);
|
await unlink(oldPath);
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,12 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
|
|
||||||
const MEDIA_DIR = "public/assets/images/media/logo";
|
const MEDIA_DIR = "public/assets/images/media/logo";
|
||||||
|
|
||||||
export async function saveLogo(
|
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
formData: FormData,
|
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
|
||||||
try {
|
try {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file) return { success: false, error: "No file provided" };
|
if (!file) return { success: false, error: "No file provided" };
|
||||||
|
|
||||||
const ext =
|
const ext = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||||
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 filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
const filePath = path.resolve(baseDir, filename);
|
const filePath = path.resolve(baseDir, filename);
|
||||||
@@ -55,9 +44,7 @@ export async function saveLogo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLogoFromUrl(
|
export async function saveLogoFromUrl(gifUrl: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
gifUrl: string,
|
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(gifUrl);
|
const res = await fetch(gifUrl);
|
||||||
if (!res.ok) return { success: false, error: `Failed to fetch GIF: ${res.status}` };
|
if (!res.ok) return { success: false, error: `Failed to fetch GIF: ${res.status}` };
|
||||||
@@ -65,16 +52,7 @@ export async function saveLogoFromUrl(
|
|||||||
const contentType = res.headers.get("content-type") ?? "image/gif";
|
const contentType = res.headers.get("content-type") ?? "image/gif";
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
|
||||||
const ext =
|
const ext = contentType === "image/png" ? "png" : contentType === "image/gif" ? "gif" : contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "gif";
|
||||||
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 filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
const filePath = path.resolve(baseDir, filename);
|
const filePath = path.resolve(baseDir, filename);
|
||||||
|
|||||||
@@ -85,12 +85,8 @@ export async function postThread(formData: FormData): Promise<void> {
|
|||||||
const guildId = Number(formData.get("guildId"));
|
const guildId = Number(formData.get("guildId"));
|
||||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
||||||
|
|
||||||
const subject = String(formData.get("subject") ?? "")
|
const subject = String(formData.get("subject") ?? "").trim().slice(0, SUBJECT_MAX);
|
||||||
.trim()
|
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||||
.slice(0, SUBJECT_MAX);
|
|
||||||
const message = String(formData.get("message") ?? "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, MESSAGE_MAX);
|
|
||||||
if (!subject || !message) return;
|
if (!subject || !message) return;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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[] {
|
function generateRecoveryCodes(): string[] {
|
||||||
const codes: string[] = [];
|
const codes: string[] = [];
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
codes.push(
|
codes.push(randomBytes(4).toString("hex").toUpperCase().replace(/(.{4})/, "$1-"));
|
||||||
randomBytes(4)
|
|
||||||
.toString("hex")
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/(.{4})/, "$1-"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return codes;
|
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. */
|
/** 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(
|
async function verifyTwoFactorCode(
|
||||||
userId: number,
|
userId: number, code: string,
|
||||||
code: string,
|
|
||||||
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
|
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -44,18 +38,12 @@ async function verifyTwoFactorCode(
|
|||||||
try {
|
try {
|
||||||
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
|
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
|
||||||
if (verifyTotp(code, secret)) return { ok: true };
|
if (verifyTotp(code, secret)) return { ok: true };
|
||||||
} catch {
|
} catch { /* fall through to recovery */ }
|
||||||
/* fall through to recovery */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try recovery codes
|
// Try recovery codes
|
||||||
if (user.twoFactorRecoveryCodes) {
|
if (user.twoFactorRecoveryCodes) {
|
||||||
let codes: string[];
|
let codes: string[];
|
||||||
try {
|
try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { codes = []; }
|
||||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
|
||||||
} catch {
|
|
||||||
codes = [];
|
|
||||||
}
|
|
||||||
const idx = codes.indexOf(code);
|
const idx = codes.indexOf(code);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
codes.splice(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
|
* voucher schema carries a single `amount`, granted as the website credits
|
||||||
* wallet currency.
|
* wallet currency.
|
||||||
*/
|
*/
|
||||||
export async function redeem(_prev: RedeemState, formData: FormData): Promise<RedeemState> {
|
export async function redeem(
|
||||||
|
_prev: RedeemState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<RedeemState> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { ok: false, message: "You must be signed in to redeem a voucher." };
|
return { ok: false, message: "You must be signed in to redeem a voucher." };
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ export default async function AdminAchievements() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
<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">
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { rows: achievements.length, categories: groups.size })}</p>
|
||||||
{t("subtitle", { rows: achievements.length, categories: groups.size })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,9 +70,7 @@ export default async function AdminAchievements() {
|
|||||||
<section key={category} className="mt-6">
|
<section key={category} className="mt-6">
|
||||||
<h2 className="admin-section-title">
|
<h2 className="admin-section-title">
|
||||||
{category}{" "}
|
{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)]">
|
<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>
|
||||||
{rows.length}
|
|
||||||
</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="admin-card p-0 overflow-x-auto">
|
<div className="admin-card p-0 overflow-x-auto">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function EditAd({ params }: { params: Promise<{ id: string }> }) {
|
export default async function EditAd({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
||||||
try {
|
try {
|
||||||
@@ -24,9 +28,7 @@ export default async function EditAd({ params }: { params: Promise<{ id: string
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit advertisement</h1>
|
<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">
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/ads">← Advertisements</Link></p>
|
||||||
<Link href="/admin/ads">← Advertisements</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form action={updateAd} className="admin-card">
|
<form action={updateAd} className="admin-card">
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ export default function NewAd() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New advertisement</h1>
|
<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">
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/ads">← Advertisements</Link></p>
|
||||||
<Link href="/admin/ads">← Advertisements</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form action={createAd} className="admin-card">
|
<form action={createAd} className="admin-card">
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export default async function AdminAds() {
|
|||||||
ads = [];
|
ads = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = ads[0]?.createdAt ? ads[0].createdAt.toISOString().slice(0, 10) : "—";
|
const latest = ads[0]?.createdAt
|
||||||
|
? ads[0].createdAt.toISOString().slice(0, 10)
|
||||||
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -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">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
|
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
|
||||||
<StatusCard label={t("critical")} value={critical} state={critical > 0 ? "danger" : "ok"} icon="🚨" />
|
<StatusCard
|
||||||
|
label={t("critical")}
|
||||||
|
value={critical}
|
||||||
|
state={critical > 0 ? "danger" : "ok"}
|
||||||
|
icon="🚨"
|
||||||
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("warnings")}
|
label={t("warnings")}
|
||||||
value={warnings}
|
value={warnings}
|
||||||
state={warnings > 0 ? "warn" : "neutral"}
|
state={warnings > 0 ? "warn" : "neutral"}
|
||||||
icon="⚠️"
|
icon="⚠️"
|
||||||
/>
|
/>
|
||||||
<StatusCard label={t("unread")} value={unread} state={unread > 0 ? "warn" : "ok"} icon="📨" />
|
<StatusCard
|
||||||
|
label={t("unread")}
|
||||||
|
value={unread}
|
||||||
|
state={unread > 0 ? "warn" : "ok"}
|
||||||
|
icon="📨"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={sendHotelAlert} className="admin-card mb-6">
|
<form action={sendHotelAlert} className="admin-card mb-6">
|
||||||
|
|||||||
@@ -85,9 +85,7 @@ export default async function AdminApplications() {
|
|||||||
<>{t("userNumber", { id: a.userId })}</>
|
<>{t("userNumber", { id: a.userId })}</>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
|
<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>
|
||||||
{t("rankLabel", { rank: a.rankId })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 m-0 mb-2">
|
<p className="text-xs text-gray-500 dark:text-gray-400 m-0 mb-2">
|
||||||
{formatDate(a.createdAt)}
|
{formatDate(a.createdAt)}
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ export default async function EditArticle({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const t = await getTranslations("pages.admin.articles");
|
const t = await getTranslations("pages.admin.articles");
|
||||||
const article = await prisma.websiteArticles
|
const article = await prisma.websiteArticles.findUnique({
|
||||||
.findUnique({
|
where: { id: BigInt(id) },
|
||||||
where: { id: BigInt(id) },
|
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
|
||||||
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
|
}).catch(() => null);
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,12 +32,14 @@ export default async function EditArticle({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("form.title")}</h1>
|
<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">
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
|
||||||
<Link href="/admin/articles">← {t("title")}</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
<ArticleForm
|
||||||
action={async (fd) => {
|
action={async (fd) => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -55,9 +55,7 @@ export default async function EditArticle({
|
|||||||
/>
|
/>
|
||||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||||
<input type="hidden" name="id" value={String(article.id)} />
|
<input type="hidden" name="id" value={String(article.id)} />
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">{t("form.deleteArticle")}</button>
|
||||||
{t("form.deleteArticle")}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { Newspaper } from "lucide-react";
|
|||||||
import { createArticle } from "@/actions/admin-articles";
|
import { createArticle } from "@/actions/admin-articles";
|
||||||
import { ArticleForm } from "@/components/admin/article-form";
|
import { ArticleForm } from "@/components/admin/article-form";
|
||||||
|
|
||||||
export default async function NewArticle({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
export default async function NewArticle({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const t = await getTranslations("pages.admin.articles");
|
const t = await getTranslations("pages.admin.articles");
|
||||||
|
|
||||||
@@ -16,12 +20,14 @@ export default async function NewArticle({ searchParams }: { searchParams: Promi
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("form.title")}</h1>
|
<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">
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
|
||||||
<Link href="/admin/articles">← {t("title")}</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<ArticleForm action={createArticle} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminArticles({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
export default async function AdminArticles({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const t = await getTranslations("pages.admin.articles");
|
const t = await getTranslations("pages.admin.articles");
|
||||||
const articles = await prisma.websiteArticles
|
const articles = await prisma.websiteArticles
|
||||||
@@ -18,7 +22,9 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
|||||||
})
|
})
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
|
|
||||||
const latest = articles[0]?.createdAt ? articles[0].createdAt.toISOString().slice(0, 10) : "—";
|
const latest = articles[0]?.createdAt
|
||||||
|
? articles[0].createdAt.toISOString().slice(0, 10)
|
||||||
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@@ -35,7 +41,11 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
|||||||
</Link>
|
</Link>
|
||||||
</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}
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
||||||
@@ -60,7 +70,9 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
|||||||
{articles.map((a) => (
|
{articles.map((a) => (
|
||||||
<tr key={String(a.id)}>
|
<tr key={String(a.id)}>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
<Link href={`/admin/articles/${a.id}`}>
|
||||||
|
{a.title}
|
||||||
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
<code>{a.slug}</code>
|
<code>{a.slug}</code>
|
||||||
@@ -75,7 +87,10 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
|||||||
</Link>
|
</Link>
|
||||||
<form action={deleteArticle} className="m-0">
|
<form action={deleteArticle} className="m-0">
|
||||||
<input type="hidden" name="id" value={String(a.id)} />
|
<input type="hidden" name="id" value={String(a.id)} />
|
||||||
<button type="submit" className="btn btn-danger">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-danger"
|
||||||
|
>
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -89,4 +104,4 @@ export default async function AdminArticles({ searchParams }: { searchParams: Pr
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,9 @@ export default async function AdminBadges({
|
|||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
|
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
|
||||||
<p className="text-xs text-[var(--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">
|
<form action={uploadBadge} className="admin-card">
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
<input type="file" name="file" accept=".gif,.png" required />
|
<input type="file" name="file" accept=".gif,.png" required />
|
||||||
@@ -98,9 +100,7 @@ export default async function AdminBadges({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">
|
<h2 className="admin-section-title">{t("catalogueTitle")} ({badges.length})</h2>
|
||||||
{t("catalogueTitle")} ({badges.length})
|
|
||||||
</h2>
|
|
||||||
{badges.length === 0 ? (
|
{badges.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noBadges")}</div>
|
<div className="admin-empty">{t("noBadges")}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -123,7 +123,10 @@ export default async function AdminBadges({
|
|||||||
<td>{b.badgeName}</td>
|
<td>{b.badgeName}</td>
|
||||||
<td className="text-sm text-gray-500 dark:text-gray-400">{b.badgeDescription}</td>
|
<td className="text-sm text-gray-500 dark:text-gray-400">{b.badgeDescription}</td>
|
||||||
<td>
|
<td>
|
||||||
<form action={giveBadge} className="flex gap-2 items-center">
|
<form
|
||||||
|
action={giveBadge}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
<input type="hidden" name="code" value={b.badgeKey} />
|
<input type="hidden" name="code" value={b.badgeKey} />
|
||||||
<input
|
<input
|
||||||
name="userId"
|
name="userId"
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ export default async function AdminBans() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">
|
<h2 className="admin-section-title">{t("activeBans")} ({bans.length})</h2>
|
||||||
{t("activeBans")} ({bans.length})
|
|
||||||
</h2>
|
|
||||||
{bans.length === 0 ? (
|
{bans.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noBans")}</div>
|
<div className="admin-empty">{t("noBans")}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -97,16 +95,12 @@ export default async function AdminBans() {
|
|||||||
<tr key={b.id}>
|
<tr key={b.id}>
|
||||||
<td>{b.userId}</td>
|
<td>{b.userId}</td>
|
||||||
<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)]">
|
<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>
|
||||||
{b.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{b.banReason || "—"}</td>
|
<td>{b.banReason || "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
{b.banExpire === 0 ? (
|
{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)]">
|
<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>
|
||||||
{t("permanent")}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
{fromUnix(b.banExpire)}
|
{fromUnix(b.banExpire)}
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ function fmtDate(ts: number): string {
|
|||||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminCalendarCampaign({ params }: { params: Promise<{ id: string }> }) {
|
export default async function AdminCalendarCampaign({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
const t = await getTranslations("pages.admin.calendar");
|
const t = await getTranslations("pages.admin.calendar");
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -97,16 +101,16 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
|
|||||||
<div className="admin-card flex items-center gap-5 mb-6">
|
<div className="admin-card flex items-center gap-5 mb-6">
|
||||||
{campaign.image ? (
|
{campaign.image ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img className="w-24 h-24 object-cover" src={campaign.image} alt={campaign.name} />
|
<img
|
||||||
|
className="w-24 h-24 object-cover"
|
||||||
|
src={campaign.image}
|
||||||
|
alt={campaign.name}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
|
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
|
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
|
||||||
{t("detail.campaignMeta", {
|
{t("detail.campaignMeta", { id: campaign.id, days: campaign.totalDays, date: fmtDate(campaign.startTimestamp) })}
|
||||||
id: campaign.id,
|
|
||||||
days: campaign.totalDays,
|
|
||||||
date: fmtDate(campaign.startTimestamp),
|
|
||||||
})}
|
|
||||||
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
|
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
|
||||||
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
|
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -134,26 +138,10 @@ export default async function AdminCalendarCampaign({ params }: { params: Promis
|
|||||||
{rewards.map((r) => (
|
{rewards.map((r) => (
|
||||||
<tr key={r.id} className="border-t border-[var(--border-subtle)]">
|
<tr key={r.id} className="border-t border-[var(--border-subtle)]">
|
||||||
<td>{r.id}</td>
|
<td>{r.id}</td>
|
||||||
<td>
|
<td>{r.productName || <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
|
||||||
{r.productName || <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}
|
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{r.itemId > 0 ? r.itemId : "—"}</td>
|
||||||
</td>
|
<td>{r.credits > 0 ? r.credits : <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">
|
<td>{r.pixels > 0 ? r.pixels : <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
|
||||||
{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>
|
<td>
|
||||||
{r.points > 0 ? (
|
{r.points > 0 ? (
|
||||||
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
|
`${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>
|
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
{r.badge || "—"}
|
{r.subscriptionType
|
||||||
</td>
|
? `${r.subscriptionType} · ${r.subscriptionDays}d`
|
||||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
: "—"}
|
||||||
{r.subscriptionType ? `${r.subscriptionType} · ${r.subscriptionDays}d` : "—"}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export default async function AdminCalendar() {
|
|||||||
for (const r of rewards) {
|
for (const r of rewards) {
|
||||||
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
|
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
|
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
|
||||||
|
|
||||||
@@ -64,9 +65,7 @@ export default async function AdminCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
<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">
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { count: campaigns.length })}</p>
|
||||||
{t("subtitle", { count: campaigns.length })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,7 +88,11 @@ export default async function AdminCalendar() {
|
|||||||
const enabled = c.enabled === "1";
|
const enabled = c.enabled === "1";
|
||||||
const count = rewardCounts.get(c.id) ?? 0;
|
const count = rewardCounts.get(c.id) ?? 0;
|
||||||
return (
|
return (
|
||||||
<Link key={c.id} href={`/admin/calendar/${c.id}`} className="admin-card hover article">
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
href={`/admin/calendar/${c.id}`}
|
||||||
|
className="admin-card hover article"
|
||||||
|
>
|
||||||
{c.image ? (
|
{c.image ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img className="article-img" src={c.image} alt={c.name} />
|
<img className="article-img" src={c.image} alt={c.name} />
|
||||||
@@ -99,9 +102,7 @@ export default async function AdminCalendar() {
|
|||||||
<div className="body">
|
<div className="body">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
|
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
|
||||||
<span
|
<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)]"}`}>
|
||||||
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")}
|
{enabled ? t("statusEnabled") : t("statusDisabled")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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