feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Home, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorScreen } from "@/components/error-screen";
|
||||
|
||||
/**
|
||||
* Site route-segment error boundary. Renders inside the site shell layout.
|
||||
* Never shows the raw error to the user.
|
||||
*/
|
||||
export default function SiteErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ErrorScreen
|
||||
code={t("code")}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
body={t("body")}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<RefreshCw className="error-screen-btn-icon" aria-hidden />
|
||||
{t("tryAgain")}
|
||||
</button>
|
||||
<Link className="btn btn-outline" href="/">
|
||||
<Home className="error-screen-btn-icon" aria-hidden />
|
||||
{t("backHome")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
error.digest ? (
|
||||
<p className="error-screen-digest">
|
||||
{t("reference", { digest: error.digest })}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { registerForEvent } from "@/actions/events";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export function EventRegisterButton({
|
||||
eventId,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: {
|
||||
eventId: number;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.events");
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{disabledReason ?? t("cannotRegister")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => registerForEvent({ eventId }), {
|
||||
successMessage: t("registerSuccess"),
|
||||
errorMessage: t("registerError"),
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? t("registering") : t("register")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventRegisterButton } from "./event-register-button";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function parseEventId(slugOrId: string): number | null {
|
||||
if (/^\d+$/.test(slugOrId)) return Number.parseInt(slugOrId, 10);
|
||||
const match = slugOrId.match(/-(\d+)$/);
|
||||
if (match) return Number.parseInt(match[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slugOrId: string }>;
|
||||
}) {
|
||||
const { slugOrId } = await params;
|
||||
const eventId = parseEventId(slugOrId);
|
||||
if (eventId == null || !Number.isFinite(eventId)) notFound();
|
||||
|
||||
const t = await getTranslations("pages.events");
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
|
||||
const event = await prisma.websiteEvent
|
||||
.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
type: true,
|
||||
prizes: { orderBy: { position: "asc" } },
|
||||
winners: { orderBy: { position: "asc" } },
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!event || (event.status !== "published" && event.status !== "completed")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const alreadyRegistered =
|
||||
Number.isInteger(userId) && userId > 0
|
||||
? Boolean(
|
||||
await prisma.websiteEventRegistration
|
||||
.findUnique({
|
||||
where: { eventId_userId: { eventId: event.id, userId } },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
: false;
|
||||
|
||||
const winnerIds = event.winners.map((w) => w.userId);
|
||||
const winners =
|
||||
winnerIds.length > 0
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: winnerIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const winnerName = new Map(winners.map((u) => [u.id, u.username]));
|
||||
|
||||
const isFull =
|
||||
event.maxPlayers != null &&
|
||||
event._count.registrations >= event.maxPlayers;
|
||||
const hasEnded =
|
||||
event.status === "completed" ||
|
||||
(event.endsAt != null && event.endsAt.getTime() < Date.now());
|
||||
const canRegister =
|
||||
event.status === "published" && !hasEnded && !isFull && !alreadyRegistered;
|
||||
|
||||
let disabledReason: string | undefined;
|
||||
if (!session?.user) disabledReason = t("loginToRegister");
|
||||
else if (alreadyRegistered) disabledReason = t("alreadyRegistered");
|
||||
else if (hasEnded) disabledReason = t("eventEnded");
|
||||
else if (isFull) disabledReason = t("eventFull");
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/events">{t("back")}</Link>
|
||||
</p>
|
||||
|
||||
{event.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "21/9",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={event.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="📅"
|
||||
title={event.title}
|
||||
subtitle={`${event.type.name} · ${formatDate(event.startsAt, "datetime", "")}`}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||
{event.description}
|
||||
</p>
|
||||
<table style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ width: 140, textAlign: "left" }}>{t("when")}</th>
|
||||
<td>
|
||||
{formatDate(event.startsAt, "datetime", "")}
|
||||
{event.endsAt
|
||||
? ` — ${formatDate(event.endsAt, "datetime", "")}`
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>{t("status")}</th>
|
||||
<td>
|
||||
{event.status === "completed"
|
||||
? t("statusCompleted")
|
||||
: t("statusPublished")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>{t("registrations")}</th>
|
||||
<td>
|
||||
{event.maxPlayers != null
|
||||
? t("spots", {
|
||||
count: event._count.registrations,
|
||||
max: event.maxPlayers,
|
||||
})
|
||||
: t("registered", {
|
||||
count: event._count.registrations,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard icon="✍️" title={t("registerTitle")} subtitle={t("registerSubtitle")}>
|
||||
{session?.user && canRegister ? (
|
||||
<EventRegisterButton eventId={event.id} />
|
||||
) : (
|
||||
<EmptyState icon={session?.user ? "ℹ️" : "🔒"}>
|
||||
{disabledReason ?? t("cannotRegister")}
|
||||
</EmptyState>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{event.prizes.length > 0 ? (
|
||||
<ContentCard icon="🎁" title={t("prizesTitle")}>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
||||
{event.prizes.map((prize) => (
|
||||
<li key={prize.id} style={{ marginBottom: "0.35rem" }}>
|
||||
<strong>#{prize.position}</strong>
|
||||
{prize.description
|
||||
? ` — ${prize.description}`
|
||||
: ` — ${prize.prizeType}`}
|
||||
{prize.credits > 0 ? ` · ${prize.credits} credits` : ""}
|
||||
{prize.pixels > 0 ? ` · ${prize.pixels} pixels` : ""}
|
||||
{prize.points > 0 ? ` · ${prize.points} points` : ""}
|
||||
{prize.badgeCode ? ` · badge ${prize.badgeCode}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
{event.winners.length > 0 ? (
|
||||
<ContentCard icon="🏆" title={t("winnersTitle")}>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
||||
{event.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
#{w.position} —{" "}
|
||||
{winnerName.get(w.userId) ?? t("unknownUser", { id: w.userId })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt, slugify } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = { title: "Events" };
|
||||
|
||||
export default async function EventsPage() {
|
||||
const t = await getTranslations("pages.events");
|
||||
|
||||
const events = await prisma.websiteEvent
|
||||
.findMany({
|
||||
where: { status: { in: ["published", "completed"] } },
|
||||
orderBy: { startsAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
type: { select: { name: true, color: true, slug: true } },
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={events.length === 0}>
|
||||
{events.length === 0 ? (
|
||||
<EmptyState icon="📅">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{events.map((event) => {
|
||||
const href = `/events/${slugify(event.title)}-${event.id}`;
|
||||
const spots =
|
||||
event.maxPlayers != null
|
||||
? t("spots", {
|
||||
count: event._count.registrations,
|
||||
max: event.maxPlayers,
|
||||
})
|
||||
: t("registered", { count: event._count.registrations });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={event.id}
|
||||
href={href}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
textDecoration: "none",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
{event.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={event.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
backgroundColor:
|
||||
event.type.color ||
|
||||
"color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.1rem",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{event.type.name}
|
||||
{event.status === "completed"
|
||||
? ` · ${t("statusCompleted")}`
|
||||
: ""}
|
||||
</p>
|
||||
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.85rem" }}
|
||||
>
|
||||
{formatDate(event.startsAt, "datetime", "")}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
||||
{excerpt(event.description, 120)}
|
||||
</p>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "auto 0 0", fontSize: "0.8rem" }}
|
||||
>
|
||||
{spots}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requestReset } from "@/actions/password-reset";
|
||||
import { CaptchaWidget } from "@/components/auth/captcha-widget";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
export default async function ForgotPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ sent?: string }>;
|
||||
searchParams: Promise<{ sent?: string; error?: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.forgot");
|
||||
const { sent } = await searchParams;
|
||||
const { sent, error } = await searchParams;
|
||||
const cfg = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
@@ -30,6 +35,25 @@ export default async function ForgotPage({
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<CaptchaWidget
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
{error === "captcha" ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-danger)",
|
||||
textAlign: "center",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{t("errorCaptcha")}
|
||||
</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("sendResetLink")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function FriendsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { removeFriendship } from "@/actions/messenger";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
@@ -11,16 +13,38 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = { title: "Friends" };
|
||||
|
||||
export default async function FriendsPage() {
|
||||
type SearchParams = Promise<{ removed?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FriendsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.friends");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const { removed, error } = await searchParams;
|
||||
|
||||
// Friendships are bidirectional and stored once: the session user can appear
|
||||
// as either user_one_id or user_two_id, with the *other* column being the
|
||||
// friend. Read both directions, then dedupe the friend ids.
|
||||
// Friendships are stored as TWO directional rows (user_one_id→user_two_id
|
||||
// and the reverse). Read both directions, then dedupe the friend ids.
|
||||
const friendships = await prisma.messengerFriendships
|
||||
.findMany({
|
||||
where: {
|
||||
@@ -60,8 +84,28 @@ export default async function FriendsPage() {
|
||||
),
|
||||
]);
|
||||
|
||||
const errorMessage =
|
||||
error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{removed === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.removed")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title={t("title")}
|
||||
@@ -120,6 +164,16 @@ export default async function FriendsPage() {
|
||||
</p>
|
||||
<OnlineBadge online={isOnline} />
|
||||
</div>
|
||||
<form action={removeFriendship}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="friendId"
|
||||
value={String(friend.id)}
|
||||
/>
|
||||
<button type="submit" className="btn">
|
||||
{t("remove")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postThread } from "@/actions/social";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -8,13 +9,34 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function NewThreadPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.guildForumNew");
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const guildId = Number(id);
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
|
||||
|
||||
@@ -35,8 +57,25 @@ export default async function NewThreadPage({
|
||||
|
||||
if (!guild) notFound();
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
type SearchParams = Promise<{ posted?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
type ThreadRow = {
|
||||
id: number;
|
||||
openerId: number | null;
|
||||
@@ -24,11 +43,14 @@ function formatTimestamp(ts: number | null | undefined): string {
|
||||
|
||||
export default async function GuildForumPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.guildForum");
|
||||
const { id } = await params;
|
||||
const { posted } = await searchParams;
|
||||
const guildId = Number(id);
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
|
||||
|
||||
@@ -89,6 +111,12 @@ export default async function GuildForumPage({
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{posted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}`}>{t("backToGuild")}</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { createTicket } from "@/actions/help-tickets";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ created?: string; error?: string }>;
|
||||
|
||||
export default async function HelpTicketsPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HelpTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.helpTickets");
|
||||
const { created, error } = await searchParams;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
@@ -33,8 +56,30 @@ export default async function HelpTicketsPage() {
|
||||
const openCount = tickets.filter((t) => t.open).length;
|
||||
const closedCount = tickets.length - openCount;
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{created === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.created")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="🎫" title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="stat-grid">
|
||||
<StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} />
|
||||
@@ -99,7 +144,9 @@ export default async function HelpTicketsPage() {
|
||||
{t2.open ? t("statusOpen") : t("statusClosed")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{formatDate(t2.createdAt, "datetime", "")}</td>
|
||||
<td className="muted">
|
||||
{formatDate(t2.createdAt, "datetime", "")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+14
-153
@@ -1,158 +1,19 @@
|
||||
"use client";
|
||||
import { headers } from "next/headers";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("pages.login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const pre = await precheckLogin(username, password);
|
||||
if (pre === "invalid") {
|
||||
setError(t("errorInvalidCredentials"));
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
redirect: false,
|
||||
});
|
||||
if (!res || res.error) {
|
||||
setError(
|
||||
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
export default async function LoginPage() {
|
||||
const cfg = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon={needs2fa ? "🔒" : "🔑"}
|
||||
title={t("title")}
|
||||
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
|
||||
>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
/>
|
||||
{needs2fa ? (
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary" disabled={pending}>
|
||||
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!needs2fa ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
margin: "1rem 0",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: "var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
<span className="muted">{t("or")}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: "var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => signIn("discord", { callbackUrl: "/" })}
|
||||
>
|
||||
{t("continueWithDiscord")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => signIn("google", { callbackUrl: "/" })}
|
||||
>
|
||||
{t("continueWithGoogle")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-danger)",
|
||||
textAlign: "center",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
||||
>
|
||||
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
|
||||
<Link href="/forgot">{t("forgotPassword")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
<LoginForm
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function MessagesLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { acceptFriend } from "@/actions/messenger";
|
||||
import type { CSSProperties } from "react";
|
||||
import { acceptFriend, declineFriendRequest } from "@/actions/messenger";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
@@ -10,18 +11,45 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
accepted?: string;
|
||||
declined?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
// sended_on is a unix timestamp in seconds (emulator convention).
|
||||
return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function MessagesPage() {
|
||||
export default async function MessagesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.messages");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const { accepted, declined, error } = await searchParams;
|
||||
|
||||
// Pending friend requests addressed to the session user, the most recent
|
||||
// offline messages they've received, and the avatar-imager base URL.
|
||||
@@ -85,8 +113,35 @@ export default async function MessagesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "unauthorized"
|
||||
? t("errors.unauthorized")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{accepted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.accepted")}
|
||||
</div>
|
||||
) : null}
|
||||
{declined === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.declined")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{/* ── Pending friend requests ───────────────────────────── */}
|
||||
@@ -144,16 +199,34 @@ export default async function MessagesPage() {
|
||||
{t("wantsToBeFriend")}
|
||||
</p>
|
||||
</div>
|
||||
<form action={acceptFriend}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="requestId"
|
||||
value={String(req.id)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
{t("accept")}
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<form action={acceptFriend}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="requestId"
|
||||
value={String(req.id)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
{t("accept")}
|
||||
</button>
|
||||
</form>
|
||||
<form action={declineFriendRequest}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="requestId"
|
||||
value={String(req.id)}
|
||||
/>
|
||||
<button type="submit" className="btn">
|
||||
{t("decline")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postComment } from "@/actions/article-comments";
|
||||
import { toggleReaction } from "@/actions/article-reactions";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { cacheQuery } from "@/lib/prisma-cache";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type SearchParams = Promise<{ comment?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await prisma.websiteArticles
|
||||
.findMany({
|
||||
@@ -62,10 +81,13 @@ const REACTIONS: { key: string; emoji: string }[] = [
|
||||
|
||||
export default async function ArticlePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { comment, error } = await searchParams;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
const article = await cacheQuery(
|
||||
@@ -145,8 +167,34 @@ export default async function ArticlePage({
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{comment === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title={article.title}
|
||||
@@ -290,6 +338,7 @@ export default async function ArticlePage({
|
||||
{loggedIn ? (
|
||||
<form action={postComment} className="card">
|
||||
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
<label
|
||||
htmlFor="comment"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PollVoteForm } from "./poll-vote-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PollDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const pollId = Number.parseInt(id, 10);
|
||||
if (!Number.isFinite(pollId) || pollId <= 0) notFound();
|
||||
|
||||
const t = await getTranslations("pages.polls");
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
|
||||
const poll = await prisma.websitePoll
|
||||
.findUnique({
|
||||
where: { id: pollId },
|
||||
include: {
|
||||
questions: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!poll || (poll.status !== "active" && poll.status !== "closed")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (poll.startsAt && poll.startsAt.getTime() > now && poll.status === "active") {
|
||||
// Not started yet — treat as unavailable for guests
|
||||
notFound();
|
||||
}
|
||||
|
||||
const hasEnded =
|
||||
poll.status === "closed" ||
|
||||
(poll.endsAt != null && poll.endsAt.getTime() < now);
|
||||
const isOpen = poll.status === "active" && !hasEnded;
|
||||
|
||||
const questionIds = poll.questions.map((q) => q.id);
|
||||
const myVotes =
|
||||
Number.isInteger(userId) && userId > 0 && questionIds.length > 0
|
||||
? await prisma.websitePollVote
|
||||
.findMany({
|
||||
where: { userId, questionId: { in: questionIds } },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const hasVoted = myVotes.length > 0;
|
||||
|
||||
const allVotes =
|
||||
poll.showResults === 1
|
||||
? await prisma.websitePollVote
|
||||
.findMany({
|
||||
where: { questionId: { in: questionIds } },
|
||||
select: { questionId: true, answer: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
|
||||
const votesByQuestion = new Map<number, string[]>();
|
||||
for (const vote of allVotes) {
|
||||
const list = votesByQuestion.get(vote.questionId) ?? [];
|
||||
list.push(vote.answer);
|
||||
votesByQuestion.set(vote.questionId, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/polls">{t("back")}</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard
|
||||
icon="📊"
|
||||
title={poll.title}
|
||||
subtitle={
|
||||
hasEnded
|
||||
? t("statusClosed")
|
||||
: t("statusActive") +
|
||||
(poll.endsAt
|
||||
? ` · ${t("ends", { date: formatDate(poll.endsAt, "datetime", "") })}`
|
||||
: "")
|
||||
}
|
||||
>
|
||||
{poll.description ? (
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||
{poll.description}
|
||||
</p>
|
||||
) : null}
|
||||
</ContentCard>
|
||||
|
||||
{isOpen && !hasVoted ? (
|
||||
session?.user ? (
|
||||
<ContentCard icon="🗳️" title={t("voteTitle")} subtitle={t("voteSubtitle")}>
|
||||
<PollVoteForm
|
||||
pollId={poll.id}
|
||||
questions={poll.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
type: q.type,
|
||||
options: q.options,
|
||||
}))}
|
||||
/>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padded>
|
||||
<EmptyState icon="🔒">{t("loginToVote")}</EmptyState>
|
||||
</ContentCard>
|
||||
)
|
||||
) : (
|
||||
<ContentCard padded>
|
||||
<EmptyState icon={hasVoted ? "✅" : "ℹ️"}>
|
||||
{hasVoted
|
||||
? t("alreadyVoted")
|
||||
: hasEnded
|
||||
? t("pollClosed")
|
||||
: t("cannotVote")}
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{poll.showResults === 1 && (hasVoted || hasEnded) ? (
|
||||
<ContentCard icon="📈" title={t("resultsTitle")}>
|
||||
<div style={{ display: "grid", gap: "1.25rem" }}>
|
||||
{poll.questions.map((q) => {
|
||||
const options = q.options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
const votes = votesByQuestion.get(q.id) ?? [];
|
||||
const total = votes.length;
|
||||
|
||||
if (q.type === "text" || options.length === 0) {
|
||||
return (
|
||||
<div key={q.id}>
|
||||
<h3 style={{ margin: "0 0 0.4rem", fontSize: "1rem" }}>
|
||||
{q.question}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t("textResponses", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const opt of options) counts.set(opt, 0);
|
||||
for (const vote of votes) {
|
||||
for (const part of vote.split("\n").map((a) => a.trim()).filter(Boolean)) {
|
||||
counts.set(part, (counts.get(part) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={q.id}>
|
||||
<h3 style={{ margin: "0 0 0.65rem", fontSize: "1rem" }}>
|
||||
{q.question}
|
||||
</h3>
|
||||
<div style={{ display: "grid", gap: "0.55rem" }}>
|
||||
{options.map((opt) => {
|
||||
const count = counts.get(opt) ?? 0;
|
||||
const pct =
|
||||
total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={opt}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.85rem",
|
||||
marginBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
<span>{opt}</span>
|
||||
<span className="muted">
|
||||
{t("voteCount", { count, pct })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 16%, transparent)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${pct}%`,
|
||||
borderRadius: 999,
|
||||
background: "var(--color-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0.5rem 0 0", fontSize: "0.8rem" }}
|
||||
>
|
||||
{t("totalVotes", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { voteOnPoll } from "@/actions/polls";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
type: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
function parseOptions(options: string): string[] {
|
||||
return options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function PollVoteForm({
|
||||
pollId,
|
||||
questions,
|
||||
}: {
|
||||
pollId: number;
|
||||
questions: Question[];
|
||||
}) {
|
||||
const t = useTranslations("pages.polls");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({});
|
||||
const [multi, setMulti] = useState<Record<number, string[]>>({});
|
||||
|
||||
function setSingle(questionId: number, value: string) {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
}
|
||||
|
||||
function toggleMulti(questionId: number, option: string) {
|
||||
setMulti((prev) => {
|
||||
const current = prev[questionId] ?? [];
|
||||
const next = current.includes(option)
|
||||
? current.filter((o) => o !== option)
|
||||
: [...current, option];
|
||||
return { ...prev, [questionId]: next };
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const votes = questions.map((q) => {
|
||||
if (q.type === "multiple") {
|
||||
return {
|
||||
questionId: q.id,
|
||||
answer: (multi[q.id] ?? []).join("\n"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
questionId: q.id,
|
||||
answer: (answers[q.id] ?? "").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
if (votes.some((v) => !v.answer)) {
|
||||
toast.error(t("voteError"));
|
||||
return;
|
||||
}
|
||||
|
||||
run(() => voteOnPoll({ pollId, votes }), {
|
||||
successMessage: t("voteSuccess"),
|
||||
errorMessage: t("voteError"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "1.25rem" }}>
|
||||
{questions.map((q) => {
|
||||
const options = parseOptions(q.options);
|
||||
return (
|
||||
<fieldset
|
||||
key={q.id}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
|
||||
borderRadius: "0.65rem",
|
||||
}}
|
||||
>
|
||||
<legend style={{ padding: "0 0.35rem", fontWeight: 700 }}>
|
||||
{q.question}
|
||||
</legend>
|
||||
|
||||
{q.type === "text" ? (
|
||||
<textarea
|
||||
required
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
value={answers[q.id] ?? ""}
|
||||
onChange={(e) => setSingle(q.id, e.target.value)}
|
||||
placeholder={t("textPlaceholder")}
|
||||
style={{ width: "100%", marginTop: "0.35rem", resize: "vertical" }}
|
||||
/>
|
||||
) : q.type === "multiple" ? (
|
||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(multi[q.id] ?? []).includes(opt)}
|
||||
onChange={() => toggleMulti(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
required
|
||||
checked={(answers[q.id] ?? "") === opt}
|
||||
onChange={() => setSingle(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? t("voting") : t("submitVote")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = { title: "Polls" };
|
||||
|
||||
export default async function PollsPage() {
|
||||
const t = await getTranslations("pages.polls");
|
||||
const now = new Date();
|
||||
|
||||
const polls = await prisma.websitePoll
|
||||
.findMany({
|
||||
where: {
|
||||
status: { in: ["active", "closed"] },
|
||||
OR: [{ startsAt: null }, { startsAt: { lte: now } }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
_count: { select: { questions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📊" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={polls.length === 0}>
|
||||
{polls.length === 0 ? (
|
||||
<EmptyState icon="📊">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{polls.map((poll) => {
|
||||
const ended =
|
||||
poll.status === "closed" ||
|
||||
(poll.endsAt != null && poll.endsAt.getTime() < Date.now());
|
||||
return (
|
||||
<Link
|
||||
key={poll.id}
|
||||
href={`/polls/${poll.id}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
display: "grid",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{ended ? t("statusClosed") : t("statusActive")}
|
||||
{" · "}
|
||||
{t("questionsCount", { count: poll._count.questions })}
|
||||
</p>
|
||||
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
|
||||
{poll.title}
|
||||
</h3>
|
||||
{poll.description ? (
|
||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
||||
{excerpt(poll.description, 120)}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.85rem" }}
|
||||
>
|
||||
{poll.endsAt
|
||||
? t("ends", {
|
||||
date: formatDate(poll.endsAt, "datetime", ""),
|
||||
})
|
||||
: formatDate(poll.createdAt, "date", "")}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postShout } from "@/actions/radio-shouts";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ posted?: string; error?: string }>;
|
||||
|
||||
export default async function RadioShoutsPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RadioShoutsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.radioShouts");
|
||||
const { posted, error } = await searchParams;
|
||||
|
||||
const [shouts, imagerBase, session] = await Promise.all([
|
||||
prisma.radioShouts
|
||||
@@ -41,8 +64,30 @@ export default async function RadioShoutsPage() {
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{posted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{isLoggedIn ? (
|
||||
@@ -122,7 +167,9 @@ export default async function RadioShoutsPage() {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
|
||||
<span className="muted">{formatDate(s.createdAt, "datetime", "")}</span>
|
||||
<span className="muted">
|
||||
{formatDate(s.createdAt, "datetime", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{s.message}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { postGuestbook } from "@/actions/guestbook";
|
||||
import { sendFriendRequest } from "@/actions/social";
|
||||
import {
|
||||
ContentCard,
|
||||
EmptyState,
|
||||
@@ -12,11 +13,34 @@ import {
|
||||
} from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
friend?: string;
|
||||
guestbook?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
@@ -48,14 +72,16 @@ function formatTimestamp(ts: number | null | undefined): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ username: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.profile");
|
||||
const { username } = await params;
|
||||
const { friend, guestbook: guestbookStatus, error } = await searchParams;
|
||||
|
||||
const [user, imagerBase, session] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
@@ -84,7 +110,65 @@ export default async function ProfilePage({
|
||||
const registered = new Date(user.accountCreated * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const isLoggedIn = Boolean(session?.user?.id);
|
||||
const meId = Number(session?.user?.id);
|
||||
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
||||
const isSelf = isLoggedIn && meId === user.id;
|
||||
|
||||
// Friend / request state relative to the signed-in viewer (if any).
|
||||
let isFriend = false;
|
||||
let outgoingPending = false;
|
||||
let incomingPending = false;
|
||||
if (isLoggedIn && !isSelf) {
|
||||
const [friendship, outgoing, incoming] = await Promise.all([
|
||||
prisma.messengerFriendships
|
||||
.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: user.id },
|
||||
{ userOneId: user.id, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.messengerFriendrequests
|
||||
.findFirst({
|
||||
where: { userFromId: meId, userToId: user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.messengerFriendrequests
|
||||
.findFirst({
|
||||
where: { userFromId: user.id, userToId: meId },
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
isFriend = Boolean(friendship);
|
||||
outgoingPending = Boolean(outgoing);
|
||||
incomingPending = Boolean(incoming);
|
||||
}
|
||||
|
||||
const feedbackErrorMessage =
|
||||
error === "self"
|
||||
? t("errors.self")
|
||||
: error === "already_friends"
|
||||
? t("errors.alreadyFriends")
|
||||
: error === "already_pending"
|
||||
? t("errors.alreadyPending")
|
||||
: error === "incoming_pending"
|
||||
? t("errors.incomingPending")
|
||||
: error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
// Profile sub-sections. Each query is isolated so a DB hiccup degrades that
|
||||
// single section to empty rather than 500-ing the whole profile.
|
||||
@@ -205,6 +289,22 @@ export default async function ProfilePage({
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{friend === "sent" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.sent")}
|
||||
</div>
|
||||
) : null}
|
||||
{guestbookStatus === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.guestbook")}
|
||||
</div>
|
||||
) : null}
|
||||
{feedbackErrorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{feedbackErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Profile header ──────────────────── */}
|
||||
<ContentCard padded={false}>
|
||||
<div
|
||||
@@ -230,6 +330,31 @@ export default async function ProfilePage({
|
||||
</p>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
</div>
|
||||
{isLoggedIn && !isSelf ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{isFriend ? (
|
||||
<span className="muted" style={{ fontWeight: 600 }}>
|
||||
{t("alreadyFriends")}
|
||||
</span>
|
||||
) : outgoingPending ? (
|
||||
<span className="muted" style={{ fontWeight: 600 }}>
|
||||
{t("requestPending")}
|
||||
</span>
|
||||
) : incomingPending ? (
|
||||
<Link href="/messages" className="btn btn-secondary">
|
||||
{t("respondInMessages")}
|
||||
</Link>
|
||||
) : (
|
||||
<form action={sendFriendRequest}>
|
||||
<input type="hidden" name="userId" value={String(user.id)} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("addFriend")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
|
||||
<StatBlock icon="🏅" value={user.rank} label={t("statRank")} />
|
||||
@@ -563,7 +688,9 @@ export default async function ProfilePage({
|
||||
<strong>
|
||||
{author?.username ?? t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">{formatDate(g.createdAt, "date", "")}</span>
|
||||
<span className="muted">
|
||||
{formatDate(g.createdAt, "date", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user