feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
@@ -1,71 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
||||
const COMMENT_MAX = 255;
|
||||
|
||||
type CommentOutcome =
|
||||
| "posted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "not_found"
|
||||
| "error";
|
||||
|
||||
function commentRedirect(slug: string, outcome: CommentOutcome): never {
|
||||
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
|
||||
if (outcome === "posted") redirect(`${path}?comment=posted`);
|
||||
redirect(`${path}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment on a news article as the SIGNED-IN user. The author id is read
|
||||
* from the session (re-fetched via auth()), never from the submitted FormData,
|
||||
* so a crafted form cannot post as another account. The articleId comes from the
|
||||
* form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT).
|
||||
*
|
||||
* Errors redirect back with a machine-readable ?error= code; success redirects
|
||||
* with ?comment=posted.
|
||||
*/
|
||||
export async function postComment(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, COMMENT_MAX);
|
||||
if (!comment) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(comment)).ok) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
const slugHint = String(formData.get("slug") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
let outcome: CommentOutcome = "error";
|
||||
let slug = slugHint;
|
||||
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
let slug: string | null;
|
||||
try {
|
||||
// Confirm the article exists (and grab its slug for revalidation).
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) return;
|
||||
slug = article.slug;
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteArticleComments.create({
|
||||
data: {
|
||||
articleId,
|
||||
userId,
|
||||
comment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`comment:${userId}`, 5, 30_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, COMMENT_MAX);
|
||||
if (!comment) {
|
||||
outcome = "empty";
|
||||
} else if (!(await isAllowed(comment)).ok) {
|
||||
outcome = "moderated";
|
||||
} else {
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
slug = article.slug;
|
||||
const now = new Date();
|
||||
await prisma.websiteArticleComments.create({
|
||||
data: {
|
||||
articleId,
|
||||
userId,
|
||||
comment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
commentRedirect(slug, outcome);
|
||||
}
|
||||
@@ -4,16 +4,25 @@ import { env } from "@/env";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
||||
export type PrecheckResult =
|
||||
| "ok"
|
||||
| "invalid"
|
||||
| "twofactor"
|
||||
| "unverified"
|
||||
| "captcha";
|
||||
|
||||
/**
|
||||
* Validates username+password WITHOUT creating a session, and reports whether a
|
||||
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
|
||||
* Also enforces captcha + optional email-verification when configured.
|
||||
*/
|
||||
export async function precheckLogin(
|
||||
username: string,
|
||||
password: string,
|
||||
captchaToken?: string | null,
|
||||
): Promise<PrecheckResult> {
|
||||
const u = String(username ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -21,14 +30,29 @@ export async function precheckLogin(
|
||||
const p = String(password ?? "");
|
||||
if (!u || !p) return "invalid";
|
||||
|
||||
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
|
||||
return "invalid";
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`precheck:${ip}`, 10, 5 * 60_000)).ok) return "invalid";
|
||||
|
||||
let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider !== "none") {
|
||||
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
|
||||
}
|
||||
|
||||
let user: {
|
||||
password: string;
|
||||
twoFactorConfirmedAt: Date | null;
|
||||
mail: string | null;
|
||||
mailVerified: string;
|
||||
} | null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: u },
|
||||
select: { password: true, twoFactorConfirmedAt: true },
|
||||
select: {
|
||||
password: true,
|
||||
twoFactorConfirmedAt: true,
|
||||
mail: true,
|
||||
mailVerified: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return "invalid";
|
||||
@@ -50,5 +74,13 @@ export async function precheckLogin(
|
||||
});
|
||||
if (!res.valid) return "invalid";
|
||||
|
||||
if (
|
||||
(await siteSettings.getBool("require_email_verification", false)) &&
|
||||
user.mail &&
|
||||
user.mailVerified !== "1"
|
||||
) {
|
||||
return "unverified";
|
||||
}
|
||||
|
||||
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
|
||||
}
|
||||
+203
-191
@@ -1,230 +1,242 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
|
||||
async function requireRcon(): Promise<void> {
|
||||
await requirePermission(PERMS.RCON_EXECUTE);
|
||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||
|
||||
async function requireRconOk(ok: boolean): Promise<void> {
|
||||
if (!ok) throw new ActionError(RCON_FAIL);
|
||||
}
|
||||
|
||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||
export async function updateCatalog(): Promise<void> {
|
||||
await requireRcon();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
// RCON is best-effort; a dead socket must not 500 the admin page.
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const updateCatalog = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async () => {
|
||||
await requireRconOk(await rcon.updateCatalog());
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||
export async function updateWordFilter(): Promise<void> {
|
||||
await requireRcon();
|
||||
try {
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const updateWordFilter = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async () => {
|
||||
await requireRconOk(await rcon.updateWordFilter());
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||
export async function updateNavigator(): Promise<void> {
|
||||
await requireRcon();
|
||||
try {
|
||||
await rcon.send("updatenavigator", null);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const updateNavigator = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async () => {
|
||||
await requireRconOk(await rcon.send("updatenavigator", null));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const hotelAlertSchema = z.object({
|
||||
message: z.string().trim().min(1).max(512),
|
||||
});
|
||||
|
||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!message) return;
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const hotelAlert = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
||||
async (ctx) => {
|
||||
const message = ctx.data.message.normalize("NFC");
|
||||
await requireRconOk(await rcon.send("hotelalert", { message }));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const disconnectSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
username: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !username) return;
|
||||
try {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
const username = ctx.data.username.normalize("NFC");
|
||||
await requireRconOk(
|
||||
await rcon.disconnectUser(ctx.data.userId, username),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const alertUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().trim().min(1).max(512),
|
||||
});
|
||||
|
||||
/** Send an alert to a specific user (rcon: alertuser). */
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!userId || !message) return;
|
||||
try {
|
||||
await rcon.alertUser(userId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const message = ctx.data.message.normalize("NFC");
|
||||
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const forwardUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
roomId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||
export async function forwardUser(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const roomId = Number(formData.get("roomId"));
|
||||
if (!userId || !roomId) return;
|
||||
try {
|
||||
await rcon.forwardUser(userId, roomId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const forwardUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const giveCreditsSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
credits: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Give credits to a user (rcon: givecredits). */
|
||||
export async function giveCredits(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const credits = Number(formData.get("credits"));
|
||||
if (!userId || !credits || credits <= 0) return;
|
||||
try {
|
||||
await rcon.giveCredits(userId, credits);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const giveCredits = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const giveAmountSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
amount: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||
export async function giveDuckets(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const giveDuckets = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||
export async function giveDiamonds(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDiamonds(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const giveDiamonds = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const giveBadgeSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badge: z.string().trim().min(1).max(32),
|
||||
});
|
||||
|
||||
/** Give a badge to a user (rcon: givebadge). */
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !badge) return;
|
||||
try {
|
||||
await rcon.giveBadge(userId, badge);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
const badge = ctx.data.badge.normalize("NFC");
|
||||
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const setMottoSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
motto: z.string().trim().min(1).max(127),
|
||||
});
|
||||
|
||||
/** Set a user's motto (rcon: setmotto). */
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 127);
|
||||
if (!userId || !motto) return;
|
||||
try {
|
||||
await rcon.setMotto(userId, motto);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const setMotto = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||
async (ctx) => {
|
||||
const motto = ctx.data.motto.normalize("NFC");
|
||||
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const setRankSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
rank: z.coerce.number().int().min(0).max(10),
|
||||
});
|
||||
|
||||
/** Set a user's rank (rcon: setrank). */
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (!userId || rank < 0 || rank > 10) return;
|
||||
try {
|
||||
await rcon.setRank(userId, rank);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const setRank = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const executeCommandSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
command: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
/** Execute a command as a user (rcon: executecommand). */
|
||||
export async function executeCommand(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const command = String(formData.get("command") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !command) return;
|
||||
try {
|
||||
await rcon.executeCommand(userId, command);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const executeCommand = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||
async (ctx) => {
|
||||
const command = ctx.data.command.normalize("NFC");
|
||||
await requireRconOk(
|
||||
await rcon.executeCommand(ctx.data.userId, command),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const sendGiftSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
itemId: z.coerce.number().int().positive(),
|
||||
message: z.string().trim().max(255).optional().default("Here is a gift."),
|
||||
});
|
||||
|
||||
/** Send a gift to a user (rcon: sendgift). */
|
||||
export async function sendGift(formData: FormData): Promise<void> {
|
||||
await requireRcon();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const itemId = Number(formData.get("itemId"));
|
||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await rcon.sendGift(userId, itemId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
export const sendGift = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
||||
async (ctx) => {
|
||||
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
|
||||
await requireRconOk(
|
||||
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn());
|
||||
const mockSendMail = vi.hoisted(() => vi.fn());
|
||||
const mockGetTranslations = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
env: {
|
||||
APP_KEY: "test-app-key-for-hmac",
|
||||
AUTH_SECRET: "",
|
||||
APP_URL: "http://localhost:3000",
|
||||
HOTEL_NAME: "TestHotel",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/email", () => ({
|
||||
sendMail: mockSendMail,
|
||||
}));
|
||||
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: mockGetTranslations,
|
||||
}));
|
||||
|
||||
import {
|
||||
isValidVerificationToken,
|
||||
sendVerification,
|
||||
verificationToken,
|
||||
} from "./email-verify";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockResolvedValue("TestHotel");
|
||||
mockSendMail.mockResolvedValue(true);
|
||||
mockGetTranslations.mockRejectedValue(new Error("missing"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("email verification tokens", () => {
|
||||
it("issues timestamped HMAC tokens that validate", async () => {
|
||||
const token = await verificationToken("[email protected]");
|
||||
expect(token).toMatch(/^\d+\.[a-f0-9]{64}$/);
|
||||
expect(await isValidVerificationToken("[email protected]", token)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects legacy forever-valid digests", async () => {
|
||||
const legacy = "a".repeat(64);
|
||||
expect(
|
||||
await isValidVerificationToken("[email protected]", legacy),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects expired tokens", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const token = await verificationToken("[email protected]");
|
||||
vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); // > 24h
|
||||
expect(await isValidVerificationToken("[email protected]", token)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends mail with a verify link", async () => {
|
||||
mockGetTranslations.mockResolvedValue(
|
||||
((key: string, values?: { hotel?: string }) => {
|
||||
const map: Record<string, string> = {
|
||||
subject: `Verify your email · ${values?.hotel}`,
|
||||
heading: "Verify your email",
|
||||
body: `Welcome to ${values?.hotel}!`,
|
||||
button: "Verify email",
|
||||
fallback: "Paste this link:",
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
await sendVerification("[email protected]");
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
"[email protected]",
|
||||
expect.stringContaining("Verify your email"),
|
||||
expect.stringContaining("/verify?token="),
|
||||
);
|
||||
});
|
||||
});
|
||||
+60
-24
@@ -1,20 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { env } from "@/env";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Stateless email verification, AtomCMS-faithful but DB-table-free.
|
||||
// Stateless email verification with a time-limited HMAC token.
|
||||
//
|
||||
// Instead of persisting a row (password_resets style), the token is a keyed
|
||||
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
|
||||
// server-only secret, an attacker who only knows the email cannot forge a
|
||||
// matching token, and /verify can recompute + compare it without any storage.
|
||||
// The token is therefore deterministic per (email, secret) pair and stays valid
|
||||
// until the account's mail_verified flips to '1' (after which /verify no-ops).
|
||||
// Token format: `{issuedAtUnix}.{hmacHex}` where
|
||||
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
|
||||
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
|
||||
// (bare 64-char hex) are rejected.
|
||||
|
||||
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
|
||||
function verifySecret(): string {
|
||||
const secret = env.APP_KEY || env.AUTH_SECRET;
|
||||
if (!secret)
|
||||
@@ -24,26 +25,43 @@ function verifySecret(): string {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Compute the verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
return createHash("sha256")
|
||||
.update(`${normalised}|${verifySecret()}`)
|
||||
function sign(email: string, issuedAt: number): string {
|
||||
return createHmac("sha256", verifySecret())
|
||||
.update(`${email}|${issuedAt}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** Compute a fresh verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
return `${issuedAt}.${sign(normalised, issuedAt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time check that `token` matches the expected digest for `email`.
|
||||
* Returns false on any length/format mismatch rather than throwing.
|
||||
* Constant-time check that `token` matches a non-expired HMAC for `email`.
|
||||
* Returns false on format/expiry/signature mismatch rather than throwing.
|
||||
*/
|
||||
export async function isValidVerificationToken(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
||||
const expected = await verificationToken(email);
|
||||
if (!email || !token) return false;
|
||||
const normalised = email.trim().toLowerCase();
|
||||
|
||||
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
|
||||
if (!match) return false; // also rejects legacy forever-valid digests
|
||||
|
||||
const issuedAt = Number(match[1]);
|
||||
const sig = match[2]?.toLowerCase() ?? "";
|
||||
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
|
||||
|
||||
const ageMs = Date.now() - issuedAt * 1000;
|
||||
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
|
||||
|
||||
const expected = sign(normalised, issuedAt);
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(token.toLowerCase(), "utf8");
|
||||
const b = Buffer.from(sig, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -63,22 +81,40 @@ export async function sendVerification(email: string): Promise<boolean> {
|
||||
const hotelName =
|
||||
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
|
||||
let subject = `Verify your email · ${hotelName}`;
|
||||
let heading = "Verify your email";
|
||||
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
|
||||
let button = "Verify email";
|
||||
let fallback =
|
||||
"If the button doesn't work, paste this link into your browser:";
|
||||
|
||||
try {
|
||||
const t = await getTranslations("emails.verify");
|
||||
subject = t("subject", { hotel: hotelName });
|
||||
heading = t("heading");
|
||||
body = t("body", { hotel: hotelName });
|
||||
button = t("button");
|
||||
fallback = t("fallback");
|
||||
} catch {
|
||||
/* messages missing — keep English defaults */
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
||||
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
||||
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
||||
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
<p style="margin:1.25rem 0">
|
||||
<a href="${link}"
|
||||
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
|
||||
Verify email
|
||||
${escapeHtml(button)}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
|
||||
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
return sendMail(normalised, subject, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
|
||||
+64
-2
@@ -1,16 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
createEventSchema,
|
||||
eventPrizeSchema,
|
||||
eventTypeSchema,
|
||||
eventWinnerSchema,
|
||||
registerForEventSchema,
|
||||
updateEventSchema,
|
||||
} from "@/lib/validators/event";
|
||||
|
||||
@@ -193,3 +195,63 @@ export const addEventWinner = adminAction(
|
||||
return actionOk({ id: winner.id });
|
||||
},
|
||||
);
|
||||
|
||||
// ── Public site: register ───────────────────────────────────────────
|
||||
|
||||
export const registerForEvent = authAction(
|
||||
{
|
||||
schema: registerForEventSchema,
|
||||
rateLimitKey: "event-register",
|
||||
rateLimitMax: 10,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const userId = Number(ctx.session.user.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return actionError("Unauthorized");
|
||||
}
|
||||
|
||||
const event = await prisma.websiteEvent.findUnique({
|
||||
where: { id: ctx.data.eventId },
|
||||
include: {
|
||||
type: true,
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) return actionError("Event not found");
|
||||
if (event.status !== "published") {
|
||||
return actionError("This event is not open for registration");
|
||||
}
|
||||
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
|
||||
return actionError("This event has already ended");
|
||||
}
|
||||
if (event.type.minRank > 0) {
|
||||
const rank = Number(ctx.session.user.rank ?? 0);
|
||||
if (rank < event.type.minRank) {
|
||||
return actionError("Your rank is too low to join this event");
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.maxPlayers != null &&
|
||||
event._count.registrations >= event.maxPlayers
|
||||
) {
|
||||
return actionError("This event is full");
|
||||
}
|
||||
|
||||
const existing = await prisma.websiteEventRegistration.findUnique({
|
||||
where: {
|
||||
eventId_userId: { eventId: event.id, userId },
|
||||
},
|
||||
});
|
||||
if (existing) return actionError("You are already registered");
|
||||
|
||||
await prisma.websiteEventRegistration.create({
|
||||
data: { eventId: event.id, userId },
|
||||
});
|
||||
|
||||
revalidatePath("/events");
|
||||
revalidatePath(`/events/${event.id}`);
|
||||
return actionOk({ eventId: event.id });
|
||||
},
|
||||
);
|
||||
+73
-31
@@ -1,13 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
||||
const MESSAGE_MAX = 255;
|
||||
|
||||
type GuestbookOutcome =
|
||||
| "posted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function guestbookRedirect(username: string, outcome: GuestbookOutcome): never {
|
||||
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
|
||||
if (outcome === "posted") redirect(`${path}?guestbook=posted`);
|
||||
redirect(`${path}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a guestbook entry on a profile.
|
||||
*
|
||||
@@ -15,45 +41,61 @@ const MESSAGE_MAX = 255;
|
||||
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
||||
* another account. Only the PROFILE OWNER id (whose guestbook is written) is
|
||||
* taken from the form, and we resolve a profile username from the form purely
|
||||
* to revalidate the right page.
|
||||
* to revalidate / redirect to the right page.
|
||||
*
|
||||
* Errors redirect back with a machine-readable ?error= code; success redirects
|
||||
* with ?guestbook=posted.
|
||||
*/
|
||||
export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const profileId = Number(formData.get("profileId"));
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) return;
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(message)).ok) return;
|
||||
|
||||
// Optional: used only to revalidate the correct profile route.
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const now = new Date();
|
||||
let outcome: GuestbookOutcome = "error";
|
||||
|
||||
try {
|
||||
await prisma.websiteUserGuestbooks.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`guestbook:${userId}`, 5, 30_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const profileId = Number(formData.get("profileId"));
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) {
|
||||
outcome = "empty";
|
||||
} else if (!(await isAllowed(message)).ok) {
|
||||
outcome = "moderated";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteUserGuestbooks.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
guestbookRedirect(username, outcome);
|
||||
}
|
||||
+80
-39
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -12,49 +13,89 @@ const ticketSchema = z.object({
|
||||
content: z.string().min(1, "Content is required").max(5000),
|
||||
});
|
||||
|
||||
type TicketOutcome =
|
||||
| "created"
|
||||
| "invalid"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function ticketsRedirect(outcome: TicketOutcome): never {
|
||||
if (outcome === "created") redirect("/help/tickets?created=1");
|
||||
redirect(`/help/tickets?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTicket(formData: FormData): Promise<void> {
|
||||
// Re-read the session user id server-side; never trust a form-supplied id.
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
let outcome: TicketOutcome = "error";
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
|
||||
const parsed = ticketSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
|
||||
const { title, content } = parsed.data;
|
||||
|
||||
// Moderation check
|
||||
try {
|
||||
await moderateOrThrow(`${title} ${content}`);
|
||||
} catch {
|
||||
return;
|
||||
// Re-read the session user id server-side; never trust a form-supplied id.
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const raw = {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
|
||||
const parsed = ticketSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const { title, content } = parsed.data;
|
||||
|
||||
let moderated = false;
|
||||
try {
|
||||
await moderateOrThrow(`${title} ${content}`);
|
||||
} catch {
|
||||
moderated = true;
|
||||
outcome = "moderated";
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "created";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/help/tickets");
|
||||
ticketsRedirect(outcome);
|
||||
}
|
||||
+217
-43
@@ -1,9 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type FriendOutcome =
|
||||
| "accepted"
|
||||
| "declined"
|
||||
| "removed"
|
||||
| "not_found"
|
||||
| "unauthorized"
|
||||
| "invalid"
|
||||
| "error";
|
||||
|
||||
function messagesRedirect(outcome: FriendOutcome): never {
|
||||
if (outcome === "accepted") redirect("/messages?accepted=1");
|
||||
if (outcome === "declined") redirect("/messages?declined=1");
|
||||
redirect(`/messages?error=${outcome}`);
|
||||
}
|
||||
|
||||
function friendsRedirect(outcome: FriendOutcome): never {
|
||||
if (outcome === "removed") redirect("/friends?removed=1");
|
||||
redirect(`/friends?error=${outcome}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
@@ -16,60 +37,213 @@ import { prisma } from "@/lib/prisma";
|
||||
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
|
||||
* create both inside a transaction and delete the originating request so it no
|
||||
* longer shows as pending in the in-game messenger or here.
|
||||
*
|
||||
* Errors redirect back to /messages with a machine-readable ?error= code;
|
||||
* success redirects with ?accepted=1. redirect() is called OUTSIDE the
|
||||
* try/catch so its control-flow throw is never swallowed.
|
||||
*/
|
||||
export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) return;
|
||||
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
||||
let outcome: FriendOutcome = "error";
|
||||
|
||||
try {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request || request.userToId !== meId) return;
|
||||
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — just clear it.
|
||||
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
return;
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
} else {
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — clear it and treat as not found.
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Clear this request and any reverse pending request between the pair.
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: requestId },
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
outcome = "accepted";
|
||||
}
|
||||
}
|
||||
|
||||
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
revalidatePath("/friends");
|
||||
messagesRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
* Only the request's target (user_to_id) may decline. Deletes the
|
||||
* messenger_friendrequests row without creating a friendship.
|
||||
*/
|
||||
export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
let outcome: FriendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
} else {
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
outcome = "declined";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
messagesRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an existing friendship between the SIGNED-IN user and another user.
|
||||
*
|
||||
* Deletes BOTH directional rows in messenger_friendships (Arcturus stores one
|
||||
* row each way) and clears any leftover pending requests between the pair.
|
||||
* Only the friend id comes from the form; the session user is never trusted
|
||||
* from FormData.
|
||||
*/
|
||||
export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
let outcome: FriendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const deleted = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.messengerFriendships.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
});
|
||||
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/friends");
|
||||
revalidatePath("/messages");
|
||||
friendsRedirect(outcome);
|
||||
}
|
||||
@@ -40,6 +40,15 @@ vi.mock("@/lib/services/email", () => ({
|
||||
sendMail: mockSendMail,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/captcha", () => ({
|
||||
captchaConfig: vi.fn().mockResolvedValue({
|
||||
provider: "none",
|
||||
siteKey: "",
|
||||
field: "",
|
||||
}),
|
||||
verifyCaptcha: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { env } from "@/env";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -20,9 +21,19 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const ip = await clientIp();
|
||||
|
||||
// CAPTCHA when a provider is configured (mirrors register).
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip))) {
|
||||
redirect("/forgot?error=captcha");
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
|
||||
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000))
|
||||
.ok;
|
||||
const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok;
|
||||
|
||||
// Always respond the same way so we don't reveal which emails exist.
|
||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
|
||||
+113
-2
@@ -1,15 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionSchema,
|
||||
updatePollSchema,
|
||||
voteOnPollSchema,
|
||||
} from "@/lib/validators/poll";
|
||||
|
||||
// ── Polls ───────────────────────────────────────────────────────────
|
||||
@@ -113,3 +115,112 @@ export const deletePollQuestion = adminAction(
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Public site: vote ───────────────────────────────────────────────
|
||||
|
||||
function parsePollOptions(options: string): string[] {
|
||||
return options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export const voteOnPoll = authAction(
|
||||
{
|
||||
schema: voteOnPollSchema,
|
||||
rateLimitKey: "poll-vote",
|
||||
rateLimitMax: 20,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const userId = Number(ctx.session.user.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return actionError("Unauthorized");
|
||||
}
|
||||
|
||||
const poll = await prisma.websitePoll.findUnique({
|
||||
where: { id: ctx.data.pollId },
|
||||
include: { questions: true },
|
||||
});
|
||||
|
||||
if (!poll) return actionError("Poll not found");
|
||||
if (poll.status !== "active") {
|
||||
return actionError("This poll is not open for voting");
|
||||
}
|
||||
const now = Date.now();
|
||||
if (poll.startsAt && poll.startsAt.getTime() > now) {
|
||||
return actionError("This poll has not started yet");
|
||||
}
|
||||
if (poll.endsAt && poll.endsAt.getTime() < now) {
|
||||
return actionError("This poll has ended");
|
||||
}
|
||||
|
||||
const questionById = new Map(poll.questions.map((q) => [q.id, q]));
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const vote of ctx.data.votes) {
|
||||
if (seen.has(vote.questionId)) {
|
||||
return actionError("Duplicate vote for the same question");
|
||||
}
|
||||
seen.add(vote.questionId);
|
||||
|
||||
const question = questionById.get(vote.questionId);
|
||||
if (!question || question.pollId !== poll.id) {
|
||||
return actionError("Invalid question for this poll");
|
||||
}
|
||||
|
||||
const answer = vote.answer.trim();
|
||||
if (!answer) return actionError("Answer is required");
|
||||
|
||||
if (question.type === "text") {
|
||||
if (answer.length > 500) {
|
||||
return actionError("Answer is too long");
|
||||
}
|
||||
} else {
|
||||
const options = parsePollOptions(question.options);
|
||||
if (question.type === "multiple") {
|
||||
const selected = answer
|
||||
.split("\n")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean);
|
||||
if (selected.length === 0) {
|
||||
return actionError("Select at least one option");
|
||||
}
|
||||
if (selected.some((a) => !options.includes(a))) {
|
||||
return actionError("Invalid option selected");
|
||||
}
|
||||
} else if (!options.includes(answer)) {
|
||||
return actionError("Invalid option selected");
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await prisma.websitePollVote.findUnique({
|
||||
where: {
|
||||
questionId_userId: {
|
||||
questionId: vote.questionId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return actionError("You have already voted on this poll");
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
ctx.data.votes.map((vote) =>
|
||||
prisma.websitePollVote.create({
|
||||
data: {
|
||||
questionId: vote.questionId,
|
||||
userId,
|
||||
answer: vote.answer.trim(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
revalidatePath("/polls");
|
||||
revalidatePath(`/polls/${poll.id}`);
|
||||
return actionOk({ pollId: poll.id });
|
||||
},
|
||||
);
|
||||
+75
-36
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -11,6 +12,28 @@ const shoutSchema = z.object({
|
||||
message: z.string().min(1, "Message is required").max(255),
|
||||
});
|
||||
|
||||
type ShoutOutcome =
|
||||
| "posted"
|
||||
| "invalid"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function shoutsRedirect(outcome: ShoutOutcome): never {
|
||||
if (outcome === "posted") redirect("/radio/shouts?posted=1");
|
||||
redirect(`/radio/shouts?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a radio shout.
|
||||
*
|
||||
@@ -18,48 +41,64 @@ const shoutSchema = z.object({
|
||||
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
||||
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
|
||||
* session id is widened to BigInt for the insert.
|
||||
*
|
||||
* Errors redirect back with a machine-readable ?error= code; success redirects
|
||||
* with ?posted=1.
|
||||
*/
|
||||
export async function postShout(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
let outcome: ShoutOutcome = "error";
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
message: String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
};
|
||||
|
||||
const parsed = shoutSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
|
||||
const { message } = parsed.data;
|
||||
|
||||
// Moderation check
|
||||
try {
|
||||
await moderateOrThrow(message);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const raw = {
|
||||
message: String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
};
|
||||
|
||||
const parsed = shoutSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const { message } = parsed.data;
|
||||
|
||||
let moderated = false;
|
||||
try {
|
||||
await moderateOrThrow(message);
|
||||
} catch {
|
||||
moderated = true;
|
||||
outcome = "moderated";
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const now = new Date();
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/radio/shouts");
|
||||
shoutsRedirect(outcome);
|
||||
}
|
||||
+191
-94
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
|
||||
// guilds_forums_comments.message which is TEXT. Keep the first post's message
|
||||
@@ -10,6 +12,51 @@ import { prisma } from "@/lib/prisma";
|
||||
const SUBJECT_MAX = 255;
|
||||
const MESSAGE_MAX = 10000;
|
||||
|
||||
type FriendRequestOutcome =
|
||||
| "sent"
|
||||
| "self"
|
||||
| "invalid"
|
||||
| "already_friends"
|
||||
| "already_pending"
|
||||
| "incoming_pending"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type ThreadOutcome =
|
||||
| "posted"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function profileRedirect(
|
||||
username: string,
|
||||
outcome: FriendRequestOutcome,
|
||||
): never {
|
||||
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
|
||||
if (outcome === "sent") redirect(`${path}?friend=sent`);
|
||||
redirect(`${path}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
|
||||
const base =
|
||||
Number.isInteger(guildId) && guildId > 0
|
||||
? `/guilds/${guildId}/forum`
|
||||
: "/guilds";
|
||||
if (outcome === "posted") redirect(`${base}?posted=1`);
|
||||
redirect(`${base}/new?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a friend request to another user.
|
||||
*
|
||||
@@ -19,52 +66,81 @@ const MESSAGE_MAX = 10000;
|
||||
*
|
||||
* Writes into messenger_friendrequests (userFromId = requester, userToId =
|
||||
* target). The emulator surfaces the pending request in the in-game messenger.
|
||||
*
|
||||
* Errors redirect back to the profile with a machine-readable ?error= code;
|
||||
* success redirects with ?friend=sent. redirect() is called OUTSIDE the
|
||||
* try/catch so its control-flow throw is never swallowed.
|
||||
*/
|
||||
export async function sendFriendRequest(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const fromId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(fromId) || fromId <= 0) return;
|
||||
|
||||
const toId = Number(formData.get("userId"));
|
||||
if (!Number.isInteger(toId) || toId <= 0) return;
|
||||
|
||||
// Can't befriend yourself.
|
||||
if (toId === fromId) return;
|
||||
|
||||
try {
|
||||
// Guard against duplicate pending requests and already-existing friendships.
|
||||
const [existingRequest, existingFriendship] = await Promise.all([
|
||||
prisma.messengerFriendrequests.findFirst({
|
||||
where: { userFromId: fromId, userToId: toId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: fromId, userTwoId: toId },
|
||||
{ userOneId: toId, userTwoId: fromId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existingRequest || existingFriendship) return;
|
||||
|
||||
await prisma.messengerFriendrequests.create({
|
||||
data: { userFromId: fromId, userToId: toId },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: revalidate the target profile if a username was supplied, purely
|
||||
// to refresh any request-state UI rendered there.
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
let outcome: FriendRequestOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const fromId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(fromId) || fromId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend:${fromId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const toId = Number(formData.get("userId"));
|
||||
if (!Number.isInteger(toId) || toId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else if (toId === fromId) {
|
||||
outcome = "self";
|
||||
} else {
|
||||
// Guard against duplicate pending requests and already-existing friendships.
|
||||
const [outgoingRequest, incomingRequest, existingFriendship] =
|
||||
await Promise.all([
|
||||
prisma.messengerFriendrequests.findFirst({
|
||||
where: { userFromId: fromId, userToId: toId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.messengerFriendrequests.findFirst({
|
||||
where: { userFromId: toId, userToId: fromId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: fromId, userTwoId: toId },
|
||||
{ userOneId: toId, userTwoId: fromId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existingFriendship) {
|
||||
outcome = "already_friends";
|
||||
} else if (outgoingRequest) {
|
||||
outcome = "already_pending";
|
||||
} else if (incomingRequest) {
|
||||
// They already asked you — respond from Messages instead of
|
||||
// creating a duplicate reverse row.
|
||||
outcome = "incoming_pending";
|
||||
} else {
|
||||
await prisma.messengerFriendrequests.create({
|
||||
data: { userFromId: fromId, userToId: toId },
|
||||
});
|
||||
outcome = "sent";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
revalidatePath("/messages");
|
||||
profileRedirect(username, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,67 +154,88 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
|
||||
* plus the opening post stored as the first comment (guilds_forums_comments).
|
||||
* We create both in a transaction so the thread always has its first post, then
|
||||
* stamp posts_count = 1 to match the emulator's bookkeeping.
|
||||
*
|
||||
* Errors redirect back to the new-thread form with ?error=; success redirects
|
||||
* to the forum with ?posted=1.
|
||||
*/
|
||||
export async function postThread(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const openerId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(openerId) || openerId <= 0) return;
|
||||
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
||||
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let outcome: ThreadOutcome = "error";
|
||||
|
||||
try {
|
||||
// Confirm the guild exists (and has a forum) before opening a thread.
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guild) return;
|
||||
const session = await auth();
|
||||
const openerId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(openerId) || openerId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const thread = await tx.guildsForumsThreads.create({
|
||||
data: {
|
||||
guildId,
|
||||
openerId,
|
||||
subject,
|
||||
postsCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: 0,
|
||||
pinned: 0,
|
||||
locked: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`forum:${openerId}`, 3, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId: openerId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guild) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const thread = await tx.guildsForumsThreads.create({
|
||||
data: {
|
||||
guildId,
|
||||
openerId,
|
||||
subject,
|
||||
postsCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: 0,
|
||||
pinned: 0,
|
||||
locked: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId: openerId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
if (Number.isInteger(guildId) && guildId > 0) {
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
}
|
||||
threadRedirect(guildId, outcome);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Home, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorScreen } from "@/components/error-screen";
|
||||
|
||||
/**
|
||||
* Site route-segment error boundary. Renders inside the site shell layout.
|
||||
* Never shows the raw error to the user.
|
||||
*/
|
||||
export default function SiteErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ErrorScreen
|
||||
code={t("code")}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
body={t("body")}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<RefreshCw className="error-screen-btn-icon" aria-hidden />
|
||||
{t("tryAgain")}
|
||||
</button>
|
||||
<Link className="btn btn-outline" href="/">
|
||||
<Home className="error-screen-btn-icon" aria-hidden />
|
||||
{t("backHome")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
error.digest ? (
|
||||
<p className="error-screen-digest">
|
||||
{t("reference", { digest: error.digest })}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { registerForEvent } from "@/actions/events";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export function EventRegisterButton({
|
||||
eventId,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: {
|
||||
eventId: number;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.events");
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{disabledReason ?? t("cannotRegister")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => registerForEvent({ eventId }), {
|
||||
successMessage: t("registerSuccess"),
|
||||
errorMessage: t("registerError"),
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? t("registering") : t("register")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventRegisterButton } from "./event-register-button";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function parseEventId(slugOrId: string): number | null {
|
||||
if (/^\d+$/.test(slugOrId)) return Number.parseInt(slugOrId, 10);
|
||||
const match = slugOrId.match(/-(\d+)$/);
|
||||
if (match) return Number.parseInt(match[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slugOrId: string }>;
|
||||
}) {
|
||||
const { slugOrId } = await params;
|
||||
const eventId = parseEventId(slugOrId);
|
||||
if (eventId == null || !Number.isFinite(eventId)) notFound();
|
||||
|
||||
const t = await getTranslations("pages.events");
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
|
||||
const event = await prisma.websiteEvent
|
||||
.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
type: true,
|
||||
prizes: { orderBy: { position: "asc" } },
|
||||
winners: { orderBy: { position: "asc" } },
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!event || (event.status !== "published" && event.status !== "completed")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const alreadyRegistered =
|
||||
Number.isInteger(userId) && userId > 0
|
||||
? Boolean(
|
||||
await prisma.websiteEventRegistration
|
||||
.findUnique({
|
||||
where: { eventId_userId: { eventId: event.id, userId } },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
: false;
|
||||
|
||||
const winnerIds = event.winners.map((w) => w.userId);
|
||||
const winners =
|
||||
winnerIds.length > 0
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: winnerIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const winnerName = new Map(winners.map((u) => [u.id, u.username]));
|
||||
|
||||
const isFull =
|
||||
event.maxPlayers != null &&
|
||||
event._count.registrations >= event.maxPlayers;
|
||||
const hasEnded =
|
||||
event.status === "completed" ||
|
||||
(event.endsAt != null && event.endsAt.getTime() < Date.now());
|
||||
const canRegister =
|
||||
event.status === "published" && !hasEnded && !isFull && !alreadyRegistered;
|
||||
|
||||
let disabledReason: string | undefined;
|
||||
if (!session?.user) disabledReason = t("loginToRegister");
|
||||
else if (alreadyRegistered) disabledReason = t("alreadyRegistered");
|
||||
else if (hasEnded) disabledReason = t("eventEnded");
|
||||
else if (isFull) disabledReason = t("eventFull");
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/events">{t("back")}</Link>
|
||||
</p>
|
||||
|
||||
{event.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "21/9",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={event.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="📅"
|
||||
title={event.title}
|
||||
subtitle={`${event.type.name} · ${formatDate(event.startsAt, "datetime", "")}`}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||
{event.description}
|
||||
</p>
|
||||
<table style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ width: 140, textAlign: "left" }}>{t("when")}</th>
|
||||
<td>
|
||||
{formatDate(event.startsAt, "datetime", "")}
|
||||
{event.endsAt
|
||||
? ` — ${formatDate(event.endsAt, "datetime", "")}`
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>{t("status")}</th>
|
||||
<td>
|
||||
{event.status === "completed"
|
||||
? t("statusCompleted")
|
||||
: t("statusPublished")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>{t("registrations")}</th>
|
||||
<td>
|
||||
{event.maxPlayers != null
|
||||
? t("spots", {
|
||||
count: event._count.registrations,
|
||||
max: event.maxPlayers,
|
||||
})
|
||||
: t("registered", {
|
||||
count: event._count.registrations,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard icon="✍️" title={t("registerTitle")} subtitle={t("registerSubtitle")}>
|
||||
{session?.user && canRegister ? (
|
||||
<EventRegisterButton eventId={event.id} />
|
||||
) : (
|
||||
<EmptyState icon={session?.user ? "ℹ️" : "🔒"}>
|
||||
{disabledReason ?? t("cannotRegister")}
|
||||
</EmptyState>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{event.prizes.length > 0 ? (
|
||||
<ContentCard icon="🎁" title={t("prizesTitle")}>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
||||
{event.prizes.map((prize) => (
|
||||
<li key={prize.id} style={{ marginBottom: "0.35rem" }}>
|
||||
<strong>#{prize.position}</strong>
|
||||
{prize.description
|
||||
? ` — ${prize.description}`
|
||||
: ` — ${prize.prizeType}`}
|
||||
{prize.credits > 0 ? ` · ${prize.credits} credits` : ""}
|
||||
{prize.pixels > 0 ? ` · ${prize.pixels} pixels` : ""}
|
||||
{prize.points > 0 ? ` · ${prize.points} points` : ""}
|
||||
{prize.badgeCode ? ` · badge ${prize.badgeCode}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
{event.winners.length > 0 ? (
|
||||
<ContentCard icon="🏆" title={t("winnersTitle")}>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
||||
{event.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
#{w.position} —{" "}
|
||||
{winnerName.get(w.userId) ?? t("unknownUser", { id: w.userId })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt, slugify } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = { title: "Events" };
|
||||
|
||||
export default async function EventsPage() {
|
||||
const t = await getTranslations("pages.events");
|
||||
|
||||
const events = await prisma.websiteEvent
|
||||
.findMany({
|
||||
where: { status: { in: ["published", "completed"] } },
|
||||
orderBy: { startsAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
type: { select: { name: true, color: true, slug: true } },
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={events.length === 0}>
|
||||
{events.length === 0 ? (
|
||||
<EmptyState icon="📅">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{events.map((event) => {
|
||||
const href = `/events/${slugify(event.title)}-${event.id}`;
|
||||
const spots =
|
||||
event.maxPlayers != null
|
||||
? t("spots", {
|
||||
count: event._count.registrations,
|
||||
max: event.maxPlayers,
|
||||
})
|
||||
: t("registered", { count: event._count.registrations });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={event.id}
|
||||
href={href}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
textDecoration: "none",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
{event.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={event.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
backgroundColor:
|
||||
event.type.color ||
|
||||
"color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.1rem",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{event.type.name}
|
||||
{event.status === "completed"
|
||||
? ` · ${t("statusCompleted")}`
|
||||
: ""}
|
||||
</p>
|
||||
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.85rem" }}
|
||||
>
|
||||
{formatDate(event.startsAt, "datetime", "")}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
||||
{excerpt(event.description, 120)}
|
||||
</p>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "auto 0 0", fontSize: "0.8rem" }}
|
||||
>
|
||||
{spots}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requestReset } from "@/actions/password-reset";
|
||||
import { CaptchaWidget } from "@/components/auth/captcha-widget";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
export default async function ForgotPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ sent?: string }>;
|
||||
searchParams: Promise<{ sent?: string; error?: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.forgot");
|
||||
const { sent } = await searchParams;
|
||||
const { sent, error } = await searchParams;
|
||||
const cfg = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
@@ -30,6 +35,25 @@ export default async function ForgotPage({
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<CaptchaWidget
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
{error === "captcha" ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-danger)",
|
||||
textAlign: "center",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{t("errorCaptcha")}
|
||||
</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("sendResetLink")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function FriendsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { removeFriendship } from "@/actions/messenger";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
@@ -11,16 +13,38 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = { title: "Friends" };
|
||||
|
||||
export default async function FriendsPage() {
|
||||
type SearchParams = Promise<{ removed?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FriendsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.friends");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const { removed, error } = await searchParams;
|
||||
|
||||
// Friendships are bidirectional and stored once: the session user can appear
|
||||
// as either user_one_id or user_two_id, with the *other* column being the
|
||||
// friend. Read both directions, then dedupe the friend ids.
|
||||
// Friendships are stored as TWO directional rows (user_one_id→user_two_id
|
||||
// and the reverse). Read both directions, then dedupe the friend ids.
|
||||
const friendships = await prisma.messengerFriendships
|
||||
.findMany({
|
||||
where: {
|
||||
@@ -60,8 +84,28 @@ export default async function FriendsPage() {
|
||||
),
|
||||
]);
|
||||
|
||||
const errorMessage =
|
||||
error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{removed === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.removed")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title={t("title")}
|
||||
@@ -120,6 +164,16 @@ export default async function FriendsPage() {
|
||||
</p>
|
||||
<OnlineBadge online={isOnline} />
|
||||
</div>
|
||||
<form action={removeFriendship}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="friendId"
|
||||
value={String(friend.id)}
|
||||
/>
|
||||
<button type="submit" className="btn">
|
||||
{t("remove")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postThread } from "@/actions/social";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -8,13 +9,34 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function NewThreadPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.guildForumNew");
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const guildId = Number(id);
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
|
||||
|
||||
@@ -35,8 +57,25 @@ export default async function NewThreadPage({
|
||||
|
||||
if (!guild) notFound();
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
type SearchParams = Promise<{ posted?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
type ThreadRow = {
|
||||
id: number;
|
||||
openerId: number | null;
|
||||
@@ -24,11 +43,14 @@ function formatTimestamp(ts: number | null | undefined): string {
|
||||
|
||||
export default async function GuildForumPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.guildForum");
|
||||
const { id } = await params;
|
||||
const { posted } = await searchParams;
|
||||
const guildId = Number(id);
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
|
||||
|
||||
@@ -89,6 +111,12 @@ export default async function GuildForumPage({
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{posted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}`}>{t("backToGuild")}</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { createTicket } from "@/actions/help-tickets";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ created?: string; error?: string }>;
|
||||
|
||||
export default async function HelpTicketsPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HelpTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.helpTickets");
|
||||
const { created, error } = await searchParams;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
@@ -33,8 +56,30 @@ export default async function HelpTicketsPage() {
|
||||
const openCount = tickets.filter((t) => t.open).length;
|
||||
const closedCount = tickets.length - openCount;
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{created === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.created")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="🎫" title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="stat-grid">
|
||||
<StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} />
|
||||
@@ -99,7 +144,9 @@ export default async function HelpTicketsPage() {
|
||||
{t2.open ? t("statusOpen") : t("statusClosed")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{formatDate(t2.createdAt, "datetime", "")}</td>
|
||||
<td className="muted">
|
||||
{formatDate(t2.createdAt, "datetime", "")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+14
-153
@@ -1,158 +1,19 @@
|
||||
"use client";
|
||||
import { headers } from "next/headers";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("pages.login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const pre = await precheckLogin(username, password);
|
||||
if (pre === "invalid") {
|
||||
setError(t("errorInvalidCredentials"));
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
redirect: false,
|
||||
});
|
||||
if (!res || res.error) {
|
||||
setError(
|
||||
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
export default async function LoginPage() {
|
||||
const cfg = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon={needs2fa ? "🔒" : "🔑"}
|
||||
title={t("title")}
|
||||
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
|
||||
>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
{needs2fa ? (
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary" disabled={pending}>
|
||||
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!needs2fa ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
margin: "1rem 0",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: "var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
<span className="muted">{t("or")}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: "var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => signIn("discord", { callbackUrl: "/" })}
|
||||
>
|
||||
{t("continueWithDiscord")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => signIn("google", { callbackUrl: "/" })}
|
||||
>
|
||||
{t("continueWithGoogle")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-danger)",
|
||||
textAlign: "center",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
||||
>
|
||||
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
|
||||
<Link href="/forgot">{t("forgotPassword")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
<LoginForm
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function MessagesLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { acceptFriend } from "@/actions/messenger";
|
||||
import type { CSSProperties } from "react";
|
||||
import { acceptFriend, declineFriendRequest } from "@/actions/messenger";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
@@ -10,18 +11,45 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
accepted?: string;
|
||||
declined?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
// sended_on is a unix timestamp in seconds (emulator convention).
|
||||
return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function MessagesPage() {
|
||||
export default async function MessagesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.messages");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const { accepted, declined, error } = await searchParams;
|
||||
|
||||
// Pending friend requests addressed to the session user, the most recent
|
||||
// offline messages they've received, and the avatar-imager base URL.
|
||||
@@ -85,8 +113,35 @@ export default async function MessagesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "unauthorized"
|
||||
? t("errors.unauthorized")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{accepted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.accepted")}
|
||||
</div>
|
||||
) : null}
|
||||
{declined === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.declined")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{/* ── Pending friend requests ───────────────────────────── */}
|
||||
@@ -144,16 +199,34 @@ export default async function MessagesPage() {
|
||||
{t("wantsToBeFriend")}
|
||||
</p>
|
||||
</div>
|
||||
<form action={acceptFriend}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="requestId"
|
||||
value={String(req.id)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
{t("accept")}
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<form action={acceptFriend}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="requestId"
|
||||
value={String(req.id)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
{t("accept")}
|
||||
</button>
|
||||
</form>
|
||||
<form action={declineFriendRequest}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="requestId"
|
||||
value={String(req.id)}
|
||||
/>
|
||||
<button type="submit" className="btn">
|
||||
{t("decline")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postComment } from "@/actions/article-comments";
|
||||
import { toggleReaction } from "@/actions/article-reactions";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { cacheQuery } from "@/lib/prisma-cache";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type SearchParams = Promise<{ comment?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await prisma.websiteArticles
|
||||
.findMany({
|
||||
@@ -62,10 +81,13 @@ const REACTIONS: { key: string; emoji: string }[] = [
|
||||
|
||||
export default async function ArticlePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { comment, error } = await searchParams;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
const article = await cacheQuery(
|
||||
@@ -145,8 +167,34 @@ export default async function ArticlePage({
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{comment === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title={article.title}
|
||||
@@ -290,6 +338,7 @@ export default async function ArticlePage({
|
||||
{loggedIn ? (
|
||||
<form action={postComment} className="card">
|
||||
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
<label
|
||||
htmlFor="comment"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PollVoteForm } from "./poll-vote-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PollDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const pollId = Number.parseInt(id, 10);
|
||||
if (!Number.isFinite(pollId) || pollId <= 0) notFound();
|
||||
|
||||
const t = await getTranslations("pages.polls");
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
|
||||
const poll = await prisma.websitePoll
|
||||
.findUnique({
|
||||
where: { id: pollId },
|
||||
include: {
|
||||
questions: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!poll || (poll.status !== "active" && poll.status !== "closed")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (poll.startsAt && poll.startsAt.getTime() > now && poll.status === "active") {
|
||||
// Not started yet — treat as unavailable for guests
|
||||
notFound();
|
||||
}
|
||||
|
||||
const hasEnded =
|
||||
poll.status === "closed" ||
|
||||
(poll.endsAt != null && poll.endsAt.getTime() < now);
|
||||
const isOpen = poll.status === "active" && !hasEnded;
|
||||
|
||||
const questionIds = poll.questions.map((q) => q.id);
|
||||
const myVotes =
|
||||
Number.isInteger(userId) && userId > 0 && questionIds.length > 0
|
||||
? await prisma.websitePollVote
|
||||
.findMany({
|
||||
where: { userId, questionId: { in: questionIds } },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const hasVoted = myVotes.length > 0;
|
||||
|
||||
const allVotes =
|
||||
poll.showResults === 1
|
||||
? await prisma.websitePollVote
|
||||
.findMany({
|
||||
where: { questionId: { in: questionIds } },
|
||||
select: { questionId: true, answer: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
|
||||
const votesByQuestion = new Map<number, string[]>();
|
||||
for (const vote of allVotes) {
|
||||
const list = votesByQuestion.get(vote.questionId) ?? [];
|
||||
list.push(vote.answer);
|
||||
votesByQuestion.set(vote.questionId, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/polls">{t("back")}</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard
|
||||
icon="📊"
|
||||
title={poll.title}
|
||||
subtitle={
|
||||
hasEnded
|
||||
? t("statusClosed")
|
||||
: t("statusActive") +
|
||||
(poll.endsAt
|
||||
? ` · ${t("ends", { date: formatDate(poll.endsAt, "datetime", "") })}`
|
||||
: "")
|
||||
}
|
||||
>
|
||||
{poll.description ? (
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||
{poll.description}
|
||||
</p>
|
||||
) : null}
|
||||
</ContentCard>
|
||||
|
||||
{isOpen && !hasVoted ? (
|
||||
session?.user ? (
|
||||
<ContentCard icon="🗳️" title={t("voteTitle")} subtitle={t("voteSubtitle")}>
|
||||
<PollVoteForm
|
||||
pollId={poll.id}
|
||||
questions={poll.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
type: q.type,
|
||||
options: q.options,
|
||||
}))}
|
||||
/>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padded>
|
||||
<EmptyState icon="🔒">{t("loginToVote")}</EmptyState>
|
||||
</ContentCard>
|
||||
)
|
||||
) : (
|
||||
<ContentCard padded>
|
||||
<EmptyState icon={hasVoted ? "✅" : "ℹ️"}>
|
||||
{hasVoted
|
||||
? t("alreadyVoted")
|
||||
: hasEnded
|
||||
? t("pollClosed")
|
||||
: t("cannotVote")}
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{poll.showResults === 1 && (hasVoted || hasEnded) ? (
|
||||
<ContentCard icon="📈" title={t("resultsTitle")}>
|
||||
<div style={{ display: "grid", gap: "1.25rem" }}>
|
||||
{poll.questions.map((q) => {
|
||||
const options = q.options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
const votes = votesByQuestion.get(q.id) ?? [];
|
||||
const total = votes.length;
|
||||
|
||||
if (q.type === "text" || options.length === 0) {
|
||||
return (
|
||||
<div key={q.id}>
|
||||
<h3 style={{ margin: "0 0 0.4rem", fontSize: "1rem" }}>
|
||||
{q.question}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t("textResponses", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const opt of options) counts.set(opt, 0);
|
||||
for (const vote of votes) {
|
||||
for (const part of vote.split("\n").map((a) => a.trim()).filter(Boolean)) {
|
||||
counts.set(part, (counts.get(part) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={q.id}>
|
||||
<h3 style={{ margin: "0 0 0.65rem", fontSize: "1rem" }}>
|
||||
{q.question}
|
||||
</h3>
|
||||
<div style={{ display: "grid", gap: "0.55rem" }}>
|
||||
{options.map((opt) => {
|
||||
const count = counts.get(opt) ?? 0;
|
||||
const pct =
|
||||
total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={opt}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.85rem",
|
||||
marginBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
<span>{opt}</span>
|
||||
<span className="muted">
|
||||
{t("voteCount", { count, pct })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 16%, transparent)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${pct}%`,
|
||||
borderRadius: 999,
|
||||
background: "var(--color-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0.5rem 0 0", fontSize: "0.8rem" }}
|
||||
>
|
||||
{t("totalVotes", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { voteOnPoll } from "@/actions/polls";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
type: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
function parseOptions(options: string): string[] {
|
||||
return options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function PollVoteForm({
|
||||
pollId,
|
||||
questions,
|
||||
}: {
|
||||
pollId: number;
|
||||
questions: Question[];
|
||||
}) {
|
||||
const t = useTranslations("pages.polls");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({});
|
||||
const [multi, setMulti] = useState<Record<number, string[]>>({});
|
||||
|
||||
function setSingle(questionId: number, value: string) {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
}
|
||||
|
||||
function toggleMulti(questionId: number, option: string) {
|
||||
setMulti((prev) => {
|
||||
const current = prev[questionId] ?? [];
|
||||
const next = current.includes(option)
|
||||
? current.filter((o) => o !== option)
|
||||
: [...current, option];
|
||||
return { ...prev, [questionId]: next };
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const votes = questions.map((q) => {
|
||||
if (q.type === "multiple") {
|
||||
return {
|
||||
questionId: q.id,
|
||||
answer: (multi[q.id] ?? []).join("\n"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
questionId: q.id,
|
||||
answer: (answers[q.id] ?? "").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
if (votes.some((v) => !v.answer)) {
|
||||
toast.error(t("voteError"));
|
||||
return;
|
||||
}
|
||||
|
||||
run(() => voteOnPoll({ pollId, votes }), {
|
||||
successMessage: t("voteSuccess"),
|
||||
errorMessage: t("voteError"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "1.25rem" }}>
|
||||
{questions.map((q) => {
|
||||
const options = parseOptions(q.options);
|
||||
return (
|
||||
<fieldset
|
||||
key={q.id}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
|
||||
borderRadius: "0.65rem",
|
||||
}}
|
||||
>
|
||||
<legend style={{ padding: "0 0.35rem", fontWeight: 700 }}>
|
||||
{q.question}
|
||||
</legend>
|
||||
|
||||
{q.type === "text" ? (
|
||||
<textarea
|
||||
required
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
value={answers[q.id] ?? ""}
|
||||
onChange={(e) => setSingle(q.id, e.target.value)}
|
||||
placeholder={t("textPlaceholder")}
|
||||
style={{ width: "100%", marginTop: "0.35rem", resize: "vertical" }}
|
||||
/>
|
||||
) : q.type === "multiple" ? (
|
||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(multi[q.id] ?? []).includes(opt)}
|
||||
onChange={() => toggleMulti(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
required
|
||||
checked={(answers[q.id] ?? "") === opt}
|
||||
onChange={() => setSingle(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? t("voting") : t("submitVote")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = { title: "Polls" };
|
||||
|
||||
export default async function PollsPage() {
|
||||
const t = await getTranslations("pages.polls");
|
||||
const now = new Date();
|
||||
|
||||
const polls = await prisma.websitePoll
|
||||
.findMany({
|
||||
where: {
|
||||
status: { in: ["active", "closed"] },
|
||||
OR: [{ startsAt: null }, { startsAt: { lte: now } }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
_count: { select: { questions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📊" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={polls.length === 0}>
|
||||
{polls.length === 0 ? (
|
||||
<EmptyState icon="📊">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{polls.map((poll) => {
|
||||
const ended =
|
||||
poll.status === "closed" ||
|
||||
(poll.endsAt != null && poll.endsAt.getTime() < Date.now());
|
||||
return (
|
||||
<Link
|
||||
key={poll.id}
|
||||
href={`/polls/${poll.id}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
display: "grid",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{ended ? t("statusClosed") : t("statusActive")}
|
||||
{" · "}
|
||||
{t("questionsCount", { count: poll._count.questions })}
|
||||
</p>
|
||||
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
|
||||
{poll.title}
|
||||
</h3>
|
||||
{poll.description ? (
|
||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
||||
{excerpt(poll.description, 120)}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.85rem" }}
|
||||
>
|
||||
{poll.endsAt
|
||||
? t("ends", {
|
||||
date: formatDate(poll.endsAt, "datetime", ""),
|
||||
})
|
||||
: formatDate(poll.createdAt, "date", "")}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postShout } from "@/actions/radio-shouts";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ posted?: string; error?: string }>;
|
||||
|
||||
export default async function RadioShoutsPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RadioShoutsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.radioShouts");
|
||||
const { posted, error } = await searchParams;
|
||||
|
||||
const [shouts, imagerBase, session] = await Promise.all([
|
||||
prisma.radioShouts
|
||||
@@ -41,8 +64,30 @@ export default async function RadioShoutsPage() {
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{posted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{isLoggedIn ? (
|
||||
@@ -122,7 +167,9 @@ export default async function RadioShoutsPage() {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
|
||||
<span className="muted">{formatDate(s.createdAt, "datetime", "")}</span>
|
||||
<span className="muted">
|
||||
{formatDate(s.createdAt, "datetime", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{s.message}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postGuestbook } from "@/actions/guestbook";
|
||||
import { sendFriendRequest } from "@/actions/social";
|
||||
import {
|
||||
ContentCard,
|
||||
EmptyState,
|
||||
@@ -12,11 +13,34 @@ import {
|
||||
} from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
friend?: string;
|
||||
guestbook?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
@@ -48,14 +72,16 @@ function formatTimestamp(ts: number | null | undefined): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ username: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.profile");
|
||||
const { username } = await params;
|
||||
const { friend, guestbook: guestbookStatus, error } = await searchParams;
|
||||
|
||||
const [user, imagerBase, session] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
@@ -84,7 +110,65 @@ export default async function ProfilePage({
|
||||
const registered = new Date(user.accountCreated * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const isLoggedIn = Boolean(session?.user?.id);
|
||||
const meId = Number(session?.user?.id);
|
||||
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
||||
const isSelf = isLoggedIn && meId === user.id;
|
||||
|
||||
// Friend / request state relative to the signed-in viewer (if any).
|
||||
let isFriend = false;
|
||||
let outgoingPending = false;
|
||||
let incomingPending = false;
|
||||
if (isLoggedIn && !isSelf) {
|
||||
const [friendship, outgoing, incoming] = await Promise.all([
|
||||
prisma.messengerFriendships
|
||||
.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: user.id },
|
||||
{ userOneId: user.id, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.messengerFriendrequests
|
||||
.findFirst({
|
||||
where: { userFromId: meId, userToId: user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.messengerFriendrequests
|
||||
.findFirst({
|
||||
where: { userFromId: user.id, userToId: meId },
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
isFriend = Boolean(friendship);
|
||||
outgoingPending = Boolean(outgoing);
|
||||
incomingPending = Boolean(incoming);
|
||||
}
|
||||
|
||||
const feedbackErrorMessage =
|
||||
error === "self"
|
||||
? t("errors.self")
|
||||
: error === "already_friends"
|
||||
? t("errors.alreadyFriends")
|
||||
: error === "already_pending"
|
||||
? t("errors.alreadyPending")
|
||||
: error === "incoming_pending"
|
||||
? t("errors.incomingPending")
|
||||
: error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
// Profile sub-sections. Each query is isolated so a DB hiccup degrades that
|
||||
// single section to empty rather than 500-ing the whole profile.
|
||||
@@ -205,6 +289,22 @@ export default async function ProfilePage({
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{friend === "sent" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.sent")}
|
||||
</div>
|
||||
) : null}
|
||||
{guestbookStatus === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.guestbook")}
|
||||
</div>
|
||||
) : null}
|
||||
{feedbackErrorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{feedbackErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Profile header ──────────────────── */}
|
||||
<ContentCard padded={false}>
|
||||
<div
|
||||
@@ -230,6 +330,31 @@ export default async function ProfilePage({
|
||||
</p>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
</div>
|
||||
{isLoggedIn && !isSelf ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{isFriend ? (
|
||||
<span className="muted" style={{ fontWeight: 600 }}>
|
||||
{t("alreadyFriends")}
|
||||
</span>
|
||||
) : outgoingPending ? (
|
||||
<span className="muted" style={{ fontWeight: 600 }}>
|
||||
{t("requestPending")}
|
||||
</span>
|
||||
) : incomingPending ? (
|
||||
<Link href="/messages" className="btn btn-secondary">
|
||||
{t("respondInMessages")}
|
||||
</Link>
|
||||
) : (
|
||||
<form action={sendFriendRequest}>
|
||||
<input type="hidden" name="userId" value={String(user.id)} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("addFriend")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
|
||||
<StatBlock icon="🏅" value={user.rank} label={t("statRank")} />
|
||||
@@ -563,7 +688,9 @@ export default async function ProfilePage({
|
||||
<strong>
|
||||
{author?.username ?? t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">{formatDate(g.createdAt, "date", "")}</span>
|
||||
<span className="muted">
|
||||
{formatDate(g.createdAt, "date", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Achievement = {
|
||||
name: string;
|
||||
category: string;
|
||||
|
||||
@@ -5,8 +5,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||
const WARNING = new Set(["warning", "warn"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
executeCommand,
|
||||
forwardUser,
|
||||
giveBadge,
|
||||
giveCredits,
|
||||
giveDiamonds,
|
||||
giveDuckets,
|
||||
hotelAlert,
|
||||
sendGift,
|
||||
setMotto,
|
||||
setRank,
|
||||
updateCatalog,
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
} from "@/actions/commandocentrum";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
const RCON_ERROR = "RCON command failed. Is the emulator running?";
|
||||
|
||||
export function CommandocentrumControls() {
|
||||
const t = useTranslations("pages.admin.commandocentrum");
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
const [hotelMessage, setHotelMessage] = useState("");
|
||||
const [disconnect, setDisconnect] = useState({ userId: "", username: "" });
|
||||
const [alert, setAlert] = useState({ userId: "", message: "" });
|
||||
const [forward, setForward] = useState({ userId: "", roomId: "" });
|
||||
const [credits, setCredits] = useState({ userId: "", credits: "" });
|
||||
const [duckets, setDuckets] = useState({ userId: "", amount: "" });
|
||||
const [diamonds, setDiamonds] = useState({ userId: "", amount: "" });
|
||||
const [badge, setBadge] = useState({ userId: "", badge: "" });
|
||||
const [motto, setMottoForm] = useState({ userId: "", motto: "" });
|
||||
const [rank, setRankForm] = useState({ userId: "", rank: "" });
|
||||
const [command, setCommand] = useState({ userId: "", command: "" });
|
||||
const [gift, setGift] = useState({
|
||||
userId: "",
|
||||
itemId: "",
|
||||
message: t("defaultGiftMessage"),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateCatalog")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateCatalogDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateCatalog(), {
|
||||
successMessage: "Catalog updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("updateCatalog")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateWordFilter")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateWordFilterDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateWordFilter(), {
|
||||
successMessage: "Word filter updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("updateWordFilter")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateNavigator")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateNavigatorDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateNavigator(), {
|
||||
successMessage: "Navigator updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("updateNavigator")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card mt-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("hotelAlert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("hotelAlertDesc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={hotelMessage}
|
||||
onChange={(e) => setHotelMessage(e.target.value)}
|
||||
required
|
||||
maxLength={512}
|
||||
placeholder={t("messageToBroadcast")}
|
||||
className="flex-1 min-w-[220px]"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={isPending || !hotelMessage.trim()}
|
||||
onClick={() =>
|
||||
run(() => hotelAlert({ message: hotelMessage }), {
|
||||
successMessage: "Hotel alert sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setHotelMessage(""),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sendAlert")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("userActions")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("userActionsDesc")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.disconnect")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("disconnectDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={disconnect.userId}
|
||||
onChange={(e) =>
|
||||
setDisconnect((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("usernameRequired")}
|
||||
value={disconnect.username}
|
||||
onChange={(e) =>
|
||||
setDisconnect((s) => ({ ...s, username: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger w-full"
|
||||
disabled={
|
||||
isPending ||
|
||||
!disconnect.userId ||
|
||||
!disconnect.username.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
disconnectUser({
|
||||
userId: Number(disconnect.userId),
|
||||
username: disconnect.username,
|
||||
}),
|
||||
{
|
||||
successMessage: "User disconnected.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDisconnect({ userId: "", username: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("actions.disconnect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.alert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("alertDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={alert.userId}
|
||||
onChange={(e) =>
|
||||
setAlert((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("alertMessage")}
|
||||
value={alert.message}
|
||||
onChange={(e) =>
|
||||
setAlert((s) => ({ ...s, message: e.target.value }))
|
||||
}
|
||||
maxLength={512}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={
|
||||
isPending || !alert.userId || !alert.message.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
alertUser({
|
||||
userId: Number(alert.userId),
|
||||
message: alert.message,
|
||||
}),
|
||||
{
|
||||
successMessage: "Alert sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setAlert({ userId: "", message: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("actions.alert")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("forwardToRoom")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("forwardDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={forward.userId}
|
||||
onChange={(e) =>
|
||||
setForward((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("roomId")}
|
||||
value={forward.roomId}
|
||||
onChange={(e) =>
|
||||
setForward((s) => ({ ...s, roomId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !forward.userId || !forward.roomId}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
forwardUser({
|
||||
userId: Number(forward.userId),
|
||||
roomId: Number(forward.roomId),
|
||||
}),
|
||||
{
|
||||
successMessage: "User forwarded.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setForward({ userId: "", roomId: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("forwardUser")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveCredits")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveCreditsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={credits.userId}
|
||||
onChange={(e) =>
|
||||
setCredits((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
value={credits.credits}
|
||||
onChange={(e) =>
|
||||
setCredits((s) => ({ ...s, credits: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !credits.userId || !credits.credits}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveCredits({
|
||||
userId: Number(credits.userId),
|
||||
credits: Number(credits.credits),
|
||||
}),
|
||||
{
|
||||
successMessage: "Credits sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setCredits({ userId: "", credits: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveCredits")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDuckets")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDucketsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={duckets.userId}
|
||||
onChange={(e) =>
|
||||
setDuckets((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
value={duckets.amount}
|
||||
onChange={(e) =>
|
||||
setDuckets((s) => ({ ...s, amount: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !duckets.userId || !duckets.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveDuckets({
|
||||
userId: Number(duckets.userId),
|
||||
amount: Number(duckets.amount),
|
||||
}),
|
||||
{
|
||||
successMessage: "Duckets sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDuckets({ userId: "", amount: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveDuckets")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDiamonds")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDiamondsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={diamonds.userId}
|
||||
onChange={(e) =>
|
||||
setDiamonds((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
value={diamonds.amount}
|
||||
onChange={(e) =>
|
||||
setDiamonds((s) => ({ ...s, amount: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !diamonds.userId || !diamonds.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveDiamonds({
|
||||
userId: Number(diamonds.userId),
|
||||
amount: Number(diamonds.amount),
|
||||
}),
|
||||
{
|
||||
successMessage: "Diamonds sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDiamonds({ userId: "", amount: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveDiamonds")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveBadge")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveBadgeDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={badge.userId}
|
||||
onChange={(e) =>
|
||||
setBadge((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("badgeCode")}
|
||||
value={badge.badge}
|
||||
onChange={(e) =>
|
||||
setBadge((s) => ({ ...s, badge: e.target.value }))
|
||||
}
|
||||
maxLength={32}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !badge.userId || !badge.badge.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveBadge({
|
||||
userId: Number(badge.userId),
|
||||
badge: badge.badge,
|
||||
}),
|
||||
{
|
||||
successMessage: "Badge granted.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setBadge({ userId: "", badge: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveBadge")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setMotto")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setMottoDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={motto.userId}
|
||||
onChange={(e) =>
|
||||
setMottoForm((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("newMotto")}
|
||||
value={motto.motto}
|
||||
onChange={(e) =>
|
||||
setMottoForm((s) => ({ ...s, motto: e.target.value }))
|
||||
}
|
||||
maxLength={127}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !motto.userId || !motto.motto.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
setMotto({
|
||||
userId: Number(motto.userId),
|
||||
motto: motto.motto,
|
||||
}),
|
||||
{
|
||||
successMessage: "Motto updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setMottoForm({ userId: "", motto: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("setMotto")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setRank")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setRankDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={rank.userId}
|
||||
onChange={(e) =>
|
||||
setRankForm((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder={t("rankPlaceholder")}
|
||||
value={rank.rank}
|
||||
onChange={(e) =>
|
||||
setRankForm((s) => ({ ...s, rank: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !rank.userId || rank.rank === ""}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
setRank({
|
||||
userId: Number(rank.userId),
|
||||
rank: Number(rank.rank),
|
||||
}),
|
||||
{
|
||||
successMessage: "Rank updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setRankForm({ userId: "", rank: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("setRank")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("executeCommand")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("executeCommandDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={command.userId}
|
||||
onChange={(e) =>
|
||||
setCommand((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("commandPlaceholder")}
|
||||
value={command.command}
|
||||
onChange={(e) =>
|
||||
setCommand((s) => ({ ...s, command: e.target.value }))
|
||||
}
|
||||
maxLength={100}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={
|
||||
isPending || !command.userId || !command.command.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
executeCommand({
|
||||
userId: Number(command.userId),
|
||||
command: command.command,
|
||||
}),
|
||||
{
|
||||
successMessage: "Command executed.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setCommand({ userId: "", command: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("execute")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("sendGift")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("sendGiftDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={gift.userId}
|
||||
onChange={(e) =>
|
||||
setGift((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("furniId")}
|
||||
value={gift.itemId}
|
||||
onChange={(e) =>
|
||||
setGift((s) => ({ ...s, itemId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("giftMessage")}
|
||||
value={gift.message}
|
||||
onChange={(e) =>
|
||||
setGift((s) => ({ ...s, message: e.target.value }))
|
||||
}
|
||||
maxLength={255}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !gift.userId || !gift.itemId}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
sendGift({
|
||||
userId: Number(gift.userId),
|
||||
itemId: Number(gift.itemId),
|
||||
message: gift.message,
|
||||
}),
|
||||
{
|
||||
successMessage: "Gift sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setGift({
|
||||
userId: "",
|
||||
itemId: "",
|
||||
message: t("defaultGiftMessage"),
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("sendGift")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,6 @@
|
||||
import os from "node:os";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
executeCommand,
|
||||
forwardUser,
|
||||
giveBadge,
|
||||
giveCredits,
|
||||
giveDiamonds,
|
||||
giveDuckets,
|
||||
hotelAlert,
|
||||
sendGift,
|
||||
setMotto,
|
||||
setRank,
|
||||
updateCatalog,
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
} from "@/actions/commandocentrum";
|
||||
import {
|
||||
DiagnosticRow,
|
||||
InfoItem,
|
||||
@@ -28,6 +11,7 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -223,421 +207,7 @@ export default async function CommandoCentrum() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<form action={updateCatalog} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateCatalog")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateCatalogDesc")}
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("updateCatalog")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={updateWordFilter} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateWordFilter")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateWordFilterDesc")}
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("updateWordFilter")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={updateNavigator} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateNavigator")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateNavigatorDesc")}
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("updateNavigator")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action={hotelAlert} className="admin-card mt-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("hotelAlert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("hotelAlertDesc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="message"
|
||||
required
|
||||
maxLength={512}
|
||||
placeholder={t("messageToBroadcast")}
|
||||
className="flex-1 min-w-[220px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("sendAlert")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── User RCON actions ──────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("userActions")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("userActionsDesc")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<form action={disconnectUser} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.disconnect")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("disconnectDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("usernameRequired")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger w-full">
|
||||
{t("actions.disconnect")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={alertUser} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.alert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("alertDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="message"
|
||||
type="text"
|
||||
placeholder={t("alertMessage")}
|
||||
required
|
||||
maxLength={512}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("actions.alert")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={forwardUser} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("forwardToRoom")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("forwardDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="roomId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("roomId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("forwardUser")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<form action={giveCredits} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveCredits")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveCreditsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="credits"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveCredits")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={giveDuckets} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDuckets")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDucketsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveDuckets")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={giveDiamonds} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDiamonds")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDiamondsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveDiamonds")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<form action={giveBadge} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveBadge")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveBadgeDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="badge"
|
||||
type="text"
|
||||
placeholder={t("badgeCode")}
|
||||
required
|
||||
maxLength={32}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveBadge")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={setMotto} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setMotto")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setMottoDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="motto"
|
||||
type="text"
|
||||
placeholder={t("newMotto")}
|
||||
required
|
||||
maxLength={127}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("setMotto")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={setRank} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setRank")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setRankDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="rank"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder={t("rankPlaceholder")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("setRank")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<form action={executeCommand} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("executeCommand")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("executeCommandDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="command"
|
||||
type="text"
|
||||
placeholder={t("commandPlaceholder")}
|
||||
required
|
||||
maxLength={100}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("execute")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={sendGift} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("sendGift")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("sendGiftDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="itemId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("furniId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="message"
|
||||
type="text"
|
||||
placeholder={t("giftMessage")}
|
||||
maxLength={255}
|
||||
defaultValue={t("defaultGiftMessage")}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("sendGift")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<CommandocentrumControls />
|
||||
|
||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
|
||||
@@ -8,8 +8,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminEmailTemplates() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteEvent } from "@/actions/events";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface EventRow {
|
||||
@@ -35,6 +38,8 @@ interface EventsTableProps {
|
||||
|
||||
export function EventsTable({ data }: EventsTableProps) {
|
||||
const t = useTranslations("pages.admin.events");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
published: t("statusPublished"),
|
||||
@@ -43,6 +48,20 @@ export function EventsTable({ data }: EventsTableProps) {
|
||||
completed: t("statusCompleted"),
|
||||
};
|
||||
|
||||
async function handleDelete(row: EventRow) {
|
||||
const ok = await confirm({
|
||||
title: "Delete event",
|
||||
description: `Delete "${row.title}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteEvent({ id: row.id }), {
|
||||
successMessage: "Event deleted!",
|
||||
errorMessage: "Failed to delete event.",
|
||||
});
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<EventRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{
|
||||
@@ -88,20 +107,34 @@ export function EventsTable({ data }: EventsTableProps) {
|
||||
key: "actions",
|
||||
label: t("colActions"),
|
||||
render: (_, row) => (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/events/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/events/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(row)}
|
||||
aria-label="Delete event"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { FaviconForm } from "./favicon-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminFaviconPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,52 +56,6 @@ interface PageMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
|
||||
|
||||
async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (classname: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (
|
||||
evt.type === "item_progress" &&
|
||||
(evt.status === "done" || evt.status === "failed")
|
||||
) {
|
||||
onDone(evt.classname);
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
|
||||
// ── Source form ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface SourceFormProps {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,52 +44,6 @@ type Mode = "libs" | "sets";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
// ── Shared SSE reader ─────────────────────────────────────────────────────────
|
||||
|
||||
async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (label: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (
|
||||
evt.type === "item_progress" &&
|
||||
(evt.status === "done" || evt.status === "failed")
|
||||
) {
|
||||
onDone(evt.classname);
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
|
||||
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
|
||||
|
||||
// A default dressed avatar; the previewed part overrides/adds its slot so the
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
|
||||
interface PetItem {
|
||||
lib: string;
|
||||
@@ -109,34 +110,22 @@ export function ImportPetsClient() {
|
||||
setBatchProgress(null);
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let done = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (evt.type === "item_progress" && evt.status === "done") {
|
||||
done++;
|
||||
markDone(evt.classname);
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
}
|
||||
await readSseStream(res.body, (evt) => {
|
||||
if (evt.type === "item_progress" && evt.status === "done") {
|
||||
done++;
|
||||
markDone(String(evt.classname ?? ""));
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "batch_complete") {
|
||||
succeeded = Number(evt.succeeded ?? 0);
|
||||
failed = Number(evt.failed ?? 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
|
||||
|
||||
@@ -14,6 +14,8 @@ import { setCsrfCookie } from "@/lib/foundation/security";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Request-time auth: requireStaff, CSRF cookie, and optional 2FA gate cannot be
|
||||
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({
|
||||
|
||||
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the
|
||||
// navigator. The Prisma delegate exists because the table has a primary key.
|
||||
type Flatcat = {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deletePoll } from "@/actions/polls";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface PollRow {
|
||||
@@ -31,6 +34,8 @@ interface PollsTableProps {
|
||||
|
||||
export function PollsTable({ data }: PollsTableProps) {
|
||||
const t = useTranslations("pages.admin.polls");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
active: t("statusActive"),
|
||||
@@ -38,6 +43,20 @@ export function PollsTable({ data }: PollsTableProps) {
|
||||
closed: t("statusClosed"),
|
||||
};
|
||||
|
||||
async function handleDelete(row: PollRow) {
|
||||
const ok = await confirm({
|
||||
title: "Delete poll",
|
||||
description: `Delete "${row.title}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deletePoll({ id: row.id }), {
|
||||
successMessage: "Poll deleted!",
|
||||
errorMessage: "Failed to delete poll.",
|
||||
});
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<PollRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{
|
||||
@@ -82,20 +101,34 @@ export function PollsTable({ data }: PollsTableProps) {
|
||||
key: "actions",
|
||||
label: t("colActions"),
|
||||
render: (_, row) => (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/polls/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/polls/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(row)}
|
||||
aria-label="Delete poll"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -241,6 +241,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "require_email_verification",
|
||||
label: "Require email verification",
|
||||
description:
|
||||
"Block login until the account email is verified (accounts without email are unaffected).",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "max_accounts_per_ip",
|
||||
label: "Max accounts per IP",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SubRow = {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
|
||||
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Tag = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
|
||||
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { VouchersClient, type VoucherRow } from "./vouchers-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminVouchers() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { WordFilterClient } from "./wordfilter-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminWordFilter() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -56,6 +56,7 @@ export const POST = withAdmin(
|
||||
return runSseBatch<BatchItem>({
|
||||
items,
|
||||
concurrency: body.concurrency || 2,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => it.classname,
|
||||
worker: async (it, _index, report) => {
|
||||
const entry = entryMap.get(it.classname);
|
||||
|
||||
@@ -95,6 +95,7 @@ export const POST = withAdmin(
|
||||
return runSseBatch({
|
||||
items: allItems,
|
||||
concurrency: 2,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => `${it.sourceName}/${it.classname}`,
|
||||
worker: async (it, _index, report) => {
|
||||
const allSrc = await listSources();
|
||||
|
||||
@@ -32,6 +32,7 @@ export const POST = withAdmin(
|
||||
return runSseBatch<FigureBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSingleFigure({ lib: it.lib, onProgress: report });
|
||||
|
||||
@@ -30,6 +30,7 @@ export const POST = withAdmin(
|
||||
return runSseBatch<ClothingSetBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => `${it.setType}-${it.setId}`,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importClothingSet({
|
||||
|
||||
@@ -30,6 +30,7 @@ export const POST = withAdmin(
|
||||
return runSseBatch<EffectMapEntry>({
|
||||
items,
|
||||
concurrency,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSingleEffect({
|
||||
|
||||
@@ -29,6 +29,7 @@ export const POST = withAdmin(
|
||||
return runSseBatch<PetBatchItem>({
|
||||
items,
|
||||
concurrency,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => it.lib,
|
||||
worker: async (it, _index, report) => {
|
||||
const r = await importSinglePet({ lib: it.lib, onProgress: report });
|
||||
|
||||
@@ -64,7 +64,6 @@ export async function GET(req: Request) {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Script from "next/script";
|
||||
|
||||
export type CaptchaPublicConfig = {
|
||||
provider: string;
|
||||
siteKey?: string;
|
||||
field?: string;
|
||||
};
|
||||
|
||||
/** Renders Turnstile / reCAPTCHA widget + script when a provider is configured. */
|
||||
export function CaptchaWidget({
|
||||
captcha,
|
||||
nonce,
|
||||
className,
|
||||
}: {
|
||||
captcha: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
if (!showCaptcha) return null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{captcha.provider === "turnstile" ? (
|
||||
<>
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer
|
||||
nonce={nonce}
|
||||
/>
|
||||
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
||||
</>
|
||||
) : null}
|
||||
{captcha.provider === "recaptcha" ? (
|
||||
<>
|
||||
<Script
|
||||
src="https://www.google.com/recaptcha/api.js"
|
||||
async
|
||||
defer
|
||||
nonce={nonce}
|
||||
/>
|
||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the provider token from a form (widget injects a hidden input). */
|
||||
export function readCaptchaToken(
|
||||
form: HTMLFormElement,
|
||||
captcha: CaptchaPublicConfig,
|
||||
): string {
|
||||
if (captcha.provider === "none" || !captcha.field) return "";
|
||||
const fd = new FormData(form);
|
||||
return String(fd.get(captcha.field) ?? "").normalize("NFC");
|
||||
}
|
||||
@@ -3,8 +3,19 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
CaptchaWidget,
|
||||
type CaptchaPublicConfig,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
|
||||
export function HomeLoginForm() {
|
||||
export function HomeLoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
} = {}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@@ -12,17 +23,26 @@ export function HomeLoginForm() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const pre = await precheckLogin(username, password);
|
||||
const captchaToken = readCaptchaToken(e.currentTarget, captcha);
|
||||
const pre = await precheckLogin(username, password, captchaToken);
|
||||
if (pre === "invalid") {
|
||||
setError("Invalid username or password");
|
||||
return;
|
||||
}
|
||||
if (pre === "captcha") {
|
||||
setError("Captcha verification failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError("Please verify your email before signing in.");
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
@@ -52,7 +72,10 @@ export function HomeLoginForm() {
|
||||
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-1">
|
||||
<label htmlFor="login-username" className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
<label
|
||||
htmlFor="login-username"
|
||||
className="block font-semibold text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
@@ -68,7 +91,10 @@ export function HomeLoginForm() {
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="flex w-full flex-col gap-1">
|
||||
<label htmlFor="login-password" className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="block font-semibold text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -85,7 +111,10 @@ export function HomeLoginForm() {
|
||||
</fieldset>
|
||||
{needs2fa ? (
|
||||
<fieldset className="flex w-full flex-col gap-1">
|
||||
<label htmlFor="login-2fa" className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
<label
|
||||
htmlFor="login-2fa"
|
||||
className="block font-semibold text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
2FA Code
|
||||
</label>
|
||||
<input
|
||||
@@ -99,7 +128,9 @@ export function HomeLoginForm() {
|
||||
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
|
||||
/>
|
||||
</fieldset>
|
||||
) : null}
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
{error ? (
|
||||
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
CaptchaWidget,
|
||||
type CaptchaPublicConfig,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export function LoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const captchaToken = readCaptchaToken(e.currentTarget, captcha);
|
||||
const pre = await precheckLogin(username, password, captchaToken);
|
||||
if (pre === "invalid") {
|
||||
setError(t("errorInvalidCredentials"));
|
||||
return;
|
||||
}
|
||||
if (pre === "captcha") {
|
||||
setError(t("errorCaptcha"));
|
||||
return;
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError(t("errorUnverified"));
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
redirect: false,
|
||||
});
|
||||
if (!res || res.error) {
|
||||
setError(
|
||||
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon={needs2fa ? "🔒" : "🔑"}
|
||||
title={t("title")}
|
||||
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
|
||||
>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
{needs2fa ? (
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={pending}>
|
||||
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!needs2fa ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
margin: "1rem 0",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: "var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
<span className="muted">{t("or")}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: "var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => signIn("discord", { callbackUrl: "/" })}
|
||||
>
|
||||
{t("continueWithDiscord")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => signIn("google", { callbackUrl: "/" })}
|
||||
>
|
||||
{t("continueWithGoogle")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-danger)",
|
||||
textAlign: "center",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
||||
>
|
||||
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
|
||||
<Link href="/forgot">{t("forgotPassword")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export default async function GuestView() {
|
||||
@@ -15,6 +17,8 @@ export default async function GuestView() {
|
||||
"habbo_imaging_url",
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
)) ?? "";
|
||||
const cfg = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
let articles: Array<{
|
||||
slug: string;
|
||||
@@ -75,7 +79,14 @@ export default async function GuestView() {
|
||||
className="p-3"
|
||||
style={{ backgroundColor: "var(--color-surface)" }}
|
||||
>
|
||||
<HomeLoginForm />
|
||||
<HomeLoginForm
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ export async function Navigation() {
|
||||
<Link href="/guilds" className="dropdown-item" role="menuitem">
|
||||
{t("guilds")}
|
||||
</Link>
|
||||
<Link href="/events" className="dropdown-item" role="menuitem">
|
||||
{t("events")}
|
||||
</Link>
|
||||
<Link href="/polls" className="dropdown-item" role="menuitem">
|
||||
{t("polls")}
|
||||
</Link>
|
||||
</NavDropdown>
|
||||
|
||||
<NavDropdown
|
||||
@@ -196,6 +202,12 @@ export async function Navigation() {
|
||||
<Link href="/guilds" className="dropdown-item" role="menuitem">
|
||||
{t("guilds")}
|
||||
</Link>
|
||||
<Link href="/events" className="dropdown-item" role="menuitem">
|
||||
{t("events")}
|
||||
</Link>
|
||||
<Link href="/polls" className="dropdown-item" role="menuitem">
|
||||
{t("polls")}
|
||||
</Link>
|
||||
</NavDropdown>
|
||||
|
||||
<NavDropdown
|
||||
|
||||
+12
-3
@@ -67,9 +67,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
const password = String(credentials?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
|
||||
const ip = await clientIp();
|
||||
|
||||
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
||||
if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok)
|
||||
return null;
|
||||
if (!(await rateLimit(`login:${ip}`, 10, 5 * 60_000)).ok) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
@@ -90,6 +91,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
});
|
||||
if (!res.valid) return null;
|
||||
|
||||
if (
|
||||
(await siteSettings.getBool("require_email_verification", false)) &&
|
||||
user.mail &&
|
||||
user.mailVerified !== "1"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (res.upgradedHash) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -117,7 +126,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
await prisma.websiteLoginLogs.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ip: await clientIp(),
|
||||
ip,
|
||||
userAgent: ua,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
|
||||
@@ -242,6 +242,9 @@ export function handleActionError(error: unknown): ActionFailure {
|
||||
if (error instanceof DatabaseError) {
|
||||
return fail("A database error occurred");
|
||||
}
|
||||
if (error instanceof Error && error.name === "ActionError") {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof Error && error.name === "ZodError") {
|
||||
return fail("Validation failed");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: mockGet },
|
||||
}));
|
||||
|
||||
import { captchaConfig, verifyCaptcha } from "./captcha";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("verifyCaptcha", () => {
|
||||
it("allows when provider is none", async () => {
|
||||
mockGet.mockImplementation(async (key: string, fallback?: string) => {
|
||||
if (key === "captcha_provider") return "none";
|
||||
return fallback ?? "";
|
||||
});
|
||||
expect(await verifyCaptcha(null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when provider set but secret missing", async () => {
|
||||
mockGet.mockImplementation(async (key: string, fallback?: string) => {
|
||||
if (key === "captcha_provider") return "turnstile";
|
||||
if (key === "turnstile_site_key") return "site-key";
|
||||
if (key === "turnstile_secret") return "";
|
||||
return fallback ?? "";
|
||||
});
|
||||
expect(await verifyCaptcha("tok")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on provider API/network errors", async () => {
|
||||
mockGet.mockImplementation(async (key: string, fallback?: string) => {
|
||||
if (key === "captcha_provider") return "turnstile";
|
||||
if (key === "turnstile_site_key") return "site-key";
|
||||
if (key === "turnstile_secret") return "secret";
|
||||
return fallback ?? "";
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new Error("network down")),
|
||||
);
|
||||
expect(await verifyCaptcha("tok", "1.2.3.4")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a successful provider response", async () => {
|
||||
mockGet.mockImplementation(async (key: string, fallback?: string) => {
|
||||
if (key === "captcha_provider") return "recaptcha";
|
||||
if (key === "recaptcha_site_key") return "site-key";
|
||||
if (key === "recaptcha_secret") return "secret";
|
||||
return fallback ?? "";
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
}),
|
||||
);
|
||||
expect(await verifyCaptcha("good-token")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captchaConfig", () => {
|
||||
it("returns none by default", async () => {
|
||||
mockGet.mockImplementation(async (_key: string, fallback?: string) => {
|
||||
return fallback ?? "none";
|
||||
});
|
||||
const cfg = await captchaConfig();
|
||||
expect(cfg.provider).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,12 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
/**
|
||||
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
||||
* provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA
|
||||
* (the two AtomCMS offers, mutually exclusive). FAIL-OPEN by configuration: when
|
||||
* no provider/secret is set, registration isn't blocked; only an explicitly
|
||||
* configured provider with a failing/absent token blocks.
|
||||
* (the two AtomCMS offers, mutually exclusive).
|
||||
*
|
||||
* Behaviour:
|
||||
* - provider "none" (or unset) → fail-open (allow)
|
||||
* - provider configured but site key / secret missing, token absent, provider
|
||||
* API error, or network failure → fail-closed (deny)
|
||||
*
|
||||
* Settings keys:
|
||||
* captcha_provider = "turnstile" | "recaptcha" | "none" (default none)
|
||||
@@ -23,7 +26,7 @@ const TURNSTILE_URL =
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||
|
||||
/** Public config the register page needs to render the widget (no secrets). */
|
||||
/** Public config the register/login pages need to render the widget (no secrets). */
|
||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||
const provider = (
|
||||
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
||||
@@ -45,18 +48,20 @@ export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||
return { provider: "none", siteKey: "", field: "" };
|
||||
}
|
||||
|
||||
/** Verify a submitted token. Returns true when allowed (incl. fail-open). */
|
||||
/** Verify a submitted token. Fail-closed when a provider is configured. */
|
||||
export async function verifyCaptcha(
|
||||
token: string | null,
|
||||
remoteIp?: string,
|
||||
): Promise<boolean> {
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider === "none" || !cfg.siteKey) return true;
|
||||
if (cfg.provider === "none") return true;
|
||||
|
||||
if (!cfg.siteKey) return false;
|
||||
|
||||
const secretKey =
|
||||
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
if (!secret) return true; // configured but no secret — don't hard-block
|
||||
if (!secret) return false;
|
||||
if (!token) return false;
|
||||
|
||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
||||
@@ -74,10 +79,11 @@ export async function verifyCaptcha(
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data?.success === true;
|
||||
} catch {
|
||||
// Network/timeout — fail-open so a provider outage can't lock out signups.
|
||||
return true;
|
||||
// Network/timeout/misconfig — fail-closed so captcha can't be bypassed.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -40,4 +40,26 @@ describe("import/core/sse-batch", () => {
|
||||
failed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops starting new chunks when AbortSignal fires", async () => {
|
||||
const ac = new AbortController();
|
||||
let started = 0;
|
||||
const res = runSseBatch({
|
||||
items: ["a", "b", "c", "d"],
|
||||
concurrency: 1,
|
||||
signal: ac.signal,
|
||||
labelOf: (item) => item,
|
||||
worker: async (item) => {
|
||||
started++;
|
||||
if (item === "a") ac.abort();
|
||||
// Slow enough that abort lands before the next chunk starts.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const events = await collect(res);
|
||||
expect(started).toBeLessThan(4);
|
||||
expect(events.some((e) => e.type === "batch_complete")).toBe(false);
|
||||
expect(events[0]).toMatchObject({ type: "batch_start", total: 4 });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export interface SseWorkerResult {
|
||||
export interface RunSseBatchOptions<T> {
|
||||
items: T[];
|
||||
concurrency: number;
|
||||
/** Abort when the client disconnects (e.g. `request.signal`). */
|
||||
signal?: AbortSignal;
|
||||
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */
|
||||
labelOf: (item: T) => string;
|
||||
/** Per-item worker. `report(status)` streams intermediate progress (e.g. 'downloading'). */
|
||||
@@ -20,21 +22,30 @@ export interface RunSseBatchOptions<T> {
|
||||
/**
|
||||
* Generic SSE batch runner. Emits the same event shape the furni client
|
||||
* parser consumes: batch_start / item_progress / batch_complete.
|
||||
* Stops starting new chunks when `signal` aborts or the client cancels the stream.
|
||||
*/
|
||||
export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
|
||||
const { items, labelOf, worker } = opts;
|
||||
const { items, labelOf, worker, signal } = opts;
|
||||
const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5);
|
||||
const encoder = new TextEncoder();
|
||||
const ac = new AbortController();
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) ac.abort();
|
||||
else signal.addEventListener("abort", () => ac.abort(), { once: true });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
if (ac.signal.aborted) return;
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
ac.abort();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,9 +57,12 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
|
||||
let withWarnings = 0;
|
||||
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
if (ac.signal.aborted) break;
|
||||
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
await Promise.allSettled(
|
||||
chunk.map(async (item, chunkIdx) => {
|
||||
if (ac.signal.aborted) return;
|
||||
const index = i + chunkIdx;
|
||||
const classname = labelOf(item);
|
||||
send({
|
||||
@@ -61,6 +75,7 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
|
||||
const result = await worker(item, index, (status) =>
|
||||
send({ type: "item_progress", classname, status, index }),
|
||||
);
|
||||
if (ac.signal.aborted) return;
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings?.length) withWarnings++;
|
||||
@@ -84,6 +99,7 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (ac.signal.aborted) return;
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
@@ -97,14 +113,23 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
|
||||
);
|
||||
}
|
||||
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
controller.close();
|
||||
if (!ac.signal.aborted) {
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
ac.abort();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { toast } from "sonner";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
export type SseEvent = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Read an SSE response body and invoke `onEvent` for each `data:` JSON payload.
|
||||
*/
|
||||
export async function readSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onEvent: (event: SseEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(part.slice(6)) as SseEvent);
|
||||
} catch {
|
||||
/* skip malformed events */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON to an admin SSE import endpoint and drive the standard
|
||||
* item_progress / batch_complete callbacks used by clothing & clone clients.
|
||||
*/
|
||||
export async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (classname: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
}
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (
|
||||
evt.type === "item_progress" &&
|
||||
(evt.status === "done" || evt.status === "failed")
|
||||
) {
|
||||
onDone(String(evt.classname ?? ""));
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = Number(evt.succeeded ?? 0);
|
||||
failed = Number(evt.failed ?? 0);
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
@@ -6,17 +6,30 @@ type UseEventSourceOptions = {
|
||||
onMessage?: (data: unknown) => void;
|
||||
onError?: (event: Event) => void;
|
||||
enabled?: boolean;
|
||||
/** Max reconnect attempts after errors (default 8). Set 0 to disable reconnect. */
|
||||
maxRetries?: number;
|
||||
/** Initial reconnect delay in ms (default 1000); doubles each attempt. */
|
||||
baseDelayMs?: number;
|
||||
/** Cap on reconnect delay in ms (default 30000). */
|
||||
maxDelayMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a Server-Sent Events (SSE) endpoint.
|
||||
* Reconnects automatically on connection loss.
|
||||
* Reconnects with exponential backoff up to `maxRetries`.
|
||||
*/
|
||||
export function useEventSource<T = unknown>(
|
||||
url: string | null,
|
||||
options?: UseEventSourceOptions,
|
||||
) {
|
||||
const { onMessage, onError, enabled = true } = options ?? {};
|
||||
const {
|
||||
onMessage,
|
||||
onError,
|
||||
enabled = true,
|
||||
maxRetries = 8,
|
||||
baseDelayMs = 1000,
|
||||
maxDelayMs = 30_000,
|
||||
} = options ?? {};
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
@@ -29,12 +42,18 @@ export function useEventSource<T = unknown>(
|
||||
|
||||
const endpoint: string = url;
|
||||
let es: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout>;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let attempt = 0;
|
||||
let disposed = false;
|
||||
|
||||
function connect() {
|
||||
if (disposed) return;
|
||||
es = new EventSource(endpoint);
|
||||
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onopen = () => {
|
||||
attempt = 0;
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
@@ -48,20 +67,27 @@ export function useEventSource<T = unknown>(
|
||||
|
||||
es.onerror = (event) => {
|
||||
es?.close();
|
||||
es = null;
|
||||
setConnected(false);
|
||||
onErrorRef.current?.(event);
|
||||
reconnectTimer = setTimeout(connect, 3000);
|
||||
|
||||
if (disposed || attempt >= maxRetries) return;
|
||||
|
||||
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
||||
attempt++;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer);
|
||||
disposed = true;
|
||||
if (reconnectTimer !== undefined) clearTimeout(reconnectTimer);
|
||||
es?.close();
|
||||
setConnected(false);
|
||||
};
|
||||
}, [url, enabled]);
|
||||
}, [url, enabled, maxRetries, baseDelayMs, maxDelayMs]);
|
||||
|
||||
return { data, connected };
|
||||
}
|
||||
@@ -67,8 +67,13 @@ export const eventWinnerSchema = z.object({
|
||||
position: z.coerce.number().int().positive().default(1),
|
||||
});
|
||||
|
||||
export const registerForEventSchema = z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export type EventTypeInput = z.infer<typeof eventTypeSchema>;
|
||||
export type CreateEventInput = z.infer<typeof createEventSchema>;
|
||||
export type UpdateEventInput = z.infer<typeof updateEventSchema>;
|
||||
export type EventPrizeInput = z.infer<typeof eventPrizeSchema>;
|
||||
export type EventWinnerInput = z.infer<typeof eventWinnerSchema>;
|
||||
export type RegisterForEventInput = z.infer<typeof registerForEventSchema>;
|
||||
@@ -25,7 +25,13 @@ export const pollVoteSchema = z.object({
|
||||
answer: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const voteOnPollSchema = z.object({
|
||||
pollId: z.coerce.number().int().positive(),
|
||||
votes: z.array(pollVoteSchema).min(1).max(50),
|
||||
});
|
||||
|
||||
export type CreatePollInput = z.infer<typeof createPollSchema>;
|
||||
export type UpdatePollInput = z.infer<typeof updatePollSchema>;
|
||||
export type PollQuestionInput = z.infer<typeof pollQuestionSchema>;
|
||||
export type PollVoteInput = z.infer<typeof pollVoteSchema>;
|
||||
export type VoteOnPollInput = z.infer<typeof voteOnPollSchema>;
|
||||
+153
-10
@@ -7,6 +7,8 @@
|
||||
"staff": "Staff",
|
||||
"rankings": "Rankings",
|
||||
"guilds": "Guilds",
|
||||
"events": "Events",
|
||||
"polls": "Polls",
|
||||
"leaderboards": "Leaderboards",
|
||||
"rareValues": "Rare values",
|
||||
"shop": "Shop",
|
||||
@@ -39,6 +41,15 @@
|
||||
"lightMode": "Light mode",
|
||||
"darkMode": "Dark mode"
|
||||
},
|
||||
"emails": {
|
||||
"verify": {
|
||||
"subject": "Verify your email · {hotel}",
|
||||
"heading": "Verify your email",
|
||||
"body": "Welcome to {hotel}! Confirm this email address to finish setting up your account.",
|
||||
"button": "Verify email",
|
||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"error": {
|
||||
"code": "Error",
|
||||
@@ -88,7 +99,16 @@
|
||||
"emptySubtitle": "You have no friends added yet",
|
||||
"subtitle": "{count, plural, one {You have # friend} other {You have # friends}}",
|
||||
"emptyState": "Add friends in the hotel and they will show up here.",
|
||||
"noMotto": "No motto"
|
||||
"noMotto": "No motto",
|
||||
"remove": "Remove",
|
||||
"success": {
|
||||
"removed": "Friend removed."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "That friendship was not found.",
|
||||
"invalid": "Invalid friend.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Messages",
|
||||
@@ -99,11 +119,22 @@
|
||||
"requestsEmptyState": "No pending friend requests.",
|
||||
"wantsToBeFriend": "wants to be your friend.",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline",
|
||||
"offlineTitle": "Offline messages",
|
||||
"offlineEmptySubtitle": "Your inbox is empty",
|
||||
"offlineSubtitle": "{count, plural, one {# message received} other {# messages received}}",
|
||||
"offlineEmptyState": "You have no offline messages.",
|
||||
"unknownUser": "User #{id}"
|
||||
"unknownUser": "User #{id}",
|
||||
"success": {
|
||||
"accepted": "Friend request accepted.",
|
||||
"declined": "Friend request declined."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "That friend request was not found.",
|
||||
"unauthorized": "You can't act on this friend request.",
|
||||
"invalid": "Invalid friend request.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"noMotto": "No motto",
|
||||
@@ -131,7 +162,26 @@
|
||||
"roomsTitle": "Rooms",
|
||||
"roomsSubtitle": "Rooms they own",
|
||||
"roomsEmpty": "No rooms to show.",
|
||||
"roomUntitled": "Untitled room"
|
||||
"roomUntitled": "Untitled room",
|
||||
"addFriend": "Add friend",
|
||||
"alreadyFriends": "You're friends",
|
||||
"requestPending": "Request pending",
|
||||
"respondInMessages": "Respond in Messages",
|
||||
"success": {
|
||||
"sent": "Friend request sent.",
|
||||
"guestbook": "Guestbook message posted."
|
||||
},
|
||||
"errors": {
|
||||
"self": "You can't send a friend request to yourself.",
|
||||
"alreadyFriends": "You're already friends with this user.",
|
||||
"alreadyPending": "You already sent a friend request to this user.",
|
||||
"incomingPending": "This user already sent you a request — accept or decline it in Messages.",
|
||||
"empty": "Please write a message before posting.",
|
||||
"moderated": "That message was blocked by moderation.",
|
||||
"ratelimit": "You're posting too fast. Please wait a moment and try again.",
|
||||
"invalid": "That request was invalid.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"rankings": {
|
||||
"title": "Rankings",
|
||||
@@ -185,7 +235,18 @@
|
||||
"addComment": "Add a comment",
|
||||
"commentPlaceholder": "Write your comment…",
|
||||
"postComment": "Post comment",
|
||||
"toComment": "to leave a comment."
|
||||
"toComment": "to leave a comment.",
|
||||
"success": {
|
||||
"posted": "Comment posted."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Please write a comment before posting.",
|
||||
"moderated": "That comment was blocked by moderation.",
|
||||
"ratelimit": "You're commenting too fast. Please wait a moment and try again.",
|
||||
"invalid": "Invalid comment.",
|
||||
"notFound": "That article was not found.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
@@ -269,7 +330,10 @@
|
||||
"pinned": "Pinned",
|
||||
"locked": "Locked",
|
||||
"userNumber": "User #{id}",
|
||||
"unknown": "Unknown"
|
||||
"unknown": "Unknown",
|
||||
"success": {
|
||||
"posted": "Thread posted."
|
||||
}
|
||||
},
|
||||
"guildForumNew": {
|
||||
"backToForum": "← Back to forum",
|
||||
@@ -281,7 +345,13 @@
|
||||
"messageLabel": "Message",
|
||||
"messagePlaceholder": "Write your opening post…",
|
||||
"postThread": "Post thread",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"invalid": "Subject and message are required.",
|
||||
"notFound": "That guild was not found.",
|
||||
"ratelimit": "You're posting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
"title": "Shop",
|
||||
@@ -395,7 +465,16 @@
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Created",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed"
|
||||
"statusClosed": "Closed",
|
||||
"success": {
|
||||
"created": "Ticket submitted."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Please fill in a subject and description.",
|
||||
"moderated": "That ticket was blocked by moderation.",
|
||||
"ratelimit": "You're submitting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"applyStaff": {
|
||||
"title": "Apply for {hotel} staff",
|
||||
@@ -472,7 +551,68 @@
|
||||
"loginToPost": "Log in to post a shout.",
|
||||
"latestTitle": "Latest shouts",
|
||||
"latestSubtitle": "The most recent messages from listeners",
|
||||
"empty": "No shouts yet. Be the first!"
|
||||
"empty": "No shouts yet. Be the first!",
|
||||
"success": {
|
||||
"posted": "Shout posted."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Please write a message before shouting.",
|
||||
"moderated": "That shout was blocked by moderation.",
|
||||
"ratelimit": "You're shouting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"subtitle": "Upcoming and recent hotel events you can join",
|
||||
"empty": "No events yet.",
|
||||
"back": "← Events",
|
||||
"when": "When",
|
||||
"status": "Status",
|
||||
"statusPublished": "Open",
|
||||
"statusCompleted": "Completed",
|
||||
"registrations": "Registrations",
|
||||
"registered": "{count} registered",
|
||||
"spots": "{count} / {max} spots",
|
||||
"registerTitle": "Register",
|
||||
"registerSubtitle": "Save your spot for this event",
|
||||
"register": "Register for event",
|
||||
"registering": "Registering…",
|
||||
"registerSuccess": "You are registered!",
|
||||
"registerError": "Could not register for this event.",
|
||||
"loginToRegister": "Log in to register for this event.",
|
||||
"alreadyRegistered": "You are already registered.",
|
||||
"eventEnded": "This event has ended.",
|
||||
"eventFull": "This event is full.",
|
||||
"cannotRegister": "Registration is not available.",
|
||||
"prizesTitle": "Prizes",
|
||||
"winnersTitle": "Winners",
|
||||
"unknownUser": "User #{id}"
|
||||
},
|
||||
"polls": {
|
||||
"title": "Polls",
|
||||
"subtitle": "Share your opinion in community polls",
|
||||
"empty": "No polls yet.",
|
||||
"back": "← Polls",
|
||||
"statusActive": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"questionsCount": "{count} questions",
|
||||
"ends": "Ends {date}",
|
||||
"voteTitle": "Cast your vote",
|
||||
"voteSubtitle": "Answer each question below",
|
||||
"submitVote": "Submit vote",
|
||||
"voting": "Submitting…",
|
||||
"voteSuccess": "Thanks for voting!",
|
||||
"voteError": "Could not submit your vote.",
|
||||
"loginToVote": "Log in to vote in this poll.",
|
||||
"alreadyVoted": "You have already voted.",
|
||||
"pollClosed": "This poll is closed.",
|
||||
"cannotVote": "Voting is not available.",
|
||||
"textPlaceholder": "Your answer…",
|
||||
"resultsTitle": "Results",
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"textResponses": "{count} free-text responses"
|
||||
},
|
||||
"radioContests": {
|
||||
"title": "Radio Contests",
|
||||
@@ -627,7 +767,9 @@
|
||||
"createOne": "Create one",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"errorInvalidCredentials": "Invalid username or password",
|
||||
"errorInvalid2fa": "Invalid 2FA code"
|
||||
"errorInvalid2fa": "Invalid 2FA code",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again."
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
@@ -663,7 +805,8 @@
|
||||
"sentNotice": "If that email is registered, a reset link has been sent. Check your inbox.",
|
||||
"emailPlaceholder": "Your email",
|
||||
"sendResetLink": "Send reset link",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again."
|
||||
},
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
|
||||
+174
-23
@@ -7,6 +7,8 @@
|
||||
"staff": "Staff",
|
||||
"rankings": "Classifiche",
|
||||
"guilds": "Gruppi",
|
||||
"events": "Eventi",
|
||||
"polls": "Sondaggi",
|
||||
"leaderboards": "Classifiche",
|
||||
"rareValues": "Valori rari",
|
||||
"shop": "Negozio",
|
||||
@@ -39,6 +41,15 @@
|
||||
"lightMode": "Tema chiaro",
|
||||
"darkMode": "Tema scuro"
|
||||
},
|
||||
"emails": {
|
||||
"verify": {
|
||||
"subject": "Verifica la tua email · {hotel}",
|
||||
"heading": "Verifica la tua email",
|
||||
"body": "Benvenuto su {hotel}! Conferma questo indirizzo email per completare la creazione del tuo account.",
|
||||
"button": "Verifica email",
|
||||
"fallback": "Se il pulsante non funziona, incolla questo link nel browser:"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"error": {
|
||||
"code": "Errore",
|
||||
@@ -88,7 +99,16 @@
|
||||
"emptySubtitle": "Non hai ancora aggiunto nessun amico",
|
||||
"subtitle": "{count, plural, one {Hai # amico} other {Hai # amici}}",
|
||||
"emptyState": "Aggiungi amici nell'hotel e compariranno qui.",
|
||||
"noMotto": "Nessun motto"
|
||||
"noMotto": "Nessun motto",
|
||||
"remove": "Rimuovi",
|
||||
"success": {
|
||||
"removed": "Amico rimosso."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Quell'amicizia non è stata trovata.",
|
||||
"invalid": "Amico non valido.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Messaggi",
|
||||
@@ -99,11 +119,22 @@
|
||||
"requestsEmptyState": "Nessuna richiesta di amicizia in sospeso.",
|
||||
"wantsToBeFriend": "vuole essere tuo amico.",
|
||||
"accept": "Accetta",
|
||||
"decline": "Rifiuta",
|
||||
"offlineTitle": "Messaggi offline",
|
||||
"offlineEmptySubtitle": "La tua casella è vuota",
|
||||
"offlineSubtitle": "{count, plural, one {# messaggio ricevuto} other {# messaggi ricevuti}}",
|
||||
"offlineEmptyState": "Non hai messaggi offline.",
|
||||
"unknownUser": "Utente #{id}"
|
||||
"unknownUser": "Utente #{id}",
|
||||
"success": {
|
||||
"accepted": "Richiesta di amicizia accettata.",
|
||||
"declined": "Richiesta di amicizia rifiutata."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Quella richiesta di amicizia non è stata trovata.",
|
||||
"unauthorized": "Non puoi gestire questa richiesta di amicizia.",
|
||||
"invalid": "Richiesta di amicizia non valida.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"noMotto": "Nessun motto",
|
||||
@@ -131,7 +162,26 @@
|
||||
"roomsTitle": "Stanze",
|
||||
"roomsSubtitle": "Le stanze che possiede",
|
||||
"roomsEmpty": "Nessuna stanza da mostrare.",
|
||||
"roomUntitled": "Stanza senza nome"
|
||||
"roomUntitled": "Stanza senza nome",
|
||||
"addFriend": "Aggiungi amico",
|
||||
"alreadyFriends": "Siete amici",
|
||||
"requestPending": "Richiesta in sospeso",
|
||||
"respondInMessages": "Rispondi nei Messaggi",
|
||||
"success": {
|
||||
"sent": "Richiesta di amicizia inviata.",
|
||||
"guestbook": "Messaggio in bacheca pubblicato."
|
||||
},
|
||||
"errors": {
|
||||
"self": "Non puoi inviarti una richiesta di amicizia.",
|
||||
"alreadyFriends": "Siete già amici.",
|
||||
"alreadyPending": "Hai già inviato una richiesta a questo utente.",
|
||||
"incomingPending": "Questo utente ti ha già inviato una richiesta — accettala o rifiutala nei Messaggi.",
|
||||
"empty": "Scrivi un messaggio prima di pubblicare.",
|
||||
"moderated": "Quel messaggio è stato bloccato dalla moderazione.",
|
||||
"ratelimit": "Stai pubblicando troppo in fretta. Attendi un momento e riprova.",
|
||||
"invalid": "Richiesta non valida.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"rankings": {
|
||||
"title": "Classifica",
|
||||
@@ -185,7 +235,18 @@
|
||||
"addComment": "Aggiungi un commento",
|
||||
"commentPlaceholder": "Scrivi il tuo commento…",
|
||||
"postComment": "Pubblica commento",
|
||||
"toComment": "per lasciare un commento."
|
||||
"toComment": "per lasciare un commento.",
|
||||
"success": {
|
||||
"posted": "Commento pubblicato."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Scrivi un commento prima di pubblicare.",
|
||||
"moderated": "Quel commento è stato bloccato dalla moderazione.",
|
||||
"ratelimit": "Stai commentando troppo in fretta. Attendi un momento e riprova.",
|
||||
"invalid": "Commento non valido.",
|
||||
"notFound": "Articolo non trovato.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"photos": {
|
||||
"title": "Foto",
|
||||
@@ -269,7 +330,10 @@
|
||||
"pinned": "In evidenza",
|
||||
"locked": "Bloccata",
|
||||
"userNumber": "Utente #{id}",
|
||||
"unknown": "Sconosciuto"
|
||||
"unknown": "Sconosciuto",
|
||||
"success": {
|
||||
"posted": "Discussione pubblicata."
|
||||
}
|
||||
},
|
||||
"guildForumNew": {
|
||||
"backToForum": "← Torna al forum",
|
||||
@@ -281,7 +345,13 @@
|
||||
"messageLabel": "Messaggio",
|
||||
"messagePlaceholder": "Scrivi il tuo primo messaggio…",
|
||||
"postThread": "Pubblica discussione",
|
||||
"cancel": "Annulla"
|
||||
"cancel": "Annulla",
|
||||
"errors": {
|
||||
"invalid": "Oggetto e messaggio sono obbligatori.",
|
||||
"notFound": "Gilda non trovata.",
|
||||
"ratelimit": "Stai pubblicando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
"title": "Negozio",
|
||||
@@ -395,7 +465,16 @@
|
||||
"colStatus": "Stato",
|
||||
"colCreated": "Creato",
|
||||
"statusOpen": "Aperto",
|
||||
"statusClosed": "Chiuso"
|
||||
"statusClosed": "Chiuso",
|
||||
"success": {
|
||||
"created": "Ticket inviato."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Compila oggetto e descrizione.",
|
||||
"moderated": "Quel ticket è stato bloccato dalla moderazione.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"applyStaff": {
|
||||
"title": "Candidati per lo staff di {hotel}",
|
||||
@@ -472,7 +551,68 @@
|
||||
"loginToPost": "Accedi per pubblicare un saluto.",
|
||||
"latestTitle": "Ultimi saluti",
|
||||
"latestSubtitle": "I messaggi più recenti degli ascoltatori",
|
||||
"empty": "Ancora nessun saluto. Sii il primo!"
|
||||
"empty": "Ancora nessun saluto. Sii il primo!",
|
||||
"success": {
|
||||
"posted": "Saluto pubblicato."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Scrivi un messaggio prima di inviare.",
|
||||
"moderated": "Quel saluto è stato bloccato dalla moderazione.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Eventi",
|
||||
"subtitle": "Eventi dell'hotel in arrivo e recenti a cui puoi partecipare",
|
||||
"empty": "Ancora nessun evento.",
|
||||
"back": "← Eventi",
|
||||
"when": "Quando",
|
||||
"status": "Stato",
|
||||
"statusPublished": "Aperto",
|
||||
"statusCompleted": "Completato",
|
||||
"registrations": "Iscrizioni",
|
||||
"registered": "{count} iscritti",
|
||||
"spots": "{count} / {max} posti",
|
||||
"registerTitle": "Iscriviti",
|
||||
"registerSubtitle": "Prenota il tuo posto per questo evento",
|
||||
"register": "Iscriviti all'evento",
|
||||
"registering": "Iscrizione in corso…",
|
||||
"registerSuccess": "Sei iscritto!",
|
||||
"registerError": "Impossibile iscriversi a questo evento.",
|
||||
"loginToRegister": "Accedi per iscriverti a questo evento.",
|
||||
"alreadyRegistered": "Sei già iscritto.",
|
||||
"eventEnded": "Questo evento è terminato.",
|
||||
"eventFull": "Questo evento è al completo.",
|
||||
"cannotRegister": "Le iscrizioni non sono disponibili.",
|
||||
"prizesTitle": "Premi",
|
||||
"winnersTitle": "Vincitori",
|
||||
"unknownUser": "Utente #{id}"
|
||||
},
|
||||
"polls": {
|
||||
"title": "Sondaggi",
|
||||
"subtitle": "Condividi la tua opinione nei sondaggi della community",
|
||||
"empty": "Ancora nessun sondaggio.",
|
||||
"back": "← Sondaggi",
|
||||
"statusActive": "Aperto",
|
||||
"statusClosed": "Chiuso",
|
||||
"questionsCount": "{count} domande",
|
||||
"ends": "Termina {date}",
|
||||
"voteTitle": "Esprimi il voto",
|
||||
"voteSubtitle": "Rispondi a ogni domanda qui sotto",
|
||||
"submitVote": "Invia voto",
|
||||
"voting": "Invio in corso…",
|
||||
"voteSuccess": "Grazie per aver votato!",
|
||||
"voteError": "Impossibile inviare il voto.",
|
||||
"loginToVote": "Accedi per votare in questo sondaggio.",
|
||||
"alreadyVoted": "Hai già votato.",
|
||||
"pollClosed": "Questo sondaggio è chiuso.",
|
||||
"cannotVote": "Il voto non è disponibile.",
|
||||
"textPlaceholder": "La tua risposta…",
|
||||
"resultsTitle": "Risultati",
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} voti totali",
|
||||
"textResponses": "{count} risposte libere"
|
||||
},
|
||||
"radioContests": {
|
||||
"title": "Concorsi radio",
|
||||
@@ -591,7 +731,9 @@
|
||||
"createOne": "Creane uno",
|
||||
"forgotPassword": "Password dimenticata?",
|
||||
"errorInvalidCredentials": "Nome utente o password non validi",
|
||||
"errorInvalid2fa": "Codice 2FA non valido"
|
||||
"errorInvalid2fa": "Codice 2FA non valido",
|
||||
"errorUnverified": "Verifica la tua email prima di accedere.",
|
||||
"errorCaptcha": "Verifica captcha non riuscita. Riprova."
|
||||
},
|
||||
"register": {
|
||||
"title": "Crea un account",
|
||||
@@ -627,7 +769,8 @@
|
||||
"sentNotice": "Se l'indirizzo è registrato, ti abbiamo inviato un link per il reset. Controlla la posta in arrivo.",
|
||||
"emailPlaceholder": "La tua email",
|
||||
"sendResetLink": "Invia link di reset",
|
||||
"backToLogin": "Torna all'accesso"
|
||||
"backToLogin": "Torna all'accesso",
|
||||
"errorCaptcha": "Verifica captcha non riuscita. Riprova."
|
||||
},
|
||||
"reset": {
|
||||
"title": "Imposta una nuova password",
|
||||
@@ -2471,19 +2614,27 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"subtitle": "Import assets from external sources into your hotel.",
|
||||
"badges": "Badges",
|
||||
"badgesDescription": "Import and manage badge assets from external sources.",
|
||||
"furni": "Furni",
|
||||
"furniDescription": "Import furniture definitions, SWF files, and icons.",
|
||||
"clothing": "Clothing",
|
||||
"clothingDescription": "Import clothing and wearables for avatars.",
|
||||
"effects": "Effects",
|
||||
"effectsDescription": "Import avatar effects and animations.",
|
||||
"pets": "Pets",
|
||||
"petsDescription": "Import pet data and icons.",
|
||||
"clone": "Clone",
|
||||
"cloneDescription": "Clone existing items from one source to another."
|
||||
"subtitle": "Importa asset da fonti esterne nel tuo hotel.",
|
||||
"badges": "Distintivi",
|
||||
"badgesDescription": "Importa e gestisci asset dei distintivi da fonti esterne.",
|
||||
"furni": "Mobili",
|
||||
"furniDescription": "Importa definizioni mobili, file SWF e icone.",
|
||||
"clothing": "Abbigliamento",
|
||||
"clothingDescription": "Importa abiti e indossabili per gli avatar.",
|
||||
"effects": "Effetti",
|
||||
"effectsDescription": "Importa effetti e animazioni avatar.",
|
||||
"pets": "Animali",
|
||||
"petsDescription": "Importa dati e icone degli animali.",
|
||||
"clone": "Clona",
|
||||
"cloneDescription": "Clona elementi esistenti da una fonte all'altra.",
|
||||
"upload": "Carica .nitro",
|
||||
"uploadDescription": "Carica bundle .nitro e icone direttamente, senza conversione SWF.",
|
||||
"sync": "Sincronizzazione automatica",
|
||||
"syncDescription": "Importa automaticamente i mobili mancanti da tutte le fonti configurate.",
|
||||
"repairIcons": "Ripara icone",
|
||||
"repairIconsDescription": "Scarica le icone mancanti per i mobili già presenti nel database.",
|
||||
"audit": "Audit catalogo",
|
||||
"auditDescription": "Scansiona il catalogo per voci mancanti, riferimenti rotti ed elementi assenti dalle fonti clone."
|
||||
},
|
||||
"translations": {
|
||||
"title": "Translations",
|
||||
|
||||
+210
-22
@@ -7,6 +7,8 @@
|
||||
"staff": "Team",
|
||||
"rankings": "Rankings",
|
||||
"guilds": "Gildes",
|
||||
"events": "Evenementen",
|
||||
"polls": "Peilingen",
|
||||
"leaderboards": "Klassementen",
|
||||
"rareValues": "Zeldzame waarden",
|
||||
"shop": "Winkel",
|
||||
@@ -39,6 +41,15 @@
|
||||
"lightMode": "Lichte modus",
|
||||
"darkMode": "Donkere modus"
|
||||
},
|
||||
"emails": {
|
||||
"verify": {
|
||||
"subject": "Verifieer je e-mail · {hotel}",
|
||||
"heading": "Verifieer je e-mail",
|
||||
"body": "Welkom bij {hotel}! Bevestig dit e-mailadres om je account af te ronden.",
|
||||
"button": "E-mail verifiëren",
|
||||
"fallback": "Werkt de knop niet? Plak dan deze link in je browser:"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"error": {
|
||||
"code": "Fout",
|
||||
@@ -88,7 +99,16 @@
|
||||
"emptySubtitle": "Je hebt nog geen vrienden toegevoegd",
|
||||
"subtitle": "{count, plural, one {Je hebt # vriend} other {Je hebt # vrienden}}",
|
||||
"emptyState": "Voeg vrienden toe in het hotel en ze verschijnen hier.",
|
||||
"noMotto": "Geen motto"
|
||||
"noMotto": "Geen motto",
|
||||
"remove": "Verwijderen",
|
||||
"success": {
|
||||
"removed": "Vriend verwijderd."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Die vriendschap is niet gevonden.",
|
||||
"invalid": "Ongeldige vriend.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Berichten",
|
||||
@@ -99,11 +119,22 @@
|
||||
"requestsEmptyState": "Geen openstaande vriendschapsverzoeken.",
|
||||
"wantsToBeFriend": "wil je vriend zijn.",
|
||||
"accept": "Accepteren",
|
||||
"decline": "Weigeren",
|
||||
"offlineTitle": "Offline berichten",
|
||||
"offlineEmptySubtitle": "Je inbox is leeg",
|
||||
"offlineSubtitle": "{count, plural, one {# bericht ontvangen} other {# berichten ontvangen}}",
|
||||
"offlineEmptyState": "Je hebt geen offline berichten.",
|
||||
"unknownUser": "Gebruiker #{id}"
|
||||
"unknownUser": "Gebruiker #{id}",
|
||||
"success": {
|
||||
"accepted": "Vriendschapsverzoek geaccepteerd.",
|
||||
"declined": "Vriendschapsverzoek geweigerd."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Dat vriendschapsverzoek is niet gevonden.",
|
||||
"unauthorized": "Je kunt dit vriendschapsverzoek niet behandelen.",
|
||||
"invalid": "Ongeldig vriendschapsverzoek.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"noMotto": "Geen motto",
|
||||
@@ -131,7 +162,26 @@
|
||||
"roomsTitle": "Kamers",
|
||||
"roomsSubtitle": "Kamers die ze bezitten",
|
||||
"roomsEmpty": "Geen kamers om te tonen.",
|
||||
"roomUntitled": "Naamloze kamer"
|
||||
"roomUntitled": "Naamloze kamer",
|
||||
"addFriend": "Vriend toevoegen",
|
||||
"alreadyFriends": "Jullie zijn vrienden",
|
||||
"requestPending": "Verzoek in behandeling",
|
||||
"respondInMessages": "Reageer in Berichten",
|
||||
"success": {
|
||||
"sent": "Vriendschapsverzoek verzonden.",
|
||||
"guestbook": "Gastenboekbericht geplaatst."
|
||||
},
|
||||
"errors": {
|
||||
"self": "Je kunt jezelf geen vriendschapsverzoek sturen.",
|
||||
"alreadyFriends": "Jullie zijn al vrienden.",
|
||||
"alreadyPending": "Je hebt deze gebruiker al een verzoek gestuurd.",
|
||||
"incomingPending": "Deze gebruiker heeft jou al een verzoek gestuurd — accepteer of weiger het in Berichten.",
|
||||
"empty": "Schrijf een bericht voordat je plaatst.",
|
||||
"moderated": "Dat bericht is geblokkeerd door moderatie.",
|
||||
"ratelimit": "Je plaatst te snel. Wacht even en probeer opnieuw.",
|
||||
"invalid": "Ongeldig verzoek.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"rankings": {
|
||||
"title": "Rankings",
|
||||
@@ -185,7 +235,18 @@
|
||||
"addComment": "Reactie toevoegen",
|
||||
"commentPlaceholder": "Schrijf je reactie…",
|
||||
"postComment": "Reactie plaatsen",
|
||||
"toComment": "om een reactie achter te laten."
|
||||
"toComment": "om een reactie achter te laten.",
|
||||
"success": {
|
||||
"posted": "Reactie geplaatst."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Schrijf een reactie voordat je plaatst.",
|
||||
"moderated": "Die reactie is geblokkeerd door moderatie.",
|
||||
"ratelimit": "Je reageert te snel. Wacht even en probeer opnieuw.",
|
||||
"invalid": "Ongeldige reactie.",
|
||||
"notFound": "Dat artikel is niet gevonden.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"photos": {
|
||||
"title": "Foto's",
|
||||
@@ -269,7 +330,10 @@
|
||||
"pinned": "Vastgepind",
|
||||
"locked": "Gesloten",
|
||||
"userNumber": "Gebruiker #{id}",
|
||||
"unknown": "Onbekend"
|
||||
"unknown": "Onbekend",
|
||||
"success": {
|
||||
"posted": "Discussie geplaatst."
|
||||
}
|
||||
},
|
||||
"guildForumNew": {
|
||||
"backToForum": "← Terug naar forum",
|
||||
@@ -281,7 +345,13 @@
|
||||
"messageLabel": "Bericht",
|
||||
"messagePlaceholder": "Schrijf je openingsbericht…",
|
||||
"postThread": "Discussie plaatsen",
|
||||
"cancel": "Annuleren"
|
||||
"cancel": "Annuleren",
|
||||
"errors": {
|
||||
"invalid": "Onderwerp en bericht zijn verplicht.",
|
||||
"notFound": "Dat gilde is niet gevonden.",
|
||||
"ratelimit": "Je plaatst te snel. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
"title": "Winkel",
|
||||
@@ -395,7 +465,16 @@
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Aangemaakt",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Gesloten"
|
||||
"statusClosed": "Gesloten",
|
||||
"success": {
|
||||
"created": "Ticket ingediend."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Vul een onderwerp en beschrijving in.",
|
||||
"moderated": "Dat ticket is geblokkeerd door moderatie.",
|
||||
"ratelimit": "Je dient te snel in. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"applyStaff": {
|
||||
"title": "Solliciteer voor {hotel} team",
|
||||
@@ -472,7 +551,68 @@
|
||||
"loginToPost": "Log in om een shout te plaatsen.",
|
||||
"latestTitle": "Laatste shouts",
|
||||
"latestSubtitle": "De meest recente berichten van luisteraars",
|
||||
"empty": "Nog geen shouts. Wees de eerste!"
|
||||
"empty": "Nog geen shouts. Wees de eerste!",
|
||||
"success": {
|
||||
"posted": "Shout geplaatst."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Schrijf een bericht voordat je shouwt.",
|
||||
"moderated": "Die shout is geblokkeerd door moderatie.",
|
||||
"ratelimit": "Je shout te snel. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Evenementen",
|
||||
"subtitle": "Aankomende en recente hotelevenementen waaraan je kunt meedoen",
|
||||
"empty": "Nog geen evenementen.",
|
||||
"back": "← Evenementen",
|
||||
"when": "Wanneer",
|
||||
"status": "Status",
|
||||
"statusPublished": "Open",
|
||||
"statusCompleted": "Afgerond",
|
||||
"registrations": "Inschrijvingen",
|
||||
"registered": "{count} ingeschreven",
|
||||
"spots": "{count} / {max} plaatsen",
|
||||
"registerTitle": "Inschrijven",
|
||||
"registerSubtitle": "Reserveer je plek voor dit evenement",
|
||||
"register": "Inschrijven voor evenement",
|
||||
"registering": "Bezig met inschrijven…",
|
||||
"registerSuccess": "Je bent ingeschreven!",
|
||||
"registerError": "Inschrijven voor dit evenement is mislukt.",
|
||||
"loginToRegister": "Log in om je in te schrijven voor dit evenement.",
|
||||
"alreadyRegistered": "Je bent al ingeschreven.",
|
||||
"eventEnded": "Dit evenement is afgelopen.",
|
||||
"eventFull": "Dit evenement is vol.",
|
||||
"cannotRegister": "Inschrijven is niet beschikbaar.",
|
||||
"prizesTitle": "Prijzen",
|
||||
"winnersTitle": "Winnaars",
|
||||
"unknownUser": "Gebruiker #{id}"
|
||||
},
|
||||
"polls": {
|
||||
"title": "Peilingen",
|
||||
"subtitle": "Deel je mening in community-peilingen",
|
||||
"empty": "Nog geen peilingen.",
|
||||
"back": "← Peilingen",
|
||||
"statusActive": "Open",
|
||||
"statusClosed": "Gesloten",
|
||||
"questionsCount": "{count} vragen",
|
||||
"ends": "Eindigt {date}",
|
||||
"voteTitle": "Breng je stem uit",
|
||||
"voteSubtitle": "Beantwoord elke vraag hieronder",
|
||||
"submitVote": "Stem indienen",
|
||||
"voting": "Bezig met indienen…",
|
||||
"voteSuccess": "Bedankt voor je stem!",
|
||||
"voteError": "Je stem kon niet worden ingediend.",
|
||||
"loginToVote": "Log in om te stemmen in deze peiling.",
|
||||
"alreadyVoted": "Je hebt al gestemd.",
|
||||
"pollClosed": "Deze peiling is gesloten.",
|
||||
"cannotVote": "Stemmen is niet beschikbaar.",
|
||||
"textPlaceholder": "Jouw antwoord…",
|
||||
"resultsTitle": "Resultaten",
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} stemmen in totaal",
|
||||
"textResponses": "{count} vrije antwoorden"
|
||||
},
|
||||
"radioContests": {
|
||||
"title": "Radiowedstrijden",
|
||||
@@ -627,7 +767,9 @@
|
||||
"createOne": "Account aanmaken",
|
||||
"forgotPassword": "Wachtwoord vergeten?",
|
||||
"errorInvalidCredentials": "Ongeldige gebruikersnaam of wachtwoord",
|
||||
"errorInvalid2fa": "Ongeldige 2FA-code"
|
||||
"errorInvalid2fa": "Ongeldige 2FA-code",
|
||||
"errorUnverified": "Verifieer je e-mail voordat je inlogt.",
|
||||
"errorCaptcha": "Captcha-verificatie mislukt. Probeer het opnieuw."
|
||||
},
|
||||
"register": {
|
||||
"title": "Account aanmaken",
|
||||
@@ -663,7 +805,8 @@
|
||||
"sentNotice": "Als dat e-mailadres geregistreerd is, is er een resetlink verzonden. Controleer je inbox.",
|
||||
"emailPlaceholder": "Je e-mailadres",
|
||||
"sendResetLink": "Resetlink verzenden",
|
||||
"backToLogin": "Terug naar inloggen"
|
||||
"backToLogin": "Terug naar inloggen",
|
||||
"errorCaptcha": "Captcha-verificatie mislukt. Probeer het opnieuw."
|
||||
},
|
||||
"reset": {
|
||||
"title": "Nieuw wachtwoord instellen",
|
||||
@@ -1972,16 +2115,35 @@
|
||||
"title": "Teamactiviteiten",
|
||||
"subtitle": "Audittrail van teamacties in het beheerderspaneel",
|
||||
"noLogs": "Nog geen teamactiviteit geregistreerd",
|
||||
"noResults": "Geen overeenkomende activiteiten",
|
||||
"colId": "ID",
|
||||
"colUser": "Gebruiker",
|
||||
"colStaff": "Team",
|
||||
"colAction": "Actie",
|
||||
"colTarget": "Doelwit",
|
||||
"colDescription": "Beschrijving",
|
||||
"colIp": "IP",
|
||||
"colWhen": "Wanneer",
|
||||
"colTimestamp": "Tijdstempel",
|
||||
"filterByUser": "Filteren op gebruiker…",
|
||||
"filterByAction": "Filteren op actie…",
|
||||
"clearFilters": "Filters wissen",
|
||||
"searchPlaceholder": "Zoek op actie, beschrijving of IP…",
|
||||
"totalActivities": "Totaal activiteiten",
|
||||
"onThisPage": "Op deze pagina",
|
||||
"recent": "Laatste minuut",
|
||||
"authorizationErrors": "Autorisatiefouten",
|
||||
"authorizationOnly": "Alleen auth-fouten",
|
||||
"allActivities": "Alle activiteiten",
|
||||
"staffId": "Team-ID",
|
||||
"actionType": "Actietype",
|
||||
"filter": "Filter",
|
||||
"activities": "{count} activiteiten",
|
||||
"staffWithId": "#{id} {name}",
|
||||
"userPrefix": "gebruiker",
|
||||
"pagination": "Pagina {page} van {pages} · {total} totaal",
|
||||
"next": "Volgende",
|
||||
"prev": "Vorige",
|
||||
"unknownUser": "onbekend",
|
||||
"emptyValue": "-",
|
||||
"colTime": "Tijd",
|
||||
@@ -2014,10 +2176,18 @@
|
||||
"wordfilter": {
|
||||
"title": "Woordfilter",
|
||||
"subtitle": "Beheer gefilterde woorden en zinnen in het hotel",
|
||||
"filteredWords": "Gefilterde woorden",
|
||||
"addWord": "Woord toevoegen",
|
||||
"addWords": "Woorden toevoegen",
|
||||
"words": "Woorden",
|
||||
"noWords": "Nog geen gefilterde woorden",
|
||||
"delete": "Verwijderen",
|
||||
"confirmDeleteTitle": "Woord verwijderen",
|
||||
"confirmDelete": "\"{word}\" verwijderen uit het filter?",
|
||||
"added": "Woord toegevoegd aan filter",
|
||||
"deleted": "Woord verwijderd uit filter",
|
||||
"addError": "Kon woord niet toevoegen (bestaat mogelijk al)",
|
||||
"deleteError": "Kon woord niet verwijderen",
|
||||
"colId": "ID",
|
||||
"colWord": "Woord",
|
||||
"colAddedBy": "Toegevoegd door",
|
||||
@@ -2290,6 +2460,9 @@
|
||||
"uploading": "Bezig met uploaden…",
|
||||
"delete": "Verwijderen",
|
||||
"deleting": "Bezig met verwijderen…",
|
||||
"cancel": "Annuleren",
|
||||
"saved": "Favicon succesvol opgeslagen!",
|
||||
"deleted": "Favicon succesvol verwijderd!",
|
||||
"error": "Er is iets misgegaan",
|
||||
"confirmDelete": "Favicon verwijderen? De site zal geen aangepaste favicon meer tonen.",
|
||||
"generator": {
|
||||
@@ -2309,6 +2482,13 @@
|
||||
"delete": "Verwijderen",
|
||||
"noSettings": "Geen instellingen gevonden",
|
||||
"confirmDelete": "Instelling \"{key}\" verwijderen?",
|
||||
"keysInDatabase": "{count} sleutels in de database",
|
||||
"managedInForm": "{count} beheerd in het formulier",
|
||||
"documented": "{count} gedocumenteerd",
|
||||
"saved": "Instellingen opgeslagen.",
|
||||
"saveFailed": "Instellingen opslaan mislukt.",
|
||||
"saveSettings": "Instellingen opslaan",
|
||||
"viewOnly": "Je kunt instellingen bekijken, maar hebt bewerkingsrechten nodig.",
|
||||
"colKey": "Sleutel",
|
||||
"colValue": "Waarde",
|
||||
"colUpdated": "Bijgewerkt",
|
||||
@@ -2325,19 +2505,27 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"subtitle": "Import assets from external sources into your hotel.",
|
||||
"subtitle": "Importeer assets van externe bronnen naar je hotel.",
|
||||
"badges": "Badges",
|
||||
"badgesDescription": "Import and manage badge assets from external sources.",
|
||||
"furni": "Furni",
|
||||
"furniDescription": "Import furniture definitions, SWF files, and icons.",
|
||||
"clothing": "Clothing",
|
||||
"clothingDescription": "Import clothing and wearables for avatars.",
|
||||
"effects": "Effects",
|
||||
"effectsDescription": "Import avatar effects and animations.",
|
||||
"pets": "Pets",
|
||||
"petsDescription": "Import pet data and icons.",
|
||||
"clone": "Clone",
|
||||
"cloneDescription": "Clone existing items from one source to another."
|
||||
"badgesDescription": "Importeer en beheer badge-assets van externe bronnen.",
|
||||
"furni": "Meubels",
|
||||
"furniDescription": "Importeer meubeldefinities, SWF-bestanden en iconen.",
|
||||
"clothing": "Kleding",
|
||||
"clothingDescription": "Importeer kleding en wearables voor avatars.",
|
||||
"effects": "Effecten",
|
||||
"effectsDescription": "Importeer avatar-effecten en animaties.",
|
||||
"pets": "Huisdieren",
|
||||
"petsDescription": "Importeer huisdiergegevens en iconen.",
|
||||
"clone": "Klonen",
|
||||
"cloneDescription": "Kloon bestaande items van de ene bron naar de andere.",
|
||||
"upload": "Upload .nitro",
|
||||
"uploadDescription": "Upload .nitro-bundles en iconen direct zonder SWF-conversie.",
|
||||
"sync": "Auto-sync",
|
||||
"syncDescription": "Importeer automatisch ontbrekende meubels van alle geconfigureerde bronnen.",
|
||||
"repairIcons": "Iconen repareren",
|
||||
"repairIconsDescription": "Download ontbrekende icoonbestanden voor meubels die al in de database staan.",
|
||||
"audit": "Cataloguscontrole",
|
||||
"auditDescription": "Scan je catalogus op ontbrekende items, kapotte verwijzingen en items die ontbreken in kloonbronnen."
|
||||
},
|
||||
"translations": {
|
||||
"title": "Translations",
|
||||
|
||||
Reference in new issue
Block a user