feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 4m23s

Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:21:02 +02:00
1 parent ed7db6e048
commit 803e8f36c1
47 files changed
+2793 -358

No files matched your search

+47 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -11,11 +12,45 @@ export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyStaffPage() {
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 ApplyStaffPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyStaff");
const { submitted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "duplicate"
? t("errors.duplicate")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -53,6 +88,17 @@ export default async function ApplyStaffPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🛡️"
title={t("title", { hotel: hotelName })}
+47 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -11,11 +12,45 @@ export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyTeamPage() {
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 ApplyTeamPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyTeam");
const { submitted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "duplicate"
? t("errors.duplicate")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🤝"
title={t("title", { hotel: hotelName })}
+2
View File
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
const ERROR_NOTE: Record<string, string> = {
invalid: "That badge is no longer available.",
credits: "You don't have enough credits to buy this badge.",
ratelimit: "You're purchasing too fast. Please wait a moment and try again.",
fail: "Something went wrong. Please try again.",
};
function getErrorNote(error: string): string {
if (error === "invalid") return ERROR_NOTE.invalid;
if (error === "credits") return ERROR_NOTE.credits;
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
return ERROR_NOTE.fail;
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
@@ -0,0 +1,275 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { replyToThread } from "@/actions/social";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const revalidate = 60;
type SearchParams = Promise<{ replied?: 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(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function GuildForumThreadPage({
params,
searchParams,
}: {
params: Promise<{ id: string; threadId: string }>;
searchParams: SearchParams;
}) {
const t = await getTranslations("pages.guildForumThread");
const { id, threadId: threadIdRaw } = await params;
const { replied, error } = await searchParams;
const guildId = Number(id);
const threadId = Number(threadIdRaw);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
if (!Number.isInteger(threadId) || threadId <= 0) notFound();
const session = await auth();
const viewerId = Number(session?.user?.id);
const isLoggedIn = Number.isInteger(viewerId) && viewerId > 0;
let guild: { id: number; name: string } | null = null;
let thread: {
id: number;
subject: string | null;
locked: number | null;
pinned: number | null;
openerId: number | null;
} | null = null;
let posts: {
id: number;
userId: number;
message: string;
createdAt: number;
}[] = [];
try {
[guild, thread] = await Promise.all([
prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true },
}),
prisma.guildsForumsThreads.findFirst({
where: { id: threadId, guildId, state: 0 },
select: {
id: true,
subject: true,
locked: true,
pinned: true,
openerId: true,
},
}),
]);
} catch {
guild = null;
thread = null;
}
if (!guild || !thread) notFound();
try {
posts = await prisma.guildsForumsComments.findMany({
where: { threadId: thread.id, state: 0 },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: {
id: true,
userId: true,
message: true,
createdAt: true,
},
});
} catch {
posts = [];
}
const authorIds = Array.from(
new Set(posts.map((p) => p.userId).filter((v) => v > 0)),
);
let users: { id: number; username: string }[] = [];
try {
users = authorIds.length
? await prisma.user.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true },
})
: [];
} catch {
users = [];
}
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const isLocked = !!thread.locked;
const canReply = isLoggedIn && !isLocked;
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "not_found"
? t("errors.notFound")
: error === "locked"
? t("errors.locked")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{replied === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.replied")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
</p>
<ContentCard
icon="💬"
title={thread.subject?.trim() || t("noSubject")}
subtitle={t("inGuildForum", {
guild: guild.name || t("unnamedGuild"),
})}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{thread.pinned ? (
<span className="online-badge online">📌 {t("pinned")}</span>
) : null}
{isLocked ? (
<span className="online-badge">🔒 {t("locked")}</span>
) : null}
</div>
</ContentCard>
<ContentCard
icon="📝"
title={t("postsTitle")}
subtitle={t("postsSubtitle", { count: posts.length })}
padded={posts.length === 0}
>
{posts.length === 0 ? (
<EmptyState icon="📝">{t("postsEmpty")}</EmptyState>
) : (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gap: "1rem",
}}
>
{posts.map((post) => {
const username = usernameById.get(post.userId);
return (
<li
key={post.id}
style={{
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border, rgba(0,0,0,0.08))",
background: "var(--color-surface)",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
gap: "0.35rem",
marginBottom: "0.5rem",
}}
>
<span style={{ fontWeight: 700 }}>
{username ? (
<Link href={`/u/${username}`}>{username}</Link>
) : (
<span className="muted">
{t("userNumber", { id: post.userId })}
</span>
)}
</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{formatTimestamp(post.createdAt)}
</span>
</div>
<p
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{post.message}
</p>
</li>
);
})}
</ul>
)}
</ContentCard>
{canReply ? (
<ContentCard icon="✍️" title={t("replyTitle")} subtitle={t("replySubtitle")}>
<form action={replyToThread}>
<input type="hidden" name="guildId" value={String(guild.id)} />
<input type="hidden" name="threadId" value={String(thread.id)} />
<textarea
name="message"
required
rows={6}
placeholder={t("replyPlaceholder")}
style={{
width: "100%",
marginBottom: "0.75rem",
resize: "vertical",
}}
/>
<button type="submit" className="btn btn-primary">
{t("replySubmit")}
</button>
</form>
</ContentCard>
) : isLocked ? (
<p className="muted" style={{ margin: 0 }}>
{t("lockedHint")}
</p>
) : (
<p className="muted" style={{ margin: 0 }}>
{t("loginToReply")}{" "}
<Link href="/login">{t("loginLink")}</Link>
</p>
)}
</main>
);
}
@@ -0,0 +1,15 @@
export default function GuildForumLoading() {
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>
);
}
+5 -2
View File
@@ -156,9 +156,12 @@ export default async function GuildForumPage({
return (
<tr key={t2.id}>
<td style={{ padding: "0.6rem 0.75rem" }}>
<span style={{ fontWeight: 700 }}>
<Link
href={`/guilds/${guild.id}/forum/${t2.id}`}
style={{ fontWeight: 700 }}
>
{t2.subject?.trim() || t("noSubject")}
</span>
</Link>
{t2.pinned ? (
<span
className="online-badge online"
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
+297
View File
@@ -0,0 +1,297 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { closeHelpTicket, replyHelpTicket } from "@/actions/help-tickets";
import { ContentCard } from "@/components/public/ui";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type SearchParams = Promise<{
replied?: string;
closed?: 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 HelpTicketDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: SearchParams;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.helpTicketDetail");
const { id } = await params;
const { replied, closed, error } = await searchParams;
const ticketId = positiveBigInt(id);
if (!ticketId) notFound();
const userId = Number(session.user.id);
let ticket: {
id: bigint;
userId: number | null;
title: string;
content: string;
open: boolean;
createdAt: Date | null;
} | null = null;
let replies: {
id: bigint;
userId: number;
content: string;
createdAt: Date | null;
}[] = [];
try {
ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: {
id: true,
userId: true,
title: true,
content: true,
open: true,
createdAt: true,
},
});
} catch {
ticket = null;
}
if (!ticket || ticket.userId !== userId) notFound();
try {
replies = await prisma.websiteHelpCenterTicketReplies.findMany({
where: { ticketId: ticket.id },
orderBy: { id: "asc" },
select: {
id: true,
userId: true,
content: true,
createdAt: true,
},
});
} catch {
replies = [];
}
const authorIds = [
...new Set([userId, ...replies.map((r) => r.userId)]),
];
let users: { id: number; username: string }[] = [];
try {
users = await prisma.user.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true },
});
} catch {
users = [];
}
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const openerName =
usernameById.get(userId) ?? t("userNumber", { id: userId });
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "not_found"
? t("errors.notFound")
: error === "closed_ticket"
? t("errors.closedTicket")
: error === "moderated"
? t("errors.moderated")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
type MessageRow = {
key: string;
authorId: number;
content: string;
createdAt: Date | null;
};
const messages: MessageRow[] = [
{
key: "opening",
authorId: userId,
content: ticket.content,
createdAt: ticket.createdAt,
},
...replies.map((r) => ({
key: String(r.id),
authorId: r.userId,
content: r.content,
createdAt: r.createdAt,
})),
];
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{replied === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.replied")}
</div>
) : null}
{closed === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.closed")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<p style={{ margin: 0 }}>
<Link href="/help/tickets">{t("backToList")}</Link>
</p>
<ContentCard icon="🎫" title={ticket.title} subtitle={t("ticketId", { id: String(ticket.id) })}>
<span className={`online-badge${ticket.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{ticket.open ? t("statusOpen") : t("statusClosed")}
</span>
</ContentCard>
<ContentCard
icon="💬"
title={t("messagesTitle")}
subtitle={t("messagesSubtitle", { count: messages.length })}
>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gap: "1rem",
}}
>
{messages.map((msg, index) => {
const username =
msg.authorId === userId
? openerName
: usernameById.get(msg.authorId);
return (
<li
key={msg.key}
style={{
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border, rgba(0,0,0,0.08))",
background: "var(--color-surface)",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
gap: "0.35rem",
marginBottom: "0.5rem",
}}
>
<span style={{ fontWeight: 700 }}>
{index === 0 ? t("you") : username ? (
<Link href={`/u/${username}`}>{username}</Link>
) : (
<span className="muted">
{t("userNumber", { id: msg.authorId })}
</span>
)}
</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{formatDate(msg.createdAt, "datetime", "")}
</span>
</div>
<p
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{msg.content}
</p>
</li>
);
})}
</ul>
</ContentCard>
{ticket.open ? (
<>
<ContentCard
icon="✍️"
title={t("replyTitle")}
subtitle={t("replySubtitle")}
>
<form action={replyHelpTicket}>
<input type="hidden" name="ticketId" value={String(ticket.id)} />
<textarea
name="content"
required
rows={5}
maxLength={5000}
placeholder={t("replyPlaceholder")}
style={{
width: "100%",
marginBottom: "0.75rem",
resize: "vertical",
}}
/>
<button type="submit" className="btn btn-primary">
{t("replySubmit")}
</button>
</form>
</ContentCard>
<ContentCard
icon="✅"
title={t("closeTitle")}
subtitle={t("closeSubtitle")}
>
<form action={closeHelpTicket}>
<input type="hidden" name="ticketId" value={String(ticket.id)} />
<button type="submit" className="btn btn-outline">
{t("closeSubmit")}
</button>
</form>
</ContentCard>
</>
) : (
<p className="muted" style={{ margin: 0 }}>
{t("closedHint")}
</p>
)}
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../friends/loading";
+7 -2
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -136,8 +137,12 @@ export default async function HelpTicketsPage({
<tbody>
{tickets.map((t2) => (
<tr key={String(t2.id)}>
<td>{String(t2.id)}</td>
<td>{t2.title}</td>
<td>
<Link href={`/help/tickets/${t2.id}`}>{String(t2.id)}</Link>
</td>
<td>
<Link href={`/help/tickets/${t2.id}`}>{t2.title}</Link>
</td>
<td>
<span className={`online-badge${t2.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
+6 -3
View File
@@ -5,17 +5,20 @@ import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*/
export default function SiteLayout({ children }: { children: ReactNode }) {
export default async function SiteLayout({ children }: { children: ReactNode }) {
const session = await auth();
return (
<>
<TopHeader />
<TopHeader session={session} />
<SiteHeader />
<Navigation />
<Navigation session={session} />
<main>
<div className="mx-auto mt-6 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-6 px-4 py-4 md:mt-0 md:p-6">
<div className="col-span-12 min-w-0">
+104 -6
View File
@@ -2,7 +2,11 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { acceptFriend, declineFriendRequest } from "@/actions/messenger";
import {
acceptFriend,
declineFriendRequest,
sendOfflineMessage,
} from "@/actions/messenger";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
@@ -14,7 +18,9 @@ export const dynamic = "force-dynamic";
type SearchParams = Promise<{
accepted?: string;
declined?: string;
sent?: string;
error?: string;
send_error?: string;
}>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
@@ -49,10 +55,8 @@ export default async function MessagesPage({
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
const { accepted, declined, error } = await searchParams;
const { accepted, declined, sent, error, send_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.
let requests: { id: number; userFromId: number }[] = [];
let messages: {
id: number;
@@ -60,10 +64,11 @@ export default async function MessagesPage({
message: string;
sendedOn: number;
}[] = [];
let friendships: { userOneId: number; userTwoId: number }[] = [];
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try {
const [reqRows, msgRows, base] = await Promise.all([
const [reqRows, msgRows, friendRows, base] = await Promise.all([
prisma.messengerFriendrequests.findMany({
where: { userToId: userId },
select: { id: true, userFromId: true },
@@ -76,6 +81,12 @@ export default async function MessagesPage({
orderBy: { sendedOn: "desc" },
take: 50,
}),
prisma.messengerFriendships.findMany({
where: {
OR: [{ userOneId: userId }, { userTwoId: userId }],
},
select: { userOneId: true, userTwoId: true },
}),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
@@ -83,17 +94,27 @@ export default async function MessagesPage({
]);
requests = reqRows;
messages = msgRows;
friendships = friendRows;
if (base) imagerBase = base;
} catch {
requests = [];
messages = [];
friendships = [];
}
// Resolve usernames/looks for every counterparty referenced above.
const friendIds = Array.from(
new Set(
friendships.map((f) =>
f.userOneId === userId ? f.userTwoId : f.userOneId,
),
),
).filter((id) => id > 0 && id !== userId);
const otherIds = Array.from(
new Set([
...requests.map((r) => r.userFromId),
...messages.map((m) => m.userFromId),
...friendIds,
]),
).filter((id) => id && id > 0);
@@ -113,6 +134,13 @@ export default async function MessagesPage({
}
}
const friendsForSelect = friendIds
.map((id) => userById.get(id))
.filter((u): u is { id: number; username: string; look: string } =>
Boolean(u),
)
.sort((a, b) => a.username.localeCompare(b.username));
const errorMessage =
error === "not_found"
? t("errors.notFound")
@@ -124,6 +152,19 @@ export default async function MessagesPage({
? t("errors.error")
: null;
const sendErrorMessage =
send_error === "not_friend"
? t("errors.notFriend")
: send_error === "empty"
? t("errors.emptyMessage")
: send_error === "rate_limited"
? t("errors.rateLimited")
: send_error === "invalid"
? t("errors.invalid")
: send_error
? t("errors.error")
: null;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{accepted === "1" ? (
@@ -136,14 +177,71 @@ export default async function MessagesPage({
{t("success.declined")}
</div>
) : null}
{sent === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.sent")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
{sendErrorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{sendErrorMessage}
</div>
) : null}
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
<ContentCard
icon="📤"
title={t("composeTitle")}
subtitle={t("composeSubtitle")}
padded={friendsForSelect.length === 0}
>
{friendsForSelect.length === 0 ? (
<EmptyState icon="👥">{t("composeEmptyFriends")}</EmptyState>
) : (
<form
action={sendOfflineMessage}
className="grid"
style={{ padding: "1rem", gap: "0.75rem", maxWidth: "36rem" }}
>
<label className="grid" style={{ gap: "0.35rem" }}>
<span className="muted">{t("composeFriendLabel")}</span>
<select name="friendId" required defaultValue="">
<option value="" disabled>
{t("composeFriendPlaceholder")}
</option>
{friendsForSelect.map((friend) => (
<option key={friend.id} value={String(friend.id)}>
{friend.username}
</option>
))}
</select>
</label>
<label className="grid" style={{ gap: "0.35rem" }}>
<span className="muted">{t("composeMessageLabel")}</span>
<textarea
name="message"
required
maxLength={500}
rows={4}
placeholder={t("composeMessagePlaceholder")}
style={{ resize: "vertical" }}
/>
</label>
<div>
<button type="submit" className="btn btn-secondary">
{t("composeSend")}
</button>
</div>
</form>
)}
</ContentCard>
{/* ── Pending friend requests ───────────────────────────── */}
<ContentCard
icon="🙋"
+8 -2
View File
@@ -16,7 +16,7 @@ import { sanitize } from "@/lib/sanitize";
export const revalidate = 60;
type SearchParams = Promise<{ comment?: string; error?: string }>;
type SearchParams = Promise<{ comment?: string; reaction?: string; error?: string }>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
@@ -87,7 +87,7 @@ export default async function ArticlePage({
searchParams: SearchParams;
}) {
const { slug } = await params;
const { comment, error } = await searchParams;
const { comment, reaction, error } = await searchParams;
const t = await getTranslations("pages.article");
const article = await cacheQuery(
@@ -189,6 +189,11 @@ export default async function ArticlePage({
{t("success.posted")}
</div>
) : null}
{reaction === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.reaction")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
@@ -240,6 +245,7 @@ export default async function ArticlePage({
name="articleId"
value={String(articleId)}
/>
<input type="hidden" name="slug" value={article.slug} />
<input type="hidden" name="reaction" value={r.key} />
<button
type="submit"
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
+43 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyDj } from "@/actions/radio-apply";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -7,11 +8,41 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function RadioApplyPage() {
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 RadioApplyPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply");
const { submitted, error } = await searchParams;
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
@@ -31,6 +62,17 @@ export default async function RadioApplyPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
{existing ? (
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
+46 -1
View File
@@ -1,4 +1,5 @@
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { formatDate } from "@/lib/format-date";
import { submitRequest } from "@/actions/radio-requests";
import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -7,10 +8,43 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type SearchParams = Promise<{ posted?: string; error?: string }>;
export default async function RadioRequestsPage() {
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 RadioRequestsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const t = await getTranslations("pages.radioRequests");
const genericT = await getTranslations("pages.radio");
const { posted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const [requests, session] = await Promise.all([
prisma.radioSongRequests
@@ -37,6 +71,17 @@ export default async function RadioRequestsPage() {
return (
<>
{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={genericT("linkRequests")}
+58 -22
View File
@@ -1,18 +1,21 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { signOutEverywhere } from "@/actions/sessions";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const metadata = { title: "Login activity" };
type SearchParams = Promise<{ signedOutAll?: string }>;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
function device(ua: string | null): string {
if (!ua) return "Unknown device";
function deviceLabel(ua: string | null, unknown: string): string {
if (!ua) return unknown;
const browser = /Edg/.test(ua)
? "Edge"
: /OPR|Opera/.test(ua)
@@ -38,10 +41,21 @@ function device(ua: string | null): string {
return os ? `${browser} on ${os}` : browser;
}
export default async function SessionsPage() {
export async function generateMetadata() {
const t = await getTranslations("pages.sessions");
return { title: t("title") };
}
export default async function SessionsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const t = await getTranslations("pages.sessions");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
const { signedOutAll } = await searchParams;
const logins = await prisma.websiteLoginLogs
.findMany({
@@ -52,35 +66,57 @@ export default async function SessionsPage() {
})
.catch(() => []);
// Failed attempts on this username (failed_logins is keyless → raw query).
type Failed = { ip_address: string; attempted_at: Date | string };
type Failed = { id: number; ip_address: string; attempted_at: Date | string };
const failed = await prisma.$queryRaw<Failed[]>`
SELECT ip_address, attempted_at FROM failed_logins
SELECT id, ip_address, attempted_at FROM failed_logins
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
`.catch(() => [] as Failed[]);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{signedOutAll === "1" ? (
<p
role="status"
style={{
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-primary)",
borderLeft: "4px solid var(--color-primary)",
}}
>
{t("successSignedOutAll")}
</p>
) : null}
<ContentCard
icon="🔐"
title="Login activity"
subtitle="Recent sign-ins and failed attempts on your account"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard
icon="✅"
title="Recent sign-ins"
padded={logins.length === 0}
icon="🚪"
title={t("signOutEverywhereTitle")}
subtitle={t("signOutEverywhereDescription")}
>
<form action={signOutEverywhere} style={{ padding: "1rem" }}>
<button type="submit" className="btn btn-danger">
{t("signOutEverywhereButton")}
</button>
</form>
</ContentCard>
<ContentCard icon="✅" title={t("recentTitle")} padded={logins.length === 0}>
{logins.length === 0 ? (
<EmptyState icon="🔐">No sign-ins recorded yet.</EmptyState>
<EmptyState icon="🔐">{t("recentEmpty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>When</th>
<th>IP</th>
<th>Device</th>
<th>{t("colWhen")}</th>
<th>{t("colIp")}</th>
<th>{t("colDevice")}</th>
</tr>
</thead>
<tbody>
@@ -90,7 +126,7 @@ export default async function SessionsPage() {
{fromDate(l.createdAt)}
</td>
<td className="muted">{l.ip}</td>
<td>{device(l.userAgent)}</td>
<td>{deviceLabel(l.userAgent, t("unknownDevice"))}</td>
</tr>
))}
</tbody>
@@ -101,19 +137,19 @@ export default async function SessionsPage() {
{failed.length > 0 ? (
<ContentCard
icon="⚠️"
title="Failed attempts"
subtitle="Wrong password on your username"
title={t("failedTitle")}
subtitle={t("failedSubtitle")}
>
<table>
<thead>
<tr>
<th>When</th>
<th>IP</th>
<th>{t("colWhen")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{failed.map((f) => (
<tr key={f.ip_address}>
<tr key={f.id}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{typeof f.attempted_at === "string"
? f.attempted_at.slice(0, 16).replace("T", " ")
+15
View File
@@ -0,0 +1,15 @@
export default function ShopLoading() {
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>
);
}
+93 -7
View File
@@ -1,13 +1,33 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { buyShopArticle } from "@/actions/shop";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { creditsPerUnit } from "@/lib/services/paypal";
export const revalidate = 300;
export const metadata = { title: "Shop" };
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}`,
};
}
// Faithful to AtomCMS WebsiteShopArticle::price(): costs are stored in cents,
// the displayed price is costs/100 with a floor of 1.
function priceLabel(costs: number): string {
@@ -15,6 +35,13 @@ function priceLabel(costs: number): string {
return `$${price.toFixed(2)}`;
}
function creditPriceLabel(costs: number): string {
const rate = creditsPerUnit();
const dollars = costs < 100 ? 1 : costs / 100;
const credits = Math.max(1, Math.floor(dollars * rate));
return credits.toLocaleString();
}
type ShopArticle = {
id: bigint;
name: string;
@@ -52,13 +79,34 @@ const ARTICLE_SELECT = {
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
searchParams: Promise<{ category?: string; bought?: string; error?: string; package?: string }>;
}) {
const t = await getTranslations("pages.shop");
const sp = await searchParams;
const rawCategory = (sp.category ?? "").trim();
const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : "";
const session = await auth();
const isLoggedIn = Boolean(session?.user?.id);
const errorMessage =
sp.error === "invalid"
? t("errors.invalid")
: sp.error === "credits"
? t("errors.credits")
: sp.error === "ratelimit"
? t("errors.ratelimit")
: sp.error
? t("errors.error")
: null;
const boughtMessage =
sp.bought === "1"
? sp.package
? t("success.boughtNamed", { name: sp.package })
: t("success.bought")
: null;
// Categories for the filter bar.
let categories: ShopCategory[] = [];
try {
@@ -110,6 +158,23 @@ export default async function ShopPage({
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{boughtMessage ? (
<div role="status" style={feedbackStyle("success")}>
{boughtMessage}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
{sp.error === "credits" ? (
<>
{" "}
<Link href="/shop/topup">{t("topUpLink")}</Link>
</>
) : null}
</div>
) : null}
<ContentCard
icon="🛒"
title={t("title")}
@@ -119,6 +184,10 @@ export default async function ShopPage({
: t("subtitle")
}
>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
{t("creditsHint")}{" "}
<Link href="/shop/topup">{t("topUpLink")}</Link>
</p>
{categories.length > 0 ? (
<div
style={{
@@ -184,8 +253,6 @@ export default async function ShopPage({
}}
>
{a.iconUrl ? (
// Article icons are arbitrary external/Imgur URLs from the
// emulator data, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.iconUrl}
@@ -198,7 +265,12 @@ export default async function ShopPage({
</div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{a.name}</h3>
<span className="muted">{priceLabel(a.costs)}</span>
<span className="muted">
{t("priceCredits", {
credits: creditPriceLabel(a.costs),
usd: priceLabel(a.costs),
})}
</span>
</div>
</div>
@@ -240,10 +312,24 @@ export default async function ShopPage({
gap: "0.5rem",
}}
>
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true">
{t("buy")}
<span style={{ fontWeight: 800 }}>
{t("priceCreditsShort", {
credits: creditPriceLabel(a.costs),
})}
</span>
{isLoggedIn ? (
<form action={buyShopArticle} style={{ margin: 0 }}>
<input type="hidden" name="articleId" value={String(a.id)} />
<input type="hidden" name="categoryId" value={categoryId} />
<button type="submit" className="btn btn-primary">
{t("buy")}
</button>
</form>
) : (
<Link href="/login" className="btn btn-primary">
{t("loginToBuy")}
</Link>
)}
</div>
</article>
))}
+12 -11
View File
@@ -1,9 +1,11 @@
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { deleteAd, updateAd } from "@/actions/admin-ads";
import { getTranslations } from "next-intl/server";
import { updateAd } from "@/actions/admin-ads";
import { prisma } from "@/lib/prisma";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../ads-table";
export const dynamic = "force-dynamic";
@@ -26,6 +28,8 @@ export default async function EditAd({
}
if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return (
<main>
<div className="flex items-center gap-3 mb-6">
@@ -34,10 +38,10 @@ export default async function EditAd({
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
Edit advertisement
{t("edit")} #{String(ad.id)}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link>
<Link href="/admin/ads">← {t("title")}</Link>
</p>
</div>
</div>
@@ -46,20 +50,17 @@ export default async function EditAd({
<input
name="image"
defaultValue={ad.image}
placeholder="Image URL"
placeholder={t("form.imagePlaceholder")}
required
maxLength={255}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteAd} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<button type="submit" className="btn btn-danger">
Delete advertisement
{t("form.save")}
</button>
</form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { Trash2 } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { deleteAd } from "@/actions/admin-ads";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { useServerAction } from "@/hooks/use-server-action";
export type AdRow = {
id: string;
image: string;
createdAt: string | null;
};
export function AdsTable({ ads }: { ads: AdRow[] }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete(row: AdRow) {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id: row.id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
});
}
return (
<>
{confirmDialog}
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt ?? "—"}
</td>
<td>
<div className="flex items-center gap-2">
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
<button
type="button"
className="btn btn-danger btn-sm inline-flex items-center gap-1"
disabled={isPending}
onClick={() => handleDelete(ad)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("delete")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
export function EditAdDeleteButton({ id }: { id: string }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete() {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
redirectTo: "/admin/ads",
});
}
return (
<>
{confirmDialog}
<button
type="button"
className="btn btn-danger"
disabled={isPending}
onClick={handleDelete}
>
{t("form.deleteAd")}
</button>
</>
);
}
+10 -35
View File
@@ -4,6 +4,7 @@ import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdsTable } from "./ads-table";
export const dynamic = "force-dynamic";
@@ -51,41 +52,15 @@ export default async function AdminAds() {
{ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(ad.id)}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
<AdsTable
ads={ads.map((ad) => ({
id: String(ad.id),
image: ad.image,
createdAt: ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: null,
}))}
/>
)}
</section>
</main>