Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise
CI / check (pull_request) Successful in 2m11s
CI / deploy (pull_request) Skipped
CI / e2e (pull_request) Skipped

This commit is contained in:
Simo committed 2026-09-04 20:08:49 +02:00
commit ce4a3ba32c
129 files changed
+4364 -1599

No files matched your search

+40 -8
View File
@@ -2,23 +2,55 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
async function removeApplication(id: bigint): Promise<boolean> {
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
return true;
} catch (error) {
logServerError("applications.delete_failed", error, { id: String(id) });
return false;
}
}
export async function dismissApplication(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await removeApplication(id);
await logStaffActivity({
staffId: staff.id,
action: "application_dismiss",
description: `Dismissed staff application #${id}`,
targetType: "staff_application",
targetId: Number(id),
});
revalidatePath("/admin/applications");
}
export async function approveApplication(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await removeApplication(id);
await logStaffActivity({
staffId: staff.id,
action: "application_approve",
description: `Approved staff application #${id}`,
targetType: "staff_application",
targetId: Number(id),
});
revalidatePath("/admin/applications");
}
+76 -4
View File
@@ -10,8 +10,27 @@ import {
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { slugify } from "@/lib/format";
import { PERMS } from "@/lib/permissions";
import { notify } from "@/lib/services/webhook";
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
type ArticleStatus = (typeof ARTICLE_STATUSES)[number];
function parseArticleStatus(raw: unknown): ArticleStatus {
const value = String(raw ?? "published").trim();
return (ARTICLE_STATUSES as readonly string[]).includes(value)
? (value as ArticleStatus)
: "published";
}
function parsePublishAt(raw: unknown): Date | null {
const value = String(raw ?? "").trim();
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
@@ -43,12 +62,21 @@ export async function createArticle(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
const rawSlug = String(formData.get("slug") ?? "").trim();
const status = parseArticleStatus(formData.get("status"));
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
if (!title) return;
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
redirect(
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
);
}
try {
const now = new Date();
const publishAt = parsePublishAt(rawPublishAt);
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
await db.insert(WebsiteArticles).values({
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
slug,
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
@@ -56,6 +84,16 @@ export async function createArticle(formData: FormData): Promise<void> {
userId: staff.id,
createdAt: now,
updatedAt: now,
status,
publishAt,
publishedAt: status === "published" ? now : null,
});
notify({
action: "news_publish",
actor: staff.username,
target: title,
details: slug,
});
} catch {
// Database error — re-render unchanged with error.
@@ -68,9 +106,28 @@ export async function createArticle(formData: FormData): Promise<void> {
export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const id = formPositiveBigInt(formData, "id");
if (!id) redirect("/admin/articles?error=Missing article id");
const rawSlug = String(formData.get("slug") ?? "").trim();
const status = parseArticleStatus(formData.get("status"));
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
redirect(
"/admin/articles?error=Scheduled articles need a valid publish date.",
);
}
try {
const publishAt = parsePublishAt(rawPublishAt);
const [existing] = await db
.select({
status: WebsiteArticles.status,
publishedAt: WebsiteArticles.publishedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
const publishedAt =
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
await db
.update(WebsiteArticles)
.set({
@@ -90,6 +147,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, 255),
status,
publishAt,
publishedAt,
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
@@ -101,8 +161,14 @@ export async function updateArticle(formData: FormData): Promise<void> {
}
export async function deleteArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const staff = await requirePermission(PERMS.NEWS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) redirect("/admin/articles?error=Missing article id");
const [article] = await db
.select({ title: WebsiteArticles.title })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
try {
await db.transaction(async (tx) => {
await tx
@@ -113,6 +179,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
.where(eq(WebsiteArticleComments.articleId, id));
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
});
notify({
action: "news_delete",
actor: staff.username,
target: article?.title ?? String(id),
});
} catch {
redirect("/admin/articles?error=Delete failed");
}
+89
View File
@@ -16,6 +16,33 @@ import {
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
const GUILD_STATES = [0, 1, 2] as const;
const GUILD_FORUM = ["0", "1"] as const;
const GUILD_FORUM_ACCESS = [
"EVERYONE",
"OWNER",
"ADMIN",
"MEMBER",
"NONE",
] as const;
const GUILD_MOD_ACCESS = ["ADMINS", "OWNER", "MEMBER", "NONE"] as const;
function parseGuildState(raw: unknown): number | null {
const value = Number(raw);
return (GUILD_STATES as readonly number[]).includes(value) ? value : null;
}
function parseEnum<T extends string>(
raw: unknown,
allowed: readonly T[],
fallback: T,
): T {
const value = String(raw ?? fallback).trim();
return (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
@@ -63,3 +90,65 @@ export async function disbandGuild(formData: FormData): Promise<void> {
});
revalidatePath("/admin/guilds");
}
export async function updateGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 50);
if (!name) return;
const description = String(formData.get("description") ?? "")
.trim()
.slice(0, 250);
const state = parseGuildState(formData.get("state"));
if (state === null) return;
const forum = parseEnum(formData.get("forum"), GUILD_FORUM, "0");
const readForum = parseEnum(
formData.get("readForum"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const postMessages = parseEnum(
formData.get("postMessages"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const postThreads = parseEnum(
formData.get("postThreads"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const modForum = parseEnum(
formData.get("modForum"),
GUILD_MOD_ACCESS,
"ADMINS",
);
await db
.update(Guilds)
.set({
name,
description,
state,
forum,
readForum,
postMessages,
postThreads,
modForum,
})
.where(eq(Guilds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "guild_update",
description: `Updated guild #${id}`,
targetType: "guild",
targetId: id,
});
revalidatePath("/admin/guilds");
revalidatePath(`/admin/guilds/${id}`);
}
+85
View File
@@ -115,3 +115,88 @@ export async function deleteValue(formData: FormData): Promise<void> {
}
revalidatePath("/admin/rare-values");
}
export async function updateCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.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;
try {
await db
.update(WebsiteRareValueCategories)
.set({ name, badge, priority })
.where(eq(WebsiteRareValueCategories.id, id));
} catch {
// Unique name collision or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function updateValue(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const categoryId = formPositiveBigInt(formData, "categoryId");
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
.normalize("NFC")
.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") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const currencyType =
String(formData.get("currencyType") ?? "diamonds")
.trim()
.slice(0, 255) || "diamonds";
try {
const sets: Record<string, unknown> = {
name,
itemId,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
};
if (categoryId) sets.categoryId = categoryId;
await db
.update(WebsiteRareValues)
.set(sets)
.where(eq(WebsiteRareValues.id, id));
} catch {
// DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
+55
View File
@@ -83,3 +83,58 @@ export async function deleteVoucher(input: {
return actionError("Could not delete voucher");
}
}
export async function updateVoucher(input: {
id: string;
code: string;
amount: number;
maxUses: number;
expiresAt?: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.SHOP_EDIT);
const id = positiveBigInt(String(input.id ?? "").trim());
if (!id) return actionError("Missing voucher id");
const code = String(input.code ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const amount = Number(input.amount);
const maxUsesRaw = Number(input.maxUses);
const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) {
return actionError("Code and a positive amount are required");
}
let expiresAt: Date | null = null;
const expiresRaw = String(input.expiresAt ?? "")
.normalize("NFC")
.trim();
if (expiresRaw) {
const parsed = new Date(expiresRaw);
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
}
try {
await db
.update(WebsiteShopVouchers)
.set({
code,
amount: Math.floor(amount),
maxUses,
expiresAt,
updatedAt: new Date(),
})
.where(eq(WebsiteShopVouchers.id, id));
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
logServerError("admin.voucher_update_failed", error, {
voucherId: String(id),
});
return actionError("Could not update voucher (code may already exist)");
}
}
+7 -1
View File
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
@@ -127,7 +128,12 @@ export async function buyBadge(formData: FormData): Promise<void> {
}
// Grant the badge live so it appears immediately for online users.
await rcon.giveBadge(userId, code).catch(() => {});
await rcon.giveBadge(userId, code).catch((error) =>
logServerError("drawbadge.give_failed", error, {
userId,
code,
}),
);
outcome = "bought";
boughtCode = code;
+11
View File
@@ -15,6 +15,7 @@ import { PERMS } from "@/lib/permissions";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook";
import {
createEventSchema,
eventPrizeSchema,
@@ -120,6 +121,11 @@ export const createEvent = adminAction(
targetId: eventId,
after: { title: ctx.data.title },
});
notify({
action: "event_create",
actor: ctx.session.user.username,
target: ctx.data.title,
});
return actionOk({ id: eventId });
},
);
@@ -155,6 +161,11 @@ export const updateEvent = adminAction(
before: { title: existing.title, status: existing.status },
after: data,
});
notify({
action: "event_update",
actor: ctx.session.user.username,
target: data.title ?? existing.title,
});
return actionOk({ id });
},
);
+10
View File
@@ -15,6 +15,7 @@ import {
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
import { notify } from "@/lib/services/webhook";
const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
@@ -160,6 +161,15 @@ export async function createTicket(formData: FormData): Promise<void> {
updatedAt: now,
});
outcome = "created";
const sessionUser = session?.user;
if (sessionUser?.name) {
notify({
action: "ticket_create",
actor: sessionUser.name,
target: ticketTitle,
});
}
}
}
}
+6 -1
View File
@@ -7,6 +7,7 @@ import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { db, PasswordReset, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email";
@@ -123,7 +124,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
await db
.delete(PasswordReset)
.where(eq(PasswordReset.email, email))
.catch(() => {});
.catch((error) =>
logServerError("password.reset_delete_tokens_failed", error, {
email,
}),
);
}
}
} catch {
+6
View File
@@ -27,6 +27,7 @@ import {
actionOk,
} from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook";
import {
createPollSchema,
pollQuestionPatchSchema,
@@ -53,6 +54,11 @@ export const createPoll = adminAction(
targetId: pollId,
after: { title: ctx.data.title },
});
notify({
action: "poll_create",
actor: ctx.session.user.username,
target: ctx.data.title,
});
return actionOk({ id: pollId });
},
);
+18 -1
View File
@@ -7,6 +7,7 @@ import { db, Items, Rooms } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { notify } from "@/lib/services/webhook";
export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
@@ -75,11 +76,17 @@ export async function roomRconAction({
roomId: number;
action: string;
}) {
await requirePermission(PERMS.ROOMS_EDIT);
const staff = await requirePermission(PERMS.ROOMS_EDIT);
if (action === "reload") {
await rcon.send("reloadroom", { room_id: roomId });
} else if (action === "kick") {
await rcon.send("kickall", { room_id: roomId });
notify({
action: "kick",
actor: staff.username,
target: String(roomId),
details: action,
});
} else if (action === "lock") {
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
} else if (action === "unlock") {
@@ -89,6 +96,11 @@ export async function roomRconAction({
export async function deleteRoom({ id }: { id: number }) {
const staff = await requirePermission(PERMS.ROOMS_DELETE);
const [room] = await db
.select({ name: Rooms.name })
.from(Rooms)
.where(eq(Rooms.id, id))
.limit(1);
await db.delete(Rooms).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
@@ -97,6 +109,11 @@ export async function deleteRoom({ id }: { id: number }) {
targetType: "room",
targetId: id,
});
notify({
action: "room_delete",
actor: staff.username,
target: room?.name ?? `#${id}`,
});
revalidatePath("/admin/rooms");
}
+7 -1
View File
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { creditsPerUnit } from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon";
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
@@ -183,7 +184,12 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
);
for (const code of badgeCodes) {
await rcon.giveBadge(userId, code).catch(() => {});
await rcon.giveBadge(userId, code).catch((error) =>
logServerError("shop.give_badge_failed", error, {
userId,
code,
}),
);
}
outcome = "bought";
+16 -2
View File
@@ -97,7 +97,7 @@ export const createUser = adminAction(
});
notify({
action: "user_edit",
action: "user_create",
actor: ctx.session.user.username,
target: username,
targetId: user.id,
@@ -450,6 +450,13 @@ export const muteUser = adminAction(
after: { duration: ctx.data.duration },
});
notify({
action: "hotel_alert",
actor: ctx.session.user.username,
target: _target.username,
details: "Muted",
});
return actionOk();
},
);
@@ -463,7 +470,7 @@ const unmuteSchema = z.object({
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank);
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.unmuteUser(ctx.data.userId);
if (!success)
throw new ActionError("Failed to unmute. Is the emulator running?");
@@ -475,6 +482,13 @@ export const unmuteUser = adminAction(
targetId: ctx.data.userId,
});
notify({
action: "hotel_alert",
actor: ctx.session.user.username,
target: target.username,
details: "Unmuted",
});
return actionOk();
},
);
+14
View File
@@ -1,4 +1,5 @@
import { desc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -14,6 +15,19 @@ import {
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.applyStaff");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
+14
View File
@@ -1,4 +1,5 @@
import { asc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -8,6 +9,19 @@ import { auth } from "@/lib/auth";
import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.applyTeam");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
+13 -1
View File
@@ -1,9 +1,21 @@
import { asc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, WebsiteBadges } from "@/lib/db";
export const metadata = { title: "Badges" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.badges");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
+14 -1
View File
@@ -1,8 +1,21 @@
import type { Metadata } from "next";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
export const metadata = { title: "Community" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.community");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
const LINKS = [
{
+10 -1
View File
@@ -1,9 +1,18 @@
import type { Metadata } from "next";
import { ContentCard } from "@/components/public/ui";
// Public API documentation. Static, hand-maintained from the routes that
// actually exist under src/app/api — keep this in sync when endpoints change.
export const metadata = { title: "API" };
export const metadata: Metadata = {
title: "Developers",
description: "Public API documentation for the hotel.",
openGraph: {
title: "Developers",
description: "Public API documentation for the hotel.",
type: "website",
},
};
type Method = "GET" | "POST" | "DELETE";
+10 -1
View File
@@ -1,4 +1,5 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { buyBadge } from "@/actions/draw-badge";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
@@ -10,7 +11,15 @@ import { siteSettings } from "@/lib/services/site-settings";
// Reads the live users + website_drawbadges tables and writes credits/badges on
// purchase — must never be statically rendered.
export const metadata = { title: "Draw a Badge" };
export const metadata: Metadata = {
title: "Draw a Badge",
description: "Draw a random badge and add it to your collection.",
openGraph: {
title: "Draw a Badge",
description: "Draw a random badge and add it to your collection.",
type: "website",
},
};
const DEFAULT_PRICE = 50;
+13 -1
View File
@@ -1,4 +1,5 @@
import { count, desc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
@@ -12,7 +13,18 @@ import {
import { excerpt, slugify } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Events" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.events");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
export default async function EventsPage() {
const t = await getTranslations("pages.events");
+13 -1
View File
@@ -1,4 +1,5 @@
import { asc, eq, inArray, or } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -9,7 +10,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { auth } from "@/lib/auth";
import { db, MessengerFriendships, User } from "@/lib/db";
export const metadata = { title: "Friends" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.friends");
return {
title: t("title"),
description: t("emptySubtitle"),
openGraph: {
title: t("title"),
description: t("emptySubtitle"),
type: "website",
},
};
}
type SearchParams = Promise<{ removed?: string; error?: string }>;
+13 -1
View File
@@ -1,11 +1,23 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, Guilds } from "@/lib/db";
import { excerpt } from "@/lib/format";
export const metadata = { title: "Guilds" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.guilds");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type GuildCard = {
id: number;
+15
View File
@@ -1,6 +1,21 @@
import { asc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.help");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
import { ContentCard, EmptyState } from "@/components/public/ui";
import {
db,
+10 -1
View File
@@ -1,12 +1,21 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import MotionPageWrapper from "@/components/motion-page-wrapper";
import { Navigation } from "@/components/navigation";
import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
const RadioPlayerGate = dynamic(
() => import("@/components/public/radio-player-gate"),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-12" />
),
},
);
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
+13 -1
View File
@@ -1,4 +1,5 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
@@ -10,7 +11,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
export const metadata = { title: "Leaderboard" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.leaderboard");
return {
title: t("title"),
description: t("subtitle", { currency: "credits" }),
openGraph: {
title: t("title"),
description: t("subtitle", { currency: "credits" }),
type: "website",
},
};
}
// Currency type ids (see UsersCurrency in src/db/schema.ts):
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
+13 -1
View File
@@ -1,10 +1,22 @@
import { desc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { db, MarketplaceItems, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Marketplace" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.marketplace");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// state == 1 → an active, unsold offer in the Arcturus marketplace.
const STATE_ACTIVE = 1;
+21 -3
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -56,7 +56,16 @@ export async function generateMetadata({
shortStory: WebsiteArticles.shortStory,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1)
.catch(() => []);
if (!article) return { title: "Article" };
@@ -108,7 +117,16 @@ export default async function ArticlePage({
updatedAt: WebsiteArticles.updatedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1)
.catch(() => []);
return row ?? null;
+26 -2
View File
@@ -1,14 +1,30 @@
import { count, desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
openGraph: {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
type: "website",
},
};
import { AmbientOrbs } from "@/components/ambient-orbs";
import { AnimatedCounter } from "@/components/animated-counter";
import { HomeLoginForm } from "@/components/auth/home-login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
@@ -235,7 +251,7 @@ export default async function Home() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
{th("online", { count: online, hotel: hotelName })}
<LiveOnlineCount initial={online} hotelName={hotelName} />
</div>
<h1
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
@@ -337,21 +353,25 @@ export default async function Home() {
value: users,
label: tp("statsCitizens"),
icon: "/assets/images/icons/friends.png",
live: false,
},
{
value: rooms,
label: tp("statsRooms"),
icon: "/assets/images/icons/catalog.png",
live: false,
},
{
value: online,
label: tp("statsOnline"),
icon: "/assets/images/icons/navigation/me.png",
live: true,
},
{
value: articles.length,
label: tp("statsArticles"),
icon: "/assets/images/icons/article.gif",
live: false,
},
].map((s) => (
<SurfaceCard
@@ -359,7 +379,11 @@ export default async function Home() {
className="relative p-3 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
<AnimatedCounter value={s.value} />
{s.live ? (
<LiveOnlineCounter initial={s.value} />
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<div
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
+13 -1
View File
@@ -1,4 +1,5 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
type LightboxPhoto,
@@ -9,7 +10,18 @@ import { cached } from "@/lib/cache";
import { CameraWeb, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Photos" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.photos");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type Photo = {
id: number;
+13 -1
View File
@@ -1,4 +1,5 @@
import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -6,7 +7,18 @@ import { db, WebsitePoll, WebsitePollQuestion } from "@/lib/db";
import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Polls" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.polls");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
export default async function PollsPage() {
const t = await getTranslations("pages.polls");
+15
View File
@@ -1,6 +1,21 @@
import { asc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.radio");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { db, RadioSchedules, User } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
+13 -1
View File
@@ -1,4 +1,5 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import {
@@ -12,7 +13,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
export const metadata = { title: "Rankings" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.rankings");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type TopUser = {
username: string;
+13 -1
View File
@@ -1,11 +1,23 @@
import { asc, count } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Rare values" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.rares");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type CategoryRow = {
id: bigint;
+9 -1
View File
@@ -5,7 +5,15 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { db, Rooms, User } from "@/lib/db";
export const metadata: Metadata = { title: "Search" };
export const metadata: Metadata = {
title: "Search",
description: "Search for users and rooms in the hotel.",
openGraph: {
title: "Search",
description: "Search for users and rooms in the hotel.",
type: "website",
},
};
type SearchParams = Promise<{ q?: string }>;
+13 -1
View File
@@ -1,4 +1,5 @@
import { asc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { buyShopArticle } from "@/actions/shop";
@@ -10,7 +11,18 @@ import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
import { excerpt } from "@/lib/format";
import { creditsPerUnit } from "@/lib/services/paypal";
export const metadata = { title: "Shop" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.shop");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
+13 -1
View File
@@ -1,11 +1,23 @@
import { asc, desc, eq, gte } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, User, WebsiteTeams } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Staff" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.staff");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type StaffMember = {
username: string;
+13 -1
View File
@@ -1,8 +1,8 @@
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
import { ArrowLeftRight } from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { RevenueChart } from "@/components/admin/revenue-chart";
import { StatsCard } from "@/components/admin/stats-card";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +16,18 @@ import {
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
const RevenueChart = dynamic(
() =>
import("@/components/admin/revenue-chart").then((m) => ({
default: m.RevenueChart,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
},
);
type EconomyData = {
totalCredits: number;
totalPixels: number;
+25 -2
View File
@@ -7,16 +7,39 @@ import {
TrendingUp,
Users,
} from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
const DashboardChart = dynamic(
() =>
import("@/components/admin/dashboard-chart").then((m) => ({
default: m.DashboardChart,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
},
);
const PeakHoursHeatmap = dynamic(
() =>
import("@/components/admin/peak-hours-heatmap").then((m) => ({
default: m.PeakHoursHeatmap,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
},
);
type AnalyticsData = {
totalUsers: number;
onlineUsers: number;
+8 -11
View File
@@ -1,13 +1,14 @@
import { desc, inArray } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications";
import { ApplicationActions } from "@/components/admin/application-actions";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteStaffApplications } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
type ApplicationRow = {
id: bigint;
@@ -31,7 +32,8 @@ export default async function AdminApplications() {
.from(WebsiteStaffApplications)
.orderBy(desc(WebsiteStaffApplications.createdAt))
.limit(100);
} catch {
} catch (error) {
logServerError("applications.query_failed", error);
applications = [];
}
@@ -45,8 +47,8 @@ export default async function AdminApplications() {
.from(User)
.where(inArray(User.id, ids));
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
} catch (error) {
logServerError("applications.user_lookup_failed", error);
}
}
@@ -93,12 +95,7 @@ export default async function AdminApplications() {
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
{a.content}
</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<Button type="submit" variant="destructive">
{t("dismiss")}
</Button>
</form>
<ApplicationActions id={String(a.id)} />
</article>
);
})}
+8 -1
View File
@@ -1,5 +1,12 @@
import { redirect } from "next/navigation";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function CatalogMaintenancePage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
export default function CatalogMaintenancePage() {
return <CatalogMaintenancePanel />;
}
+141 -1
View File
@@ -1,7 +1,7 @@
import { asc, count, eq, inArray } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { disbandGuild } from "@/actions/admin-guilds";
import { disbandGuild, updateGuild } from "@/actions/admin-guilds";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
@@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({
dateCreated: Guilds.dateCreated,
state: Guilds.state,
forum: Guilds.forum,
readForum: Guilds.readForum,
postMessages: Guilds.postMessages,
postThreads: Guilds.postThreads,
modForum: Guilds.modForum,
})
.from(Guilds)
.where(eq(Guilds.id, id))
@@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({
</p>
) : null}
{canEdit ? (
<section>
<h2 className="admin-section-title">{t("edit")}</h2>
<form action={updateGuild} className="space-y-3">
<input type="hidden" name="id" value={guild.id} />
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
<div>
<label htmlFor="guild-name" className="text-xs muted">
{t("formName")}
</label>
<input
id="guild-name"
type="text"
name="name"
maxLength={50}
defaultValue={guild.name}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
/>
</div>
<div>
<label htmlFor="guild-description" className="text-xs muted">
{t("formDescription")}
</label>
<input
id="guild-description"
type="text"
name="description"
maxLength={250}
defaultValue={guild.description}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
/>
</div>
<div>
<label htmlFor="guild-state" className="text-xs muted">
{t("formState")}
</label>
<select
id="guild-state"
name="state"
defaultValue={guild.state}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="0">0 — Normal</option>
<option value="1">1 — Locked</option>
<option value="2">2 — Exclusive</option>
</select>
</div>
<div>
<label htmlFor="guild-forum" className="text-xs muted">
{t("formForum")}
</label>
<select
id="guild-forum"
name="forum"
defaultValue={guild.forum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="0">0 — Off</option>
<option value="1">1 — On</option>
</select>
</div>
<div>
<label htmlFor="guild-readForum" className="text-xs muted">
{t("formReadForum")}
</label>
<select
id="guild-readForum"
name="readForum"
defaultValue={guild.readForum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-postMessages" className="text-xs muted">
{t("formPostMessages")}
</label>
<select
id="guild-postMessages"
name="postMessages"
defaultValue={guild.postMessages}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-postThreads" className="text-xs muted">
{t("formPostThreads")}
</label>
<select
id="guild-postThreads"
name="postThreads"
defaultValue={guild.postThreads}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-modForum" className="text-xs muted">
{t("formModForum")}
</label>
<select
id="guild-modForum"
name="modForum"
defaultValue={guild.modForum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="ADMINS">ADMINS</option>
<option value="OWNER">OWNER</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
</div>
<Button type="submit" variant="ghost" size="sm">
{t("save")}
</Button>
</form>
</section>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
<div className="admin-card">
<div className="text-xs muted">{t("colOwner")}</div>
+13 -1
View File
@@ -1,7 +1,19 @@
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { AdminMediaGrid } from "@/components/admin/media-grid";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const AdminMediaGrid = dynamic(
() =>
import("@/components/admin/media-grid").then((m) => ({
default: m.AdminMediaGrid,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
},
);
export default async function AdminMediaPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
@@ -104,6 +104,7 @@ export function ModActionsClient() {
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Enter user ID"
aria-label="User ID"
/>
</div>
@@ -144,6 +145,7 @@ export function ModActionsClient() {
onChange={(e) => setMuteDuration(e.target.value)}
className="w-24"
placeholder="Min"
aria-label="Mute duration in minutes"
/>
<Button
variant="outline"
@@ -215,6 +217,7 @@ export function ModActionsClient() {
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
placeholder="Enter room ID"
aria-label="Room ID"
/>
</div>
<Button
+4 -1
View File
@@ -13,6 +13,7 @@ import Link from "@/components/link";
import { unixNow } from "@/lib/bans";
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { logServerError } from "@/lib/server-log";
type RankCount = { rank: number; count: number };
@@ -37,7 +38,9 @@ export default async function AdminDashboard() {
online = onlineRow[0]?.total ?? 0;
articles = articlesRow[0]?.total ?? 0;
bans = bansRow[0]?.total ?? 0;
} catch {}
} catch (error) {
logServerError("admin.dashboard_stats_failed", error);
}
const dbOk = users >= 0;
const rankGroups = await db
+147 -32
View File
@@ -6,6 +6,8 @@ import {
createValue,
deleteCategory,
deleteValue,
updateCategory,
updateValue,
} from "@/actions/admin-rare-values";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
@@ -120,12 +122,49 @@ export default async function AdminRareValues() {
{t("itemCount", { count: rows.length })}
</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<Button type="submit" variant="destructive">
{t("deleteCategory")}
</Button>
</form>
<div className="flex gap-2 items-center">
<details className="group">
<summary className="cursor-pointer list-none text-xs font-bold text-[var(--admin-accent)] hover:underline">
{t("editCategory")}
</summary>
<form
action={updateCategory}
className="mt-2 flex gap-2 flex-wrap"
>
<input type="hidden" name="id" value={catId} />
<input
name="name"
defaultValue={cat.name}
placeholder={t("categoryForm.namePlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input
name="badge"
defaultValue={cat.badge}
placeholder={t("badgePlaceholder")}
required
/>
<input
name="priority"
type="number"
min={1}
defaultValue={cat.priority}
placeholder={t("priorityPlaceholder")}
className="w-[110px]"
/>
<Button type="submit" variant="default">
{t("categoryForm.save")}
</Button>
</form>
</details>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<Button type="submit" variant="destructive">
{t("deleteCategory")}
</Button>
</form>
</div>
</div>
<table style={{ marginTop: "0.75rem" }}>
@@ -141,32 +180,108 @@ export default async function AdminRareValues() {
</tr>
</thead>
<tbody>
{rows.map((v) => (
<tr key={String(v.id)}>
<td>{v.name}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.itemId ?? "—"}
</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{v.currencyType}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.furnitureIcon}
</td>
<td>
<form action={deleteValue}>
<input type="hidden" name="id" value={String(v.id)} />
<Button type="submit" variant="destructive">
✕
</Button>
</form>
</td>
</tr>
))}
{rows.map((v) => {
const vId = String(v.id);
return (
<tr key={vId}>
<td>{v.name}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.itemId ?? "—"}
</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{v.currencyType}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.furnitureIcon}
</td>
<td>
<div className="flex gap-1 items-center">
<details className="group">
<summary className="cursor-pointer list-none text-[0.72rem] font-bold text-[var(--admin-accent)] hover:underline">
{t("editValue")}
</summary>
<form
action={updateValue}
className="mt-2 flex gap-1 flex-wrap items-end"
>
<input type="hidden" name="id" value={vId} />
<input
type="hidden"
name="categoryId"
value={catId}
/>
<input
name="name"
defaultValue={v.name}
placeholder={t(
"itemForm.itemNamePlaceholder",
)}
required
className="min-w-[120px]"
/>
<input
name="itemId"
type="number"
min={1}
defaultValue={v.itemId ?? ""}
placeholder={t("itemForm.itemIdPlaceholder")}
className="w-[90px]"
/>
<input
name="furnitureIcon"
defaultValue={v.furnitureIcon}
placeholder={t("itemForm.iconPlaceholder")}
required
className="min-w-[120px]"
/>
<input
name="creditValue"
defaultValue={v.creditValue ?? ""}
placeholder={t("itemForm.creditsPlaceholder")}
className="w-[110px]"
/>
<input
name="currencyValue"
defaultValue={v.currencyValue ?? ""}
placeholder={t(
"itemForm.currencyValuePlaceholder",
)}
className="w-[110px]"
/>
<select
name="currencyType"
defaultValue={v.currencyType}
>
{CURRENCY_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<Button
type="submit"
variant="default"
className="text-xs"
>
{t("itemForm.save")}
</Button>
</form>
</details>
<form action={deleteValue}>
<input type="hidden" name="id" value={vId} />
<Button type="submit" variant="destructive">
✕
</Button>
</form>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{rows.length === 0 ? (
+8 -1
View File
@@ -1,6 +1,13 @@
import { redirect } from "next/navigation";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
export default function StudioCatalogMaintenancePage() {
return (
<div className="min-h-0">
<CatalogMaintenancePanel />
+131 -9
View File
@@ -2,7 +2,11 @@
import { useTranslations } from "next-intl";
import { useState } from "react";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import {
createVoucher,
deleteVoucher,
updateVoucher,
} from "@/actions/admin-vouchers";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
import { StatusCard } from "@/components/admin/dashboard";
import { CurrencyIcon } from "@/components/shared/currency-icon";
@@ -33,10 +37,15 @@ export function VouchersClient({
const t = useTranslations("pages.admin.vouchers");
const { run, isPending } = useServerAction();
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
const [editingVoucher, setEditingVoucher] = useState<VoucherRow | null>(null);
const [code, setCode] = useState("");
const [amount, setAmount] = useState("");
const [maxUses, setMaxUses] = useState("1");
const [expiresAt, setExpiresAt] = useState("");
const [editCode, setEditCode] = useState("");
const [editAmount, setEditAmount] = useState("");
const [editMaxUses, setEditMaxUses] = useState("1");
const [editExpiresAt, setEditExpiresAt] = useState("");
function handleCreate(e: React.FormEvent) {
e.preventDefault();
@@ -72,6 +81,36 @@ export function VouchersClient({
});
}
function handleStartEdit(v: VoucherRow) {
setEditingVoucher(v);
setEditCode(v.code);
setEditAmount(String(v.amount));
setEditMaxUses(String(v.maxUses));
setEditExpiresAt(
v.expiresAt ? new Date(v.expiresAt).toISOString().slice(0, 16) : "",
);
}
function handleUpdate(e: React.FormEvent) {
e.preventDefault();
if (!editingVoucher || !canEdit) return;
run(
() =>
updateVoucher({
id: editingVoucher.id,
code: editCode,
amount: Number(editAmount),
maxUses: Number(editMaxUses),
expiresAt: editExpiresAt || undefined,
}),
{
successMessage: t("updated"),
errorMessage: t("updateError"),
onSuccess: () => setEditingVoucher(null),
},
);
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
@@ -158,6 +197,79 @@ export function VouchersClient({
</form>
)}
{canEdit && editingVoucher && (
<form onSubmit={handleUpdate} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>
{t("edit")} — {editingVoucher.code}
</h3>
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.code")}
</span>
<input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
placeholder={t("form.codePlaceholder")}
maxLength={255}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.amount")}
</span>
<input
value={editAmount}
onChange={(e) => setEditAmount(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.maxUses")}
</span>
<input
value={editMaxUses}
onChange={(e) => setEditMaxUses(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.expiresAt")}
</span>
<input
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
type="datetime-local"
disabled={isPending}
/>
</label>
</div>
<div className="flex items-center gap-2">
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? t("form.saving") : t("form.save")}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setEditingVoucher(null)}
>
{t("form.cancel")}
</Button>
</div>
</form>
)}
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({vouchers.length})
@@ -216,14 +328,24 @@ export function VouchersClient({
</td>
{canEdit && (
<td>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() => handleStartEdit(v)}
>
{t("edit")}
</Button>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</Button>
</div>
</td>
)}
</tr>
+23 -8
View File
@@ -28,6 +28,12 @@ interface BatchItem {
classname: string;
}
/** Maximum retries for fetching furnidata before giving up. */
const FURNITDATA_MAX_RETRIES = 3;
/** Base delay between retries (ms). */
const FURNITDATA_RETRY_DELAY = 1_000;
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
@@ -44,14 +50,23 @@ export const POST = withAdmin(
if (!source) return apiError("Source not found", 404);
// Fetch furnidata once so we can resolve entries by classname.
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
try {
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
} catch (err) {
return apiError(
`Failed to fetch furnidata: ${(err as Error).message}`,
502,
);
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>> = [];
for (let attempt = 1; attempt <= FURNITDATA_MAX_RETRIES; attempt++) {
try {
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
break;
} catch (err) {
if (attempt < FURNITDATA_MAX_RETRIES) {
await new Promise((resolve) =>
setTimeout(resolve, FURNITDATA_RETRY_DELAY),
);
continue;
}
return apiError(
`Failed to fetch furnidata after ${FURNITDATA_MAX_RETRIES} attempts: ${(err as Error).message}`,
502,
);
}
}
const entryMap = new Map(allEntries.map((e) => [e.classname, e]));
@@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logAudit } from "@/lib/services/audit";
import {
cloneSingleFurni,
@@ -50,7 +51,11 @@ export const POST = withAdmin(
const entries = await fetchSourceFurnidata(source.furnidataUrl);
const byClassname = new Map(entries.map((e) => [e.classname, e]));
sourceFurniDataMap.set(source.id, byClassname);
} catch {}
} catch (error) {
logServerError("clone-import.furnidata_prefetch_failed", error, {
source: source.id,
});
}
}
// Collect all missing items using pre-fetched data
+245
View File
@@ -0,0 +1,245 @@
import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import {
db,
Guilds,
Rooms,
User,
WebsiteArticles,
WebsiteRareValues,
WebsiteShopArticles,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
export type AdminSearchResult = {
type: string;
id: number | string;
title: string;
subtitle: string;
url: string;
};
const PER_TYPE = 5;
const MAX_TOTAL = 20;
const MAX_QUERY_LENGTH = 64;
/** Escape LIKE wildcards so user input can't widen the match. */
export function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
}
export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD },
async (request, context) => {
const limited = await rateLimit(
`admin-search:${context.session.user.id}`,
30,
60_000,
);
if (!limited.ok) {
return NextResponse.json(
{ ok: false, error: "Too many requests" },
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
);
}
const q = (request.nextUrl.searchParams.get("q") || "")
.trim()
.slice(0, MAX_QUERY_LENGTH);
if (q.length < 2) {
return apiOk({ results: [] });
}
const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] =
await Promise.all([
db
.select({
id: User.id,
title: User.username,
subtitle: User.mail,
})
.from(User)
.where(
or(
like(User.username, pattern),
like(User.mail, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.limit(PER_TYPE),
db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.limit(PER_TYPE),
]);
const groupMap: Record<
string,
{ type: string; items: Array<AdminSearchResult> }
> = {
users: { type: "users", items: [] },
articles: { type: "articles", items: [] },
rooms: { type: "rooms", items: [] },
guilds: { type: "guilds", items: [] },
shop: { type: "shop", items: [] },
rareValues: { type: "rareValues", items: [] },
};
for (const r of users) {
groupMap.users.items.push({
type: "users",
id: r.id,
title: r.title,
subtitle: r.subtitle || "",
url: `/admin/users/show/${r.id}`,
});
}
for (const r of articles) {
groupMap.articles.items.push({
type: "articles",
id: Number(r.id),
title: r.title,
subtitle: r.subtitle,
url: `/admin/articles/${r.id}`,
});
}
for (const r of rooms) {
groupMap.rooms.items.push({
type: "rooms",
id: r.id,
title: r.title || `Room #${r.id}`,
subtitle: r.subtitle || "",
url: `/admin/rooms/${r.id}`,
});
}
for (const r of guilds) {
groupMap.guilds.items.push({
type: "guilds",
id: r.id,
title: r.title || `Guild #${r.id}`,
subtitle: r.subtitle || "",
url: `/admin/guilds/${r.id}`,
});
}
for (const r of shopArticles) {
groupMap.shop.items.push({
type: "shop",
id: Number(r.id),
title: r.title,
subtitle: r.subtitle || "",
url: `/admin/shop/${r.id}`,
});
}
for (const r of rareValues) {
groupMap.rareValues.items.push({
type: "rareValues",
id: Number(r.id),
title: r.title,
subtitle: r.subtitle != null ? `Item #${r.subtitle}` : "",
url: `/admin/rare-values/${r.id}`,
});
}
const results: AdminSearchResult[] = [];
const order = [
"users",
"articles",
"rooms",
"guilds",
"shop",
"rareValues",
];
// Round-robin so no single type starves the others when capped.
for (let i = 0; results.length < MAX_TOTAL; i++) {
let added = false;
for (const key of order) {
const item = groupMap[key]?.items[i];
if (item && results.length < MAX_TOTAL) {
results.push(item);
added = true;
}
}
if (!added) break;
}
return apiOk({ results });
},
);
+11 -2
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { apiJson } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
@@ -30,7 +30,16 @@ export async function GET(
updatedAt: WebsiteArticles.updatedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1);
if (!article) {
+13 -2
View File
@@ -1,4 +1,4 @@
import { count, desc } from "drizzle-orm";
import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
@@ -17,8 +17,18 @@ export async function GET(req: Request) {
apiCacheKey(`articles:${page}:${perPage}`),
60,
async () => {
const publishedFilter = and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
);
const [totalRows, articles] = await Promise.all([
db.select({ total: count() }).from(WebsiteArticles),
db
.select({ total: count() })
.from(WebsiteArticles)
.where(publishedFilter),
db
.select({
id: WebsiteArticles.id,
@@ -29,6 +39,7 @@ export async function GET(req: Request) {
createdAt: WebsiteArticles.createdAt,
})
.from(WebsiteArticles)
.where(publishedFilter)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(take)
.offset(skip),
+10 -1
View File
@@ -1,4 +1,4 @@
import { count, desc, eq } from "drizzle-orm";
import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { env } from "@/env";
import { apiJson } from "@/lib/api";
import { db, User, WebsiteArticles } from "@/lib/db";
@@ -24,6 +24,15 @@ export async function GET(_req: Request) {
createdAt: WebsiteArticles.createdAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(4),
db.select({ total: count() }).from(User).where(eq(User.online, "1")),
+98
View File
@@ -0,0 +1,98 @@
// Server-Sent Events stream of the live online user count for the in-game
// client toolbar. Pushes { count } every ~5s so the counter updates in
// real-time without the client polling. Closes cleanly when the client
// disconnects.
//
// The first event is sent immediately; subsequent events push the cached
// count (10s TTL in the REST endpoint) so repeated reads share one query.
import { count, eq } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { rcon } from "@/lib/services/rcon";
async function fetchOnlineCount(): Promise<number> {
try {
return await cached("online_count", 10_000, async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
} catch {
return 0;
}
}
// Emulator (RCON) reachability, cached and shared across all subscribers so an
// N-client toolbar doesn't fire N pings every interval. Falls back to graceful
// degradation on failure so the stream never errors out.
async function fetchEmulatorStatus(): Promise<boolean> {
try {
return await cached("emulator_rcon_status", 15_000, () =>
rcon.send("ping", null).catch(() => false),
);
} catch {
return false;
}
}
export async function GET(req: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let closed = false;
const send = async () => {
if (closed) return;
const [count, emulator] = await Promise.all([
fetchOnlineCount(),
fetchEmulatorStatus(),
]);
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ count, emulator })}\n\n`),
);
} catch {
closed = true;
}
};
// Initial event immediately, then on an interval.
await send();
const interval = setInterval(() => void send(), 5_000);
// SSE comment as a keep-alive ping between data events.
const ping = setInterval(() => {
if (!closed) {
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
closed = true;
}
}
}, 25_000);
const stop = () => {
closed = true;
clearInterval(interval);
clearInterval(ping);
try {
controller.close();
} catch {
/* already closed */
}
};
req.signal.addEventListener("abort", stop);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
},
});
}
+308 -96
View File
@@ -1,6 +1,7 @@
"use client";
import { LogOut } from "lucide-react";
import { useTranslations } from "next-intl";
import {
type ReactNode,
useCallback,
@@ -12,45 +13,78 @@ import {
import { signOutAndRevokeTicket } from "@/actions/sessions";
import Link from "@/components/link";
import { buildClientLoginUrl } from "@/lib/client-url";
import { useEventSource } from "@/lib/use-event-source";
function ToolbarBtn({
onClick,
title,
label,
children,
href,
}: {
onClick?: () => void;
title: string;
label: string;
children: ReactNode;
href?: string;
}) {
// Custom floating tooltip: appears on hover/focus regardless of where the
// element is on screen. Kept in one component for a consistent look across
// the bar (replaces the browser's slow, untitled `title` bubbles).
const [tip, setTip] = useState(false);
const cls =
"w-7 h-7 sm:w-8 sm:h-8 rounded-lg border-2 cursor-pointer flex items-center justify-center shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl active:translate-y-0";
"group relative w-8 h-8 sm:w-9 sm:h-9 rounded-xl cursor-pointer flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary";
const style = {
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
border:
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
"linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
boxShadow:
"0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
} as const;
if (href) {
return (
<Link href={href} className={cls} style={style} title={title}>
{children}
</Link>
);
}
return (
const trigger = href ? (
<Link
href={href}
className={cls}
style={style}
aria-label={label}
onMouseEnter={() => setTip(true)}
onMouseLeave={() => setTip(false)}
onFocus={() => setTip(true)}
onBlur={() => setTip(false)}
>
{children}
</Link>
) : (
<button
onClick={onClick}
className={cls}
style={style}
title={title}
type="button"
aria-label={label}
onMouseEnter={() => setTip(true)}
onMouseLeave={() => setTip(false)}
onFocus={() => setTip(true)}
onBlur={() => setTip(false)}
>
{children}
</button>
);
return (
<span className="relative inline-flex">
{trigger}
{tip && (
<span
role="tooltip"
className="absolute left-1/2 -translate-x-1/2 -top-8 whitespace-nowrap rounded-md bg-foreground backdrop-blur px-2 py-1 text-[10px] font-bold text-background shadow-lg pointer-events-none"
>
{label}
</span>
)}
</span>
);
}
export function ClientView({
@@ -64,17 +98,47 @@ export function ClientView({
hotelName: string;
initialOnline: number;
}) {
const [_isFullscreen, setIsFullscreen] = useState(false);
const tToolbar = useTranslations("toolbar");
const [isFullscreen, setIsFullscreen] = useState(false);
const [onlineCount, setOnlineCount] = useState(initialOnline);
const [emulatorOk, setEmulatorOk] = useState<boolean | null>(null);
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
const [showOnlineUsers, setShowOnlineUsers] = useState(false);
const [barHidden, setBarHidden] = useState(false);
// Real-time online count + emulator status via SSE multicast (replaces the
// legacy 15s polling).
const { data: onlineStream } = useEventSource<{
count: number;
emulator: boolean;
}>("/api/online/count/stream", { enabled: true });
useEffect(() => {
const interval = setInterval(async () => {
try {
const res = await fetch("/api/online/count");
if (res.ok) setOnlineCount((await res.json()).count ?? 0);
} catch {}
}, 15000);
return () => clearInterval(interval);
if (!onlineStream) return;
if (typeof onlineStream.count === "number")
setOnlineCount(onlineStream.count);
if (typeof onlineStream.emulator === "boolean")
setEmulatorOk(onlineStream.emulator);
}, [onlineStream]);
// "Who's online" list, fetched sparingly (server-side cached 10s). Only a
// fresh fetch is issued when hovered and the last fetch is stale — hovering
// repeatedly must not spam the endpoint.
const onlineUsersRef = useRef<number>(0);
const refreshOnlineUsers = useCallback(() => {
const now = Date.now();
if (now - onlineUsersRef.current < 30_000) return;
onlineUsersRef.current = now;
fetch("/api/online", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const usernames = Array.isArray(data?.users)
? data.users
.map((u: { username?: string }) => u.username)
.filter((n: unknown): n is string => typeof n === "string")
: [];
setOnlineUsers(usernames);
})
.catch(() => setOnlineUsers([]));
}, []);
const toggleFullscreen = useCallback(() => {
@@ -163,16 +227,18 @@ export function ClientView({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button, a")) return;
startDrag(e.clientX, e.clientY);
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
startDrag(e.clientX, e.clientY);
}
},
[startDrag],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if ((e.target as HTMLElement).closest("button, a")) return;
startDrag(e.touches[0].clientX, e.touches[0].clientY);
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
startDrag(e.touches[0].clientX, e.touches[0].clientY);
}
},
[startDrag],
);
@@ -225,22 +291,24 @@ export function ClientView({
return (
<>
{pos ? (
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
<div
ref={toolbarRef}
className={`fixed z-[9999] backdrop-blur-xl bg-foreground/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
className="fixed z-[9999] rounded-2xl shadow-2xl px-1.5 py-1.5 select-none backdrop-blur-2xl"
style={{
top: pos.top,
left: pos.left,
top: pos?.top ?? 8,
left: pos?.left ?? 8,
transition: snapTween
? "top 0.18s ease-out, left 0.18s ease-out"
: undefined,
background:
"linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04))",
border: "1px solid rgba(255,255,255,0.14)",
boxShadow:
"0 8px 32px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.12), inset 0 0 24px rgba(255,255,255,0.04)",
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div className="flex items-center gap-1 sm:gap-2">
<ToolbarBtn href="/" title="Home">
<div className="flex items-center gap-1 sm:gap-1.5">
<ToolbarBtn href="/" label={tToolbar("home")}>
<svg
width="14"
height="14"
@@ -251,60 +319,16 @@ export function ClientView({
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Home"
aria-label={tToolbar("home")}
>
<title>Home</title>
<title>{tToolbar("home")}</title>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</ToolbarBtn>
<ToolbarBtn onClick={() => window.location.reload()} title="Reload">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Reload"
>
<title>Reload</title>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</ToolbarBtn>
<ToolbarBtn onClick={toggleFullscreen} title="Fullscreen">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Fullscreen"
>
<title>Fullscreen</title>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
</ToolbarBtn>
<div
className="h-7 sm:h-8 px-1.5 sm:px-2 rounded-lg border-2 flex items-center justify-center gap-1 shadow-lg select-none"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
}}
title="Online users"
<ToolbarBtn
onClick={() => window.location.reload()}
label={tToolbar("reload")}
>
<svg
width="14"
@@ -316,27 +340,154 @@ export function ClientView({
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Online users"
aria-label={tToolbar("reload")}
>
<title>Online users</title>
<title>{tToolbar("reload")}</title>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</ToolbarBtn>
<ToolbarBtn
onClick={toggleFullscreen}
label={
isFullscreen
? tToolbar("fullscreenExit")
: tToolbar("fullscreen")
}
>
{isFullscreen ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("fullscreenExit")}
>
<title>{tToolbar("fullscreenExit")}</title>
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("fullscreen")}
>
<title>{tToolbar("fullscreen")}</title>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
)}
</ToolbarBtn>
<div
className="h-8 sm:h-9 rounded-xl flex items-center justify-center px-2"
style={{
border:
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 50%, transparent)",
background:
"color-mix(in srgb, var(--color-primary, #eeb425) 14%, rgba(255,255,255,0.05))",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.20)",
}}
role="status"
aria-label={
emulatorOk === null
? tToolbar("emulatorUnknown")
: emulatorOk
? tToolbar("emulatorOnline")
: tToolbar("emulatorOffline")
}
title={
emulatorOk === null
? tToolbar("emulatorUnknown")
: emulatorOk
? tToolbar("emulatorOnline")
: tToolbar("emulatorOffline")
}
>
<span className="relative flex h-2.5 w-2.5">
{emulatorOk === null ? (
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-muted-foreground ring-2 ring-muted-foreground/30" />
) : emulatorOk ? (
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-success ring-2 ring-success/30 shadow-[0_0_8px_rgba(52,211,153,0.8)]" />
) : (
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-destructive ring-2 ring-destructive/30 shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
)}
</span>
</div>
<div
className="relative h-8 sm:h-9 px-2 sm:px-2.5 rounded-xl flex items-center justify-center gap-1.5 select-none cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0"
style={{
border:
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
background:
"linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
boxShadow:
"0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
}}
role="status"
aria-label={tToolbar("onlineUsers")}
onMouseEnter={() => {
setShowOnlineUsers(true);
refreshOnlineUsers();
}}
onMouseLeave={() => setShowOnlineUsers(false)}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-background/80 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-background shadow-[0_0_8px_rgba(255,255,255,0.9)]" />
</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("onlineUsers")}
>
<title>{tToolbar("onlineUsers")}</title>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="text-[10px] sm:text-xs font-bold">
{onlineCount}
</span>
{showOnlineUsers && onlineUsers.length > 0 && (
<span className="absolute left-1/2 -translate-x-1/2 -top-10 max-w-[200px] rounded-md bg-foreground backdrop-blur px-2 py-1 text-[10px] font-bold text-background shadow-lg pointer-events-none whitespace-normal text-center">
{onlineUsers.slice(0, 8).join(", ")}
{onlineUsers.length > 8
? ` +${onlineUsers.length - 8} more`
: ""}
</span>
)}
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
<div
className="h-7 sm:h-8 px-1 rounded-lg border-2 flex items-center justify-center shadow-lg opacity-50"
className="toolbar-grip h-8 sm:h-9 px-1.5 rounded-xl flex items-center justify-center cursor-grab active:cursor-grabbing touch-none opacity-60 hover:opacity-100 transition-opacity duration-200"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.85)",
}}
title="Drag to move"
title={tToolbar("dragToMove")}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<svg
width="12"
@@ -348,9 +499,9 @@ export function ClientView({
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Drag"
aria-label={tToolbar("dragToMove")}
>
<title>Drag</title>
<title>{tToolbar("dragToMove")}</title>
<circle cx="9" cy="5" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="9" cy="12" r="1" />
@@ -361,14 +512,75 @@ export function ClientView({
</div>
<ToolbarBtn
onClick={() => void signOutAndRevokeTicket()}
title="Logout"
label={tToolbar("logout")}
>
<LogOut size={14} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => setBarHidden(true)}
label={tToolbar("hide")}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("hide")}
>
<title>{tToolbar("hide")}</title>
<path d="M9 18l6-6-6-6" />
</svg>
</ToolbarBtn>
</div>
</div>
) : null}
{barHidden ? (
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
setBarHidden(false);
}}
className="fixed z-[9999] h-8 rounded-xl flex items-center justify-center px-2 cursor-pointer transition-all duration-200 opacity-80 hover:opacity-100 hover:-translate-y-0.5 active:translate-y-0 active:scale-90"
style={{
top: pos?.top ?? 8,
left: pos?.left ?? 8,
border:
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
background:
"linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
boxShadow:
"0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
}}
aria-label={tToolbar("show")}
title={tToolbar("show")}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("show")}
>
<title>{tToolbar("show")}</title>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
) : null}
<div
className="relative w-screen h-screen overflow-hidden"
style={{ backgroundColor: "var(--color-background)" }}
+4 -4
View File
@@ -74,10 +74,10 @@ export default async function ModBansPage() {
<table>
<thead>
<tr>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
<th scope="col">{t("colUserId")}</th>
<th scope="col">{t("colType")}</th>
<th scope="col">{t("colReason")}</th>
<th scope="col">{t("colExpires")}</th>
</tr>
</thead>
<tbody>
+11 -3
View File
@@ -15,6 +15,7 @@ import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, SupportTickets, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
const SORT_COLS = {
id: SupportTickets.id,
@@ -55,7 +56,8 @@ export default async function ModCfhListPage({
.where(like(User.username, `%${q}%`))
.limit(50);
userIds = users.map((u) => u.id);
} catch {
} catch (error) {
logServerError("cfh.user_search_failed", error, { query: q });
userIds = [];
}
@@ -91,7 +93,10 @@ export default async function ModCfhListPage({
.from(SupportTickets)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0);
.catch((error) => {
logServerError("cfh.count_failed", error);
return 0;
});
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
@@ -101,7 +106,10 @@ export default async function ModCfhListPage({
.orderBy(finalOrder)
.offset(pagination.offset)
.limit(pagination.perPage)
.catch(() => []);
.catch((error) => {
logServerError("cfh.query_failed", error);
return [];
});
const userIds = [
...new Set([
+9 -2
View File
@@ -8,6 +8,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User } from "@/lib/db";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import type { DataTableColumn } from "@/types/common";
type ModUserRow = {
@@ -139,7 +140,10 @@ export default async function ModUsersPage({
.from(User)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
.catch((error) => {
logServerError("users.count_failed", error);
return 0;
}),
db
.select({
id: User.id,
@@ -155,7 +159,10 @@ export default async function ModUsersPage({
.orderBy(finalOrder)
.offset((parsed.page - 1) * parsed.perPage)
.limit(parsed.perPage)
.catch(() => [] as ModUserRow[]),
.catch((error) => {
logServerError("users.query_failed", error);
return [] as ModUserRow[];
}),
]);
const total = totals;
+2
View File
@@ -2,6 +2,7 @@
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { SearchDialog } from "@/components/admin/search-dialog";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav";
@@ -29,6 +30,7 @@ export function AdminTopbar({
</span>
</div>
<div className="flex items-center gap-3">
<SearchDialog />
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
@@ -0,0 +1,74 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
approveApplication,
dismissApplication,
} from "@/actions/admin-applications";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
export function ApplicationActions({ id }: { id: string }) {
const t = useTranslations("pages.admin.applications");
const router = useRouter();
const [pending, startTransition] = useTransition();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleApprove() {
const ok = await confirm({
title: t("approve"),
description: t("confirmApprove"),
confirmLabel: t("approve"),
variant: "default",
});
if (!ok) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
await approveApplication(fd);
router.refresh();
});
}
async function handleReject() {
const ok = await confirm({
title: t("reject"),
description: t("confirmReject"),
confirmLabel: t("reject"),
variant: "danger",
});
if (!ok) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
await dismissApplication(fd);
router.refresh();
});
}
return (
<div className="flex gap-2">
{confirmDialog}
<Button
type="button"
variant="default"
size="sm"
disabled={pending}
onClick={handleApprove}
>
{t("approve")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={handleReject}
>
{t("reject")}
</Button>
</div>
);
}
+36
View File
@@ -18,6 +18,8 @@ export function ArticleForm({
image?: string;
shortStory?: string;
fullStory?: string;
status?: string;
publishAt?: string;
};
}) {
const t = useTranslations("pages.admin.articles.form");
@@ -26,6 +28,7 @@ export function ArticleForm({
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
const [status, setStatus] = useState(defaultValues?.status ?? "published");
const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -132,6 +135,39 @@ export function ArticleForm({
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("status")}
</span>
<select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="input input-bordered mt-1 w-full"
>
<option value="draft">{t("statusDraft")}</option>
<option value="scheduled">{t("statusScheduled")}</option>
<option value="published">{t("statusPublished")}</option>
</select>
</label>
{status === "scheduled" ? (
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")}
</span>
<input
type="datetime-local"
name="publishAt"
defaultValue={defaultValues?.publishAt ?? ""}
required
className="input input-bordered mt-1 w-full"
/>
</label>
) : null}
</div>
<button type="submit" className="btn btn-primary justify-self-start">
{t("save")}
</button>
@@ -24,7 +24,9 @@ export function BreadcrumbBar() {
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
}
})
.catch(() => {});
.catch((error) =>
console.error("[Breadcrumb] Failed to fetch ancestors:", error),
);
return () => ac.abort();
}, [selectedPageId, dispatch, catQs]);
@@ -667,7 +667,9 @@ export function SortableTree({
ids: pages.map((p) => p.id),
});
})
.catch(() => {});
.catch((error) =>
console.error("[SortableTree] Failed to load children:", error),
);
}
}
}, [activeTabId, childrenMap, nodes, dispatch, loadChildren]);
@@ -81,7 +81,7 @@ export function QuickAddFurni({
)
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch(() => {})
.catch((error) => console.error("[QuickAdd] Furni search failed:", error))
.finally(() => setLoading(false));
return () => ac.abort();
}, [debounced, open]);
+1 -1
View File
@@ -101,7 +101,7 @@ export function AdminMediaGrid() {
setCopied(url);
setTimeout(() => setCopied(null), 1500);
})
.catch(() => {});
.catch((error) => console.error("[Media] Clipboard copy failed:", error));
}
async function remove(name: string) {
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AdminSearchResult } from "@/app/api/admin/search/route";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useDebounce } from "@/hooks/use-debounce";
type SearchResult = AdminSearchResult;
type SearchResponse = {
ok: boolean;
results: SearchResult[];
};
export function SearchDialog() {
const t = useTranslations("pages.admin.search");
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 250);
const abortRef = useRef<AbortController | null>(null);
const groupLabels: Record<string, string> = {
users: t("users"),
articles: t("articles"),
rooms: t("rooms"),
guilds: t("guilds"),
shop: t("shop"),
rareValues: t("rareValues"),
};
const fetchResults = useCallback(async (q: string) => {
abortRef.current?.abort();
if (q.length < 2) {
setResults([]);
setLoading(false);
return;
}
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
if (!res.ok) return;
const data: SearchResponse = await res.json();
if (abortRef.current !== controller) return;
if (data.ok) setResults(data.results);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
if (abortRef.current === controller) setResults([]);
} finally {
if (abortRef.current === controller) setLoading(false);
}
}, []);
useEffect(() => {
fetchResults(debouncedQuery);
}, [debouncedQuery, fetchResults]);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
function handleSelect(url: string) {
setOpen(false);
setQuery("");
setResults([]);
router.push(url);
}
const grouped = groupResults(results);
return (
<Dialog open={open} onOpenChange={setOpen}>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
>
<SearchIcon size={14} />
<span className="hidden sm:inline">{t("placeholder")}</span>
<kbd className="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-60">
⌘K
</kbd>
</button>
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
shouldFilter={false}
>
<CommandInput
placeholder={t("placeholder")}
value={query}
onValueChange={setQuery}
className="h-12"
/>
<CommandList className="max-h-[400px]">
{loading && query.length >= 2 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
…
</div>
) : null}
<CommandEmpty>{t("noResults")}</CommandEmpty>
{Object.entries(grouped).map(([group, items]) => (
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
{items.map((item) => (
<CommandItem
key={`${item.type}-${item.id}`}
value={`${item.type}-${item.id}`}
onSelect={() => handleSelect(item.url)}
className="flex flex-col items-start gap-0.5 py-2"
>
<span className="text-sm font-medium">{item.title}</span>
{item.subtitle ? (
<span className="text-xs text-muted-foreground truncate w-full">
{item.subtitle}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function groupResults(results: SearchResult[]) {
const grouped: Record<string, SearchResult[]> = {};
for (const r of results) {
if (!grouped[r.type]) grouped[r.type] = [];
grouped[r.type].push(r);
}
return grouped;
}
+34 -6
View File
@@ -466,13 +466,30 @@ export function StudioClient({
signal: abort.signal,
});
if (!res.ok || !res.body) {
toast.error(`Batch chunk mislukt (${res.status})`);
continue;
if (!res.ok) {
const errMsg =
res.status === 502
? "Furnidata ophalen mislukt – controleer de hotel-URL en netwerk"
: res.status === 400
? "Ongeldige input voor batch-import"
: `Batch chunk mislukt (${res.status})`;
toast.error(errMsg);
// Toon foutmelding uit SSE-stream als die al onderweg is
if (res.body && "getReader" in res.body) {
void res.body.getReader();
toast.error(
"Foutmelding stream niet beschikbaar – de import loopt voort, fouten worden in de stream getoond",
);
} else {
toast.error(
"SSE-stream niet beschikbaar – foutmelding overgeslagen",
);
}
return;
}
await readSseStream(
res.body,
res.body!,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
@@ -805,9 +822,20 @@ export function StudioClient({
}
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Batch import cancelled");
toast.info("Batch import geannuleerd door gebruiker");
} else if ((err as Error).name === "TimeoutError") {
toast.error(
"Import tijdelijkstop gedurende te lange tijd – probeer het opnieuw of verlaag de concurrentie",
);
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
toast.error(
"Netwerkfout tijdens upload – controleer je internetverbinding en probeer opnieuw",
);
} else {
toast.error("Connection lost during batch import");
toast.error(
"Onverwende fout tijdens import: " +
(err instanceof Error ? err.message : String(err)),
);
}
} finally {
batchAbortRef.current = null;
+22 -12
View File
@@ -12,25 +12,35 @@ export function AnimatedCounter({
}) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
const started = useRef(false);
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!isInView) return;
if (started.current) {
setDisplay(value);
return;
}
started.current = true;
const duration = 1200;
const steps = 30;
const increment = value / steps;
let current = 0;
let step = 0;
const id = setInterval(() => {
step++;
current = Math.min(Math.round(increment * step), value);
setDisplay(current);
if (step >= steps) {
clearInterval(id);
const start = performance.now();
let raf = 0;
const tick = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - (1 - progress) ** 3;
setDisplay(Math.round(eased * value));
if (progress < 1) {
raf = requestAnimationFrame(tick);
} else {
setDisplay(value);
}
}, duration / steps);
return () => clearInterval(id);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [isInView, value]);
return (
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useEventSource } from "@/lib/use-event-source";
/**
* Live online user counter. Subscribes to the SSE online-count stream so the
* displayed count updates in real-time (multicast) instead of only reflecting
* the server-rendered value at page load.
*/
export function LiveOnlineCount({
initial,
hotelName,
}: {
initial: number;
hotelName: string;
}) {
const t = useTranslations("header");
const [count, setCount] = useState(initial);
const { data: onlineStream } = useEventSource<{ count: number }>(
"/api/online/count/stream",
{ enabled: true },
);
useEffect(() => {
if (onlineStream && typeof onlineStream.count === "number") {
setCount(onlineStream.count);
}
}, [onlineStream]);
return <>{t("online", { count, hotel: hotelName })}</>;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { useEventSource } from "@/lib/use-event-source";
/**
* Live animated online-count number. Subscribes to the SSE online-count stream
* so the stat updates in real-time (multicast) instead of only the initial SSR
* value, while keeping the entrance counter animation.
*/
export function LiveOnlineCounter({
initial,
suffix = "",
}: {
initial: number;
suffix?: string;
}) {
const [count, setCount] = useState(initial);
const { data: onlineStream } = useEventSource<{ count: number }>(
"/api/online/count/stream",
{ enabled: true },
);
useEffect(() => {
if (onlineStream && typeof onlineStream.count === "number") {
setCount(onlineStream.count);
}
}, [onlineStream]);
return <AnimatedCounter value={count} suffix={suffix} />;
}
+102 -2
View File
@@ -2,7 +2,14 @@
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { type ReactNode, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { mobileMenuVariants } from "@/lib/motion";
interface MobileNavProps {
@@ -20,10 +27,96 @@ export function MobileNav({
}: MobileNavProps) {
const [open, setOpen] = useState(false);
const detailsRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const prevFocusRef = useRef<HTMLElement | null>(null);
const menuId = useId();
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
(prevFocusRef.current ?? toggleRef.current)?.focus();
}
},
[open],
);
useEffect(() => {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [handleEscape]);
// Initial focus, scroll-lock, click-outside close, focus restore.
useEffect(() => {
if (!open) return;
prevFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
menuRef.current
?.querySelector<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
?.focus();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function handlePointerDown(e: PointerEvent) {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!toggleRef.current?.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("pointerdown", handlePointerDown);
(prevFocusRef.current ?? toggleRef.current)?.focus();
};
}, [open]);
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
if (!menu) return;
const focusableSelector =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function handleTab(e: KeyboardEvent) {
if (e.key !== "Tab" || !menu) return;
const focusable = Array.from(
menu.querySelectorAll<HTMLElement>(focusableSelector),
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
menu.addEventListener("keydown", handleTab);
return () => menu.removeEventListener("keydown", handleTab);
}, [open]);
return (
<div ref={detailsRef} className="group relative md:hidden">
<button
ref={toggleRef}
type="button"
onClick={() => setOpen((o) => !o)}
className="list-none cursor-pointer flex items-center justify-center w-11 h-11 rounded-xl shrink-0
@@ -33,6 +126,8 @@ export function MobileNav({
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
aria-label={open ? closeLabel : menuLabel}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={menuId}
>
<motion.svg
className="w-6 h-6"
@@ -63,6 +158,10 @@ export function MobileNav({
<AnimatePresence>
{open && (
<motion.div
id={menuId}
role="dialog"
aria-modal="true"
aria-label={brandLabel || "Navigation menu"}
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
style={{ backgroundColor: "transparent" }}
variants={mobileMenuVariants}
@@ -71,8 +170,9 @@ export function MobileNav({
exit="exit"
>
<div
ref={menuRef}
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
+6 -1
View File
@@ -11,6 +11,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
import { db, MessengerFriendrequests, MessengerOffline } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
export async function Navigation({ session }: { session: Session | null }) {
const t = await getTranslations("nav");
@@ -58,7 +59,11 @@ export async function Navigation({ session }: { session: Session | null }) {
]);
unreadMessages = unreadRows[0]?.total ?? 0;
pendingFriendRequests = pendingRows[0]?.total ?? 0;
} catch {}
} catch (error) {
logServerError("navigation.messenger_counts_failed", error, {
userId: id,
});
}
}
return (
+1 -1
View File
@@ -171,7 +171,7 @@ export default function RadioPlayer() {
.then((data) => {
if (data !== null) setConfig(parseConfig(data));
})
.catch(() => {});
.catch((error) => console.error("[Radio] Config fetch failed:", error));
}, []);
// Keep the <audio> volume in sync with the slider.
+5 -1
View File
@@ -15,7 +15,11 @@ export function PwaRegister() {
}
});
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
navigator.serviceWorker
.register(`/sw.js?${SW_VERSION}`)
.catch((error) =>
console.error("[PWA] Service worker registration failed:", error),
);
}, []);
return null;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { CSSProperties } from "react";
import { sanitize } from "@/lib/sanitize";
interface SanitizedHtmlProps {
html: string;
@@ -11,7 +12,7 @@ export function SanitizedHtml({ html, className, style }: SanitizedHtmlProps) {
<div
className={className}
style={style}
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={{ __html: sanitize(html) }}
/>
);
}
+2 -3
View File
@@ -1,14 +1,13 @@
import { count, eq } from "drizzle-orm";
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
const t = await getTranslations("header");
const [hotelName, header, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_header", "/assets/images/background.png"),
@@ -140,7 +139,7 @@ export async function SiteHeader() {
className="text-xs sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
{t("online", { count: online, hotel: hotelName })}
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</div>
</div>
+11 -6
View File
@@ -1,10 +1,10 @@
import { count, eq, inArray } from "drizzle-orm";
import Image from "next/image";
import type { Session } from "next-auth";
import { getTranslations } from "next-intl/server";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import { HeaderUserIdentity } from "@/components/header-user-identity";
import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { RoomQuickEntry } from "@/components/room-quick-entry";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
@@ -17,6 +17,7 @@ import {
} from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
function Currency({
icon,
@@ -76,7 +77,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
if (c.type === 0) duckets = c.amount;
if (c.type === 5) diamonds = c.amount;
}
} catch {}
} catch (error) {
logServerError("top-header.wallet_failed", error, { userId: id });
}
let showAdmin = false;
let showMod = false;
@@ -100,7 +103,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
const t = await getTranslations("header");
const hotelName = await resolveHotelName();
let online: number;
@@ -112,7 +114,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
} catch {
} catch (error) {
logServerError("top-header.online_count_failed", error);
online = 0;
}
@@ -131,7 +134,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
]);
unreadMessages = unreadRows[0]?.total ?? 0;
pendingFriendRequests = pendingRows[0]?.total ?? 0;
} catch {}
} catch (error) {
logServerError("top-header.messenger_counts_failed", error, { userId: id });
}
const friendRequests = await db
.select({ userFromId: MessengerFriendrequests.userFromId })
@@ -173,7 +178,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
className="text-[10px] sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
{t("online", { count: online, hotel: hotelName })}
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</div>
+3
View File
@@ -661,6 +661,9 @@ export const WebsiteArticles = mysqlTable("website_articles", {
image: varchar("image", { length: 255 }).notNull(),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
status: varchar("status", { length: 20 }).notNull().default("published"),
publishAt: timestamp("publish_at"),
publishedAt: timestamp("published_at"),
});
export const WebsiteLanguages = mysqlTable("website_languages", {
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, it } from "vitest";
import {
calcPagination,
PER_PAGE_OPTIONS,
parseListParams,
} from "./admin-helpers";
describe("parseListParams", () => {
it("parses defaults", () => {
const p = parseListParams(new URLSearchParams());
expect(p).toEqual({
search: "",
perPage: 20,
page: 1,
sort: undefined,
order: "desc",
});
});
it("parses explicit values", () => {
const p = parseListParams(
new URLSearchParams("search=sofa&perPage=50&page=3&sort=name&order=asc"),
);
expect(p.search).toBe("sofa");
expect(p.perPage).toBe(50);
expect(p.page).toBe(3);
expect(p.sort).toBe("name");
expect(p.order).toBe("asc");
});
it("clamps perPage to [1, 100]", () => {
expect(parseListParams(new URLSearchParams("perPage=0")).perPage).toBe(1);
expect(parseListParams(new URLSearchParams("perPage=1000")).perPage).toBe(
100,
);
});
it("clamps page to >= 1", () => {
expect(parseListParams(new URLSearchParams("page=0")).page).toBe(1);
expect(parseListParams(new URLSearchParams("page=-5")).page).toBe(1);
});
it("defaults order to desc when not asc", () => {
expect(parseListParams(new URLSearchParams("order=asc")).order).toBe("asc");
expect(parseListParams(new URLSearchParams("order=desc")).order).toBe(
"desc",
);
expect(parseListParams(new URLSearchParams("order=bogus")).order).toBe(
"desc",
);
});
});
describe("calcPagination", () => {
it("computes last page, offset and clamps the page", () => {
const p = calcPagination(50, 3, 20);
expect(p.lastPage).toBe(3);
expect(p.page).toBe(3);
expect(p.offset).toBe(40);
});
it("clamps page beyond last page", () => {
const p = calcPagination(10, 99, 20);
expect(p.lastPage).toBe(1);
expect(p.page).toBe(1);
expect(p.offset).toBe(0);
});
it("handles zero total with lastPage 1", () => {
const p = calcPagination(0, 1, 20);
expect(p.lastPage).toBe(1);
expect(p.offset).toBe(0);
});
});
describe("PER_PAGE_OPTIONS", () => {
it("exposes the selectable page sizes", () => {
expect(PER_PAGE_OPTIONS).toEqual([10, 20, 50]);
});
});
+51 -30
View File
@@ -2,45 +2,66 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("CI workflow", () => {
const workflow = readFileSync(
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
"utf8",
).replace(/\r\n/g, "\n");
const workflow = readFileSync(
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
"utf8",
);
it("runs check then production deploy on push to main", () => {
describe("CI workflow", () => {
it("has check and deploy jobs", () => {
expect(workflow).toContain("check:");
expect(workflow).toContain("deploy:");
});
it("deploy depends on check", () => {
expect(workflow).toContain("needs: check");
});
it("deploy only runs on push to main/master", () => {
expect(workflow).toContain("gitea.event_name == 'push'");
expect(workflow).toContain("gitea.ref_name == 'main'");
});
it("check runs lint, typecheck, and test", () => {
expect(workflow).toContain("pnpm biome:lint");
expect(workflow).toContain("pnpm typecheck");
expect(workflow).toContain("pnpm test");
expect(workflow).toContain("needs: check");
expect(workflow).toContain(
"gitea.event_name == 'push' && gitea.ref_name == 'main'",
});
it("check job runs on self-hosted runner", () => {
const checkJob = workflow.slice(
workflow.indexOf("check:"),
workflow.indexOf("\n deploy:"),
);
expect(workflow).toContain("worktree add --detach");
expect(workflow).toContain("/api/health");
expect(checkJob).toContain("runs-on: self-hosted");
});
it("deploy job runs on self-hosted runner", () => {
const deployJob = workflow.slice(workflow.indexOf("\n deploy:"));
expect(deployJob).toContain("runs-on: self-hosted");
});
it("check includes toolchain check", () => {
expect(workflow).toContain("scripts/check-node-toolchain.mjs");
});
it("uses only valid pnpm install flags (no --jobs)", () => {
expect(workflow).toContain("pnpm install --frozen-lockfile");
expect(workflow).not.toContain("--jobs");
});
it("does not use github context", () => {
expect(workflow).not.toContain("github.ref");
});
it("uses Gitea SHA for check checkout", () => {
expect(workflow).toContain("gitea.sha");
expect(workflow).toContain("SKIP_ENV_VALIDATION=1");
it("has tag trigger", () => {
expect(workflow).toContain('tags: ["v*"]');
});
it("keeps tag release in the same workflow", () => {
expect(workflow).toContain('tags:\n - "v*"');
expect(workflow).toContain("Creating release for");
expect(workflow).toContain("startsWith(gitea.ref_name, 'v')");
});
it("applies CMS migrations on tag release before build", () => {
const release = workflow.slice(workflow.indexOf("\n release:"));
expect(release).toContain("pnpm db:migrate");
expect(release).not.toContain("pnpm prisma:generate");
expect(release).toContain("pnpm db:migrate");
expect(release).toContain("src/db/schema.ts");
const migrateAt = release.indexOf("pnpm db:migrate");
const buildAt = release.indexOf("pnpm build");
expect(migrateAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(migrateAt);
it("has e2e job that smoke-tests the deployed app", () => {
expect(workflow).toContain("e2e:");
expect(workflow).toContain("needs: deploy");
expect(workflow).toContain("pnpm test:e2e");
expect(workflow).toContain("PLAYWRIGHT_BASE_URL");
});
});
+77 -180
View File
@@ -1,200 +1,97 @@
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const liveVar = "${" + "LIVE}";
const stageVar = "${" + "STAGE}";
const userVar = "${" + "DEPLOY_USER}";
const groupVar = "${" + "DEPLOY_GROUP}";
const cutoverStartedVar = "${" + "CUTOVER_STARTED}";
const workflow = readFileSync(
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
"utf8",
);
const deployStart = workflow.indexOf("\n deploy:");
const e2eStart = workflow.indexOf("\n e2e:");
const deployJob =
deployStart > -1
? workflow.slice(deployStart, e2eStart > deployStart ? e2eStart : undefined)
: "";
function toContainLiteral(workflow: string, literal: string) {
return workflow.indexOf(literal) >= 0;
}
describe("deploy job", () => {
it("runs on self-hosted for Docker access", () => {
expect(deployJob).toContain("runs-on: self-hosted");
});
describe("production deploy workflow", () => {
const workflow = readFileSync(
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
"utf8",
);
const deployStart = workflow.indexOf("\n deploy:");
const afterDeploy = workflow.indexOf("\n release:", deployStart + 1);
const deployJob =
afterDeploy > deployStart
? workflow.slice(deployStart, afterDeploy)
: workflow.slice(deployStart);
it("has checkout step", () => {
expect(deployJob).toContain("actions/checkout@v4");
});
it("is gated behind the check job", () => {
expect(deployJob).toContain("needs: check");
expect(deployJob).toContain(
"gitea.event_name == 'push' && gitea.ref_name == 'main'",
it("builds Docker image with BuildKit", () => {
expect(deployJob).toContain("DOCKER_BUILDKIT=1");
expect(deployJob).toContain("docker build");
expect(deployJob).toContain("-t epicnext-cms:latest");
});
it("builds on host network for registry access", () => {
expect(deployJob).toContain("--network=host");
});
it("stops old container before starting new one", () => {
expect(deployJob).toContain("docker stop epicnext-cms-app");
expect(deployJob).toContain("docker rm epicnext-cms-app");
});
it("starts container with correct settings", () => {
expect(deployJob).toContain("--restart always");
expect(deployJob).toContain("--net=host");
expect(deployJob).toContain("--name epicnext-cms-app");
});
it("provides production .env to the build", () => {
expect(deployJob).toContain("cp /var/www/atom-nexst/.env .env");
});
it("runs container with production env and volumes", () => {
expect(deployJob).toContain(". /var/www/atom-nexst/.env");
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal shell snippet
expect(deployJob).toContain('"${ENV_ARGS[@]}"');
expect(deployJob).toContain("/var/www/Gamedata:/var/www/Gamedata");
expect(deployJob).toContain("/app/storage");
});
it("does not use --env-file (it keeps literal quotes)", () => {
expect(deployJob).not.toContain("--env-file");
});
it("frees port 3002 by stopping the compose container", () => {
expect(deployJob).toContain("docker stop epicnext-cms 2>/dev/null || true");
});
it("rolls back to compose on health check failure", () => {
expect(deployJob).toContain("docker compose");
});
it("runs database migrations before replacing the container", () => {
expect(deployJob).toContain("pnpm db:migrate");
expect(deployJob.indexOf("pnpm db:migrate")).toBeLessThan(
deployJob.indexOf("docker stop epicnext-cms-app"),
);
});
it("builds in a stage worktree while preserving live .env and storage", () => {
expect(deployJob).toContain("worktree add --detach");
expect(deployJob).toContain("/var/tmp/atom-nexst-stage-");
expect(toContainLiteral(deployJob, `ln -sfn "${liveVar}/.env"`)).toBe(true);
expect(deployJob).toContain("-e storage");
expect(deployJob).toContain("DATABASE_POOL_SIZE=");
expect(deployJob).toContain("REDIS_URL is unset");
expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1");
expect(deployJob).toContain("pnpm install --frozen-lockfile");
it("runs health check", () => {
expect(deployJob).toContain("/api/health");
expect(deployJob).toContain('"database":true');
});
it("preserves runtime furni assets when cleaning the live checkout", () => {
const cleanStart = deployJob.indexOf("git clean -fd \\");
const commandLines: string[] = [];
for (const line of deployJob.slice(cleanStart).split(/\r?\n/)) {
commandLines.push(line);
if (!line.trimEnd().endsWith("\\")) break;
}
const cleanLines = commandLines.join(" ");
const excludes = Array.from(
cleanLines.matchAll(/-e\s+([^\s\\]+)/g),
).flatMap(([, pattern]) => ["-e", pattern]);
const work = mkdtempSync(resolve(tmpdir(), "epicnext-deploy-clean-"));
const runtimeFiles = [
"public/swf/dcr/hof_furni/icons/runtime_icon.png",
"public/swf/dcr/hof_furni/swf/runtime.swf",
"public/nitro-assets/bundled/furniture/runtime.nitro",
];
try {
spawnSync("git", ["init", "--quiet"], { cwd: work });
for (const file of [...runtimeFiles, "remove-me.tmp"]) {
const absolute = resolve(work, file);
mkdirSync(resolve(absolute, ".."), { recursive: true });
writeFileSync(absolute, "runtime");
}
const clean = spawnSync("git", ["clean", "-fd", ...excludes], {
cwd: work,
encoding: "utf8",
});
expect(clean.status, clean.stderr).toBe(0);
for (const file of runtimeFiles) {
expect(existsSync(resolve(work, file)), file).toBe(true);
}
expect(existsSync(resolve(work, "remove-me.tmp"))).toBe(false);
} finally {
rmSync(work, { recursive: true, force: true });
}
it("cleans up old images", () => {
expect(deployJob).toContain("docker image prune -f");
});
it("reclaims ownership before git operations so www-data files can be overwritten", () => {
const chownCmd = `sudo chown -R "${userVar}:${groupVar}"`;
expect(toContainLiteral(workflow, chownCmd)).toBe(true);
const reclaimAt = deployJob.indexOf(chownCmd);
expect(
toContainLiteral(deployJob, `git -C "${liveVar}" fetch origin --prune`),
).toBe(true);
const fetchAt = deployJob.indexOf(
`git -C "${liveVar}" fetch origin --prune`,
);
expect(reclaimAt).toBeGreaterThan(-1);
expect(fetchAt).toBeGreaterThan(reclaimAt);
});
it("avoids nuclear src wipe and verifies live src after cutover reset", () => {
expect(deployJob).not.toContain("rm -rf src");
expect(deployJob).not.toContain("Nuclear-replacing src/");
expect(deployJob).toContain("no-skip-worktree");
expect(deployJob).toContain("no-assume-unchanged");
expect(deployJob).toContain("Verified live src/ matches HEAD");
expect(deployJob).toContain("ls-files -v");
expect(deployJob).not.toContain("git ls-files -z");
expect(deployJob).toContain("pnpm typecheck");
});
it("migrates while the current app is online, then swaps the built artifact", () => {
expect(deployJob).toContain("mv .next .next.prev");
expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe(
true,
);
expect(
toContainLiteral(deployJob, `mv "${stageVar}/node_modules" node_modules`),
).toBe(true);
expect(deployJob).toContain("mv node_modules node_modules.prev");
expect(deployJob).toContain("Rolling back .next to previous artifact");
expect(deployJob).toContain(
"Rolling back node_modules to previous artifact",
);
const buildAt = deployJob.indexOf("pnpm build");
const stopAt = deployJob.indexOf("pm2 stop next");
const migrateAt = deployJob.indexOf("pnpm db:migrate");
const resetAt = deployJob.indexOf("git reset --hard origin/main");
const startLabelAt = deployJob.indexOf("Starting PM2");
const startAt = deployJob.indexOf(
"pm2 start pnpm --name next -- start",
startLabelAt,
);
expect(buildAt).toBeGreaterThan(-1);
expect(migrateAt).toBeGreaterThan(buildAt);
expect(resetAt).toBeGreaterThan(migrateAt);
expect(stopAt).toBeGreaterThan(resetAt);
expect(startLabelAt).toBeGreaterThan(stopAt);
expect(startAt).toBeGreaterThan(startLabelAt);
expect(deployJob.match(/pm2 stop next/g)).toHaveLength(1);
expect(deployJob.slice(stopAt, startAt)).not.toContain("sleep ");
});
it("does not restart the healthy app when deployment fails before cutover", () => {
const handlerStart = deployJob.indexOf("error_handler() {");
const handlerEnd = deployJob.indexOf("trap 'error_handler", handlerStart);
const handler = deployJob.slice(handlerStart, handlerEnd);
const cutoverGuardAt = handler.indexOf(
`if [ "${cutoverStartedVar}" = "1" ]; then`,
);
const restartAt = handler.indexOf("pm2 restart next", cutoverGuardAt);
const stageCleanupAt = handler.indexOf(`if [ -n "${stageVar}"`, restartAt);
expect(handlerStart).toBeGreaterThan(-1);
expect(cutoverGuardAt).toBeGreaterThan(-1);
expect(restartAt).toBeGreaterThan(cutoverGuardAt);
expect(stageCleanupAt).toBeGreaterThan(restartAt);
expect(handler.slice(restartAt, stageCleanupAt)).toContain(
"\n fi",
);
});
it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => {
expect(workflow).not.toContain("PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES");
});
it("runs typecheck before build in the stage", () => {
const typecheckAt = deployJob.indexOf("pnpm typecheck");
const buildAt = deployJob.indexOf("pnpm build");
expect(typecheckAt).toBeGreaterThan(-1);
expect(buildAt).toBeGreaterThan(typecheckAt);
it("does not run pnpm test in deploy", () => {
expect(deployJob).not.toContain("pnpm test");
});
it("exports APP_VERSION from git for the deployment ID fallback", () => {
const exportCmd = `export APP_VERSION="$(git -C "${liveVar}" rev-parse --short origin/main)"`;
expect(toContainLiteral(workflow, exportCmd)).toBe(true);
it("shows docker logs on failure", () => {
expect(deployJob).toContain("docker logs epicnext-cms-app");
});
it("runs an HTTP health check before declaring deploy success", () => {
expect(workflow).toContain("/api/health");
expect(workflow).toContain('"database":true');
expect(deployJob).toContain("export PORT=");
expect(deployJob).toContain("free_tcp_port");
const startAt = deployJob.indexOf("pm2 start pnpm --name next -- start");
const healthAt = deployJob.indexOf("/api/health");
const successAt = deployJob.indexOf("--- Deployed successfully ---");
expect(startAt).toBeGreaterThan(-1);
expect(healthAt).toBeGreaterThan(startAt);
expect(successAt).toBeGreaterThan(healthAt);
it("exits with error on health check failure", () => {
expect(deployJob).toContain("exit 1");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { sanitize } from "./sanitize";
describe("sanitize", () => {
it("returns empty string for nullish input", () => {
expect(sanitize(null)).toBe("");
expect(sanitize(undefined)).toBe("");
expect(sanitize("")).toBe("");
});
it("keeps safe formatting tags", () => {
const out = sanitize("<p>Hello <strong>world</strong></p>");
expect(out).toContain("<strong>world</strong>");
});
it("strips event handlers", () => {
const out = sanitize('<img src="x.gif" onerror="alert(1)">');
expect(out).not.toContain("onerror");
});
it("strips javascript: URLs", () => {
const out = sanitize('<a href="javascript:alert(1)">click</a>');
expect(out).not.toContain("javascript:");
});
it("strips svg onload vectors", () => {
const out = sanitize('<svg onload="alert(1)"><circle r="10"/></svg>');
expect(out).not.toContain("onload");
expect(out).not.toContain("<svg");
});
it("strips script tags", () => {
const out = sanitize("<p>hi</p><script>alert(1)</script>");
expect(out).not.toContain("<script");
expect(out).toContain("hi");
});
});
+47 -3
View File
@@ -1,8 +1,52 @@
import DOMPurify from "dompurify";
import DOMPurify from "isomorphic-dompurify";
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
const ALLOWED_TAGS = [
"a",
"b",
"blockquote",
"br",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"hr",
"i",
"img",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"u",
"ul",
];
const ALLOWED_ATTR = [
"href",
"src",
"alt",
"title",
"target",
"rel",
"colspan",
"rowspan",
];
export function sanitize(html: string | null | undefined): string {
if (!html) return "";
return sanitizeHtml(html);
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ["style", "script", "svg", "math", "form", "input", "button"],
USE_PROFILES: { html: true },
});
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, promises as fs } from "node:fs";
import { logServerError } from "@/lib/server-log";
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
import { listFigureLibraries } from "@/lib/services/figuremap";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
@@ -140,7 +141,9 @@ export async function deleteImportedFigure(
): Promise<{ deletedFile: boolean }> {
const p = await nitroPathFor(lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
await fs
.unlink(/*turbopackIgnore: true*/ p)
.catch((error) => logServerError("figure.delete_failed", error, { lib }));
return { deletedFile: true };
}
return { deletedFile: false };
+4 -1
View File
@@ -6,6 +6,7 @@ import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { logServerError } from "@/lib/server-log";
import {
DEFAULT_FURNI_NITRO_DIR,
getFurniAssetDirs,
@@ -479,7 +480,9 @@ export async function allocateCatalogItemId<T>(
catalogIdSeedChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch(() => {});
await prev.catch((error) =>
logServerError("furni.catalog_id_chain_failed", error),
);
try {
if (
catalogNextId === null ||
+10 -1
View File
@@ -1,6 +1,6 @@
import "server-only";
import { desc } from "drizzle-orm";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, WebsiteArticles } from "@/lib/db";
@@ -42,6 +42,15 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
createdAt: WebsiteArticles.createdAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(FETCH_LIMIT),
);
+10 -2
View File
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { logServerError } from "@/lib/server-log";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import {
type CloneSource,
@@ -154,7 +155,10 @@ export async function repairMissingIcons(
if (gamedataRoot) {
const badgeFiles = await fs
.readdir(getRuntimePath(gamedataRoot, "album1584"))
.catch(() => [] as string[]);
.catch((error) => {
logServerError("repair_icons.readdir_album_failed", error);
return [] as string[];
});
const albumBadgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
);
@@ -376,7 +380,11 @@ export async function repairMissingIcons(
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
);
} finally {
await fs.unlink(nitroTmp).catch(() => {});
await fs
.unlink(nitroTmp)
.catch((error) =>
logServerError("repair_icons.cleanup_failed", error),
);
}
if (ok) break;
}
+22 -4
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { logServerError } from "@/lib/server-log";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import {
@@ -96,7 +97,9 @@ async function allocateItemsBaseId<T>(
itemsBaseIdSeedChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch(() => {});
await prev.catch((error) =>
logServerError("upload.items_base_id_chain_failed", error),
);
try {
if (
itemsBaseNextId === null ||
@@ -365,10 +368,25 @@ export async function uploadSingleFurni(params: {
return nextId;
});
} catch (err) {
await fs.unlink(nitroPath).catch(() => {});
if (iconPath) await fs.unlink(iconPath).catch(() => {});
await fs
.unlink(nitroPath)
.catch((error) =>
logServerError("upload.cleanup_nitro_failed", error, { classname }),
);
if (iconPath)
await fs
.unlink(iconPath)
.catch((error) =>
logServerError("upload.cleanup_icon_failed", error, { classname }),
);
await Promise.all(
mirroredPaths.map((file) => fs.unlink(file).catch(() => {})),
mirroredPaths.map((file) =>
fs
.unlink(file)
.catch((error) =>
logServerError("upload.cleanup_mirror_failed", error, { file }),
),
),
);
return {
ok: false,
+11 -2
View File
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { env } from "@/env";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { logServerError } from "@/lib/server-log";
export type { WebhookAction } from "@/types/admin";
@@ -126,6 +127,14 @@ async function sendTelegram(payload: WebhookPayload): Promise<void> {
/** Fire-and-forget notification to Discord + Telegram */
export function notify(payload: WebhookPayload): void {
sendDiscord(payload).catch(() => {});
sendTelegram(payload).catch(() => {});
sendDiscord(payload).catch((error) =>
logServerError("webhook.discord_notify_failed", error, {
action: payload.action,
}),
);
sendTelegram(payload).catch((error) =>
logServerError("webhook.telegram_notify_failed", error, {
action: payload.action,
}),
);
}
+14
View File
@@ -663,5 +663,19 @@
}
}
}
},
"toolbar": {
"home": "الرئيسية",
"reload": "إعادة تحميل",
"fullscreen": "ملء الشاشة",
"logout": "تسجيل الخروج",
"onlineUsers": "المستخدمون المتواجدون",
"emulatorOnline": "المحاكي متصل",
"emulatorOffline": "المحاكي غير متصل",
"dragToMove": "اسحب للتحريك",
"hide": "إخفاء الشريط",
"fullscreenExit": "الخروج من ملء الشاشة",
"show": "إظهار الشريط",
"emulatorUnknown": "حالة المحاكي غير معروفة"
}
}
+14
View File
@@ -3916,5 +3916,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Начало",
"reload": "Презареждане",
"fullscreen": "Цял екран",
"logout": "Изход",
"onlineUsers": "Потребители онлайн",
"emulatorOnline": "Емулаторът е онлайн",
"emulatorOffline": "Емулаторът е офлайн",
"dragToMove": "Влачете за преместване",
"hide": "Скрий лентата",
"fullscreenExit": "Изход от цял екран",
"show": "Покажи лентата",
"emulatorUnknown": "Състоянието на емулатора е неизвестно"
}
}
+14
View File
@@ -3916,5 +3916,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Domů",
"reload": "Obnovit",
"fullscreen": "Celá obrazovka",
"logout": "Odhlásit",
"onlineUsers": "Uživatelé online",
"emulatorOnline": "Emulátor online",
"emulatorOffline": "Emulátor offline",
"dragToMove": "Přetažením přesunete",
"hide": "Skrýt lištu",
"fullscreenExit": "Ukončit celou obrazovku",
"show": "Zobrazit lištu",
"emulatorUnknown": "Stav emulátoru neznámý"
}
}
+14
View File
@@ -3916,5 +3916,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Hjem",
"reload": "Genindlæs",
"fullscreen": "Fuldskærm",
"logout": "Log ud",
"onlineUsers": "Brugere online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Træk for at flytte",
"hide": "Skjul værktøjslinje",
"fullscreenExit": "Afslut fuldskærm",
"show": "Vis værktøjslinje",
"emulatorUnknown": "Emulatorstatus ukendt"
}
}
+14
View File
@@ -3917,5 +3917,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Startseite",
"reload": "Neu laden",
"fullscreen": "Vollbild",
"logout": "Abmelden",
"onlineUsers": "Nutzer online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Ziehen zum Verschieben",
"hide": "Leiste ausblenden",
"fullscreenExit": "Vollbild beenden",
"show": "Leiste anzeigen",
"emulatorUnknown": "Emulatorstatus unbekannt"
}
}
+14
View File
@@ -3916,5 +3916,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Αρχική",
"reload": "Επαναφόρτωση",
"fullscreen": "Πλήρης οθόνη",
"logout": "Αποσύνδεση",
"onlineUsers": "Χρήστες συνδεδεμένοι",
"emulatorOnline": "Ο εξομοιωτής είναι online",
"emulatorOffline": "Ο εξομοιωτής είναι offline",
"dragToMove": "Σύρετε για μετακίνηση",
"hide": "Απόκρυψη γραμμής",
"fullscreenExit": "Έξοδος από πλήρη οθόνη",
"show": "Εμφάνιση γραμμής",
"emulatorUnknown": "Άγνωστη κατάσταση εξομοιωτή"
}
}
+49 -3
View File
@@ -1069,7 +1069,17 @@
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools",
"studio": "Studio"
"studio": "Studio",
"search": {
"placeholder": "Search…",
"noResults": "No results found",
"users": "Users",
"articles": "Articles",
"rooms": "Rooms",
"guilds": "Guilds",
"shop": "Shop",
"hint": "Start typing to search across users, articles, rooms, guilds and shop items"
}
},
"dashboard": {
"title": "Dashboard",
@@ -1120,7 +1130,13 @@
"saving": "Saving…",
"deleteArticle": "Delete article",
"deleteConfirm": "This action cannot be undone. The article will be permanently removed.",
"cancel": "Cancel"
"cancel": "Cancel",
"status": "Status",
"statusDraft": "Draft",
"statusScheduled": "Scheduled",
"statusPublished": "Published",
"publishAt": "Publish at",
"publishedAt": "Published at"
}
},
"events": {
@@ -1893,7 +1909,20 @@
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
"disband": "Disband guild",
"edit": "Edit guild",
"save": "Save",
"saving": "Saving…",
"updated": "Guild updated",
"updateError": "Could not update guild",
"formName": "Name",
"formDescription": "Description",
"formState": "State",
"formForum": "Forum",
"formReadForum": "Read forum",
"formPostMessages": "Post messages",
"formPostThreads": "Post threads",
"formModForum": "Moderate forum"
},
"transactions": {
"title": "Transactions",
@@ -1943,8 +1972,10 @@
"confirmDelete": "Delete voucher \"{code}\"?",
"created": "Voucher created",
"deleted": "Voucher deleted",
"updated": "Voucher updated",
"createError": "Could not create voucher",
"deleteError": "Could not delete voucher",
"updateError": "Could not update voucher",
"statusActive": "Active",
"statusUsedUp": "Used up",
"statusExpired": "Expired",
@@ -2010,6 +2041,7 @@
"items": "Items",
"addCategory": "Add category",
"editCategory": "Edit category",
"editValue": "Edit value",
"deleteCategory": "Delete category",
"noCategories": "No rare value categories yet",
"confirmDeleteCategory": "Delete category \"{name}\" and all its items?",
@@ -3920,5 +3952,19 @@
}
}
}
},
"toolbar": {
"home": "Home",
"reload": "Reload",
"fullscreen": "Fullscreen",
"logout": "Logout",
"onlineUsers": "Online users",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Drag to move",
"hide": "Hide toolbar",
"fullscreenExit": "Exit fullscreen",
"show": "Show toolbar",
"emulatorUnknown": "Emulator status unknown"
}
}
+14
View File
@@ -3917,5 +3917,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Inicio",
"reload": "Recargar",
"fullscreen": "Pantalla completa",
"logout": "Cerrar sesión",
"onlineUsers": "Usuarios en línea",
"emulatorOnline": "Emulador en línea",
"emulatorOffline": "Emulador fuera de línea",
"dragToMove": "Arrastra para mover",
"hide": "Ocultar barra",
"fullscreenExit": "Salir de pantalla completa",
"show": "Mostrar barra",
"emulatorUnknown": "Estado del emulador desconocido"
}
}
+14
View File
@@ -663,5 +663,19 @@
}
}
}
},
"toolbar": {
"home": "Koti",
"reload": "Lataa uudelleen",
"fullscreen": "Koko näyttö",
"logout": "Kirjaudu ulos",
"onlineUsers": "Käyttäjät verkossa",
"emulatorOnline": "Emulaattori verkossa",
"emulatorOffline": "Emulaattori ei verkossa",
"dragToMove": "Vedä liikuttaaksesi",
"hide": "Piilota työkalupalkki",
"fullscreenExit": "Lopeta koko näyttö",
"show": "Näytä työkalupalkki",
"emulatorUnknown": "Emulaattorin tila tuntematon"
}
}
+14
View File
@@ -3917,5 +3917,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Accueil",
"reload": "Recharger",
"fullscreen": "Plein écran",
"logout": "Déconnexion",
"onlineUsers": "Utilisateurs en ligne",
"emulatorOnline": "Émulateur en ligne",
"emulatorOffline": "Émulateur hors ligne",
"dragToMove": "Glissez pour déplacer",
"hide": "Masquer la barre",
"fullscreenExit": "Quitter le plein écran",
"show": "Afficher la barre",
"emulatorUnknown": "État de l'émulateur inconnu"
}
}
+14
View File
@@ -3916,5 +3916,19 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Početna",
"reload": "Ponovno učitaj",
"fullscreen": "Cijeli zaslon",
"logout": "Odjava",
"onlineUsers": "Korisnici online",
"emulatorOnline": "Emulator je online",
"emulatorOffline": "Emulator je offline",
"dragToMove": "Povucite za pomicanje",
"hide": "Sakrij traku",
"fullscreenExit": "Izađi iz cijelog zaslona",
"show": "Prikaži traku",
"emulatorUnknown": "Status emulatora nepoznat"
}
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more