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

+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>