Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+27924 -5729

No files matched your search

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