feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

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:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

+59
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+26 -2
View File
@@ -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>
+15
View File
@@ -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>
);
}
+58 -4
View File
@@ -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>
+28
View File
@@ -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>
+50 -3
View File
@@ -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
View File
@@ -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}
/>
);
}
+15
View File
@@ -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>
);
}
+85 -12
View File
@@ -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>
);
})}
+51 -2
View File
@@ -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={{
+225
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+50 -3
View File
@@ -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>
+131 -4
View File
@@ -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>
-2
View File
@@ -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;
-2
View File
@@ -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>
</>
);
}
+2 -432
View File
@@ -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">
-2
View File
@@ -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)) {
+44 -11
View File
@@ -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}
</>
);
}
-2
View File
@@ -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`);
+2
View File
@@ -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({
-2
View File
@@ -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 = {
+44 -11
View File
@@ -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",
-2
View File
@@ -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;
-2
View File
@@ -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;
-2
View File
@@ -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)) {
-2
View File
@@ -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 });
-1
View File
@@ -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": "*",
},
});
}