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

No files matched your search

@@ -0,0 +1,4 @@
-- CMS JWT invalidation counter on users (stateless NextAuth sessions).
-- Idempotent (MariaDB).
ALTER TABLE users
ADD COLUMN IF NOT EXISTS website_jwt_version INT NOT NULL DEFAULT 0;
+2
View File
@@ -64,6 +64,8 @@ model User {
twoFactorSecret String? @map("two_factor_secret") @db.Text twoFactorSecret String? @map("two_factor_secret") @db.Text
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0) twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
// Bumped to invalidate all CMS JWT sessions ("sign out everywhere").
websiteJwtVersion Int @default(0) @map("website_jwt_version")
createdTickets WebsiteTicket[] @relation("ticketCreator") createdTickets WebsiteTicket[] @relation("ticketCreator")
assignedTickets WebsiteTicket[] @relation("ticketAssignee") assignedTickets WebsiteTicket[] @relation("ticketAssignee")
+33 -2
View File
@@ -2,10 +2,13 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for website advertisements (website_ads). Emulator does not own this // CRUD for website advertisements (website_ads). Emulator does not own this
@@ -70,7 +73,35 @@ export async function updateAd(formData: FormData): Promise<void> {
redirect("/admin/ads"); redirect("/admin/ads");
} }
export async function deleteAd(formData: FormData): Promise<void> { const deleteAdInput = z.object({
id: z
.union([z.string(), z.number(), z.bigint()])
.transform((v) => BigInt(String(v))),
});
export const deleteAd = adminAction(
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
async (ctx) => {
const id = ctx.data.id;
try {
await prisma.websiteAds.delete({ where: { id } });
} catch {
throw new ActionError("Advertisement not found");
}
await logStaffActivity({
staffId: Number(ctx.session.user.id),
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
revalidatePath("/admin/ads");
return actionOk();
},
);
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
export async function deleteAdForm(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise<void> {
.trim(); .trim();
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid"); if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
let outcome: "bought" | "invalid" | "credits" | "fail"; let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
let boughtCode = ""; let boughtCode = "";
try { try {
await clientIp();
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const badge = await prisma.websiteDrawbadges.findUnique({ const badge = await prisma.websiteDrawbadges.findUnique({
where: { id: BigInt(rawId) }, where: { id: BigInt(rawId) },
select: { id: true, badgePath: true, published: true }, select: { id: true, badgePath: true, published: true },
@@ -115,6 +120,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
} }
} }
} }
}
} catch { } catch {
outcome = "fail"; outcome = "fail";
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+43 -14
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// Column bounds from prisma/schema.prisma (radio_applications): // Column bounds from prisma/schema.prisma (radio_applications):
// real_name VARCHAR(255); the rest are TEXT. age is an INT. // real_name VARCHAR(255); the rest are TEXT. age is an INT.
@@ -10,6 +12,27 @@ const NAME_MAX = 255;
const TEXT_MAX = 5000; const TEXT_MAX = 5000;
const STYLE_MAX = 5000; const STYLE_MAX = 5000;
type ApplyOutcome =
| "submitted"
| "invalid"
| "ratelimit"
| "error";
function applyRedirect(outcome: ApplyOutcome): never {
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
redirect(`/radio/apply?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
function str(form: FormData, key: string, max: number): string { function str(form: FormData, key: string, max: number): string {
return String(form.get(key) ?? "") return String(form.get(key) ?? "")
.normalize("NFC") .normalize("NFC")
@@ -19,17 +42,21 @@ function str(form: FormData, key: string, max: number): string {
/** /**
* Submit a radio DJ application. * Submit a radio DJ application.
*
* The applicant (userId) is ALWAYS re-read from the session via auth() and is
* never taken from the submitted FormData, so a crafted form cannot file an
* application on behalf of another account. radio_applications.user_id is an
* UnsignedBigInt, hence the BigInt() coercion.
*/ */
export async function applyDj(formData: FormData): Promise<void> { export async function applyDj(formData: FormData): Promise<void> {
let outcome: ApplyOutcome = "error";
try {
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`radio-apply:${userId}`, 2, 300_000)).ok) {
outcome = "ratelimit";
} else {
const realName = str(formData, "realName", NAME_MAX); const realName = str(formData, "realName", NAME_MAX);
const availability = str(formData, "availability", TEXT_MAX); const availability = str(formData, "availability", TEXT_MAX);
const motivation = str(formData, "motivation", TEXT_MAX); const motivation = str(formData, "motivation", TEXT_MAX);
@@ -39,12 +66,10 @@ export async function applyDj(formData: FormData): Promise<void> {
const ageRaw = Number(formData.get("age")); const ageRaw = Number(formData.get("age"));
const age = Number.isInteger(ageRaw) ? ageRaw : 0; const age = Number.isInteger(ageRaw) ? ageRaw : 0;
// Required fields per the schema (NOT NULL): real_name, age, availability, if (!realName || !availability || !motivation || age <= 0) {
// motivation. experience + music_style are nullable. outcome = "invalid";
if (!realName || !availability || !motivation || age <= 0) return; } else {
const now = new Date(); const now = new Date();
try {
await prisma.radioApplications.create({ await prisma.radioApplications.create({
data: { data: {
userId: BigInt(userId), userId: BigInt(userId),
@@ -59,10 +84,14 @@ export async function applyDj(formData: FormData): Promise<void> {
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { outcome = "submitted";
// DB unavailable or duplicate — fail soft; nothing to persist. }
return; }
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
} }
revalidatePath("/radio/apply"); revalidatePath("/radio/apply");
applyRedirect(outcome);
} }
+44 -6
View File
@@ -1,17 +1,50 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
const SONG_MAX = 255; const SONG_MAX = 255;
const ARTIST_MAX = 255; const ARTIST_MAX = 255;
type RequestOutcome =
| "posted"
| "empty"
| "invalid"
| "ratelimit"
| "error";
function requestsRedirect(outcome: RequestOutcome): never {
if (outcome === "posted") redirect("/radio/requests?posted=1");
redirect(`/radio/requests?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
export async function submitRequest(formData: FormData): Promise<void> { export async function submitRequest(formData: FormData): Promise<void> {
let outcome: RequestOutcome = "error";
try {
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`radio-req:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const songTitle = String(formData.get("songTitle") ?? "") const songTitle = String(formData.get("songTitle") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -20,10 +53,10 @@ export async function submitRequest(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, ARTIST_MAX); .slice(0, ARTIST_MAX);
if (!songTitle && !artist) return; if (!songTitle && !artist) {
outcome = "empty";
} else {
const now = new Date(); const now = new Date();
try {
await prisma.radioSongRequests.create({ await prisma.radioSongRequests.create({
data: { data: {
userId: BigInt(userId), userId: BigInt(userId),
@@ -34,9 +67,14 @@ export async function submitRequest(formData: FormData): Promise<void> {
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { outcome = "posted";
return; }
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
} }
revalidatePath("/radio/requests"); revalidatePath("/radio/requests");
requestsRedirect(outcome);
} }
+28
View File
@@ -0,0 +1,28 @@
"use server";
import { auth, signOut } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
/**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
* then end the current browser session too.
*/
export async function signOutEverywhere(): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
await signOut({ redirectTo: "/login" });
return;
}
try {
await prisma.user.update({
where: { id: userId },
data: { websiteJwtVersion: { increment: 1 } },
});
} catch {
/* still sign out locally */
}
await signOut({ redirectTo: "/login?signedOutAll=1" });
}
+196
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -3
View File
@@ -17,7 +17,12 @@ import {
// ── User actions (authenticated, no admin perms needed) ────────────── // ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction( export const createTicket = authAction(
{ schema: createTicketSchema }, {
schema: createTicketSchema,
rateLimitKey: "ticket-create",
rateLimitMax: 5,
rateLimitWindowMs: 60_000,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.create({ const ticket = await prisma.websiteTicket.create({
data: { data: {
@@ -49,7 +54,12 @@ export const createTicket = authAction(
); );
export const userReplyTicket = authAction( export const userReplyTicket = authAction(
{ schema: replyTicketSchema }, {
schema: replyTicketSchema,
rateLimitKey: "ticket-reply",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
); );
export const closeTicketByUser = authAction( export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) }, {
schema: replyTicketSchema.pick({ ticketId: true }),
rateLimitKey: "ticket-close",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
+9
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency"; import { sendCurrency } from "@/lib/services/send-currency";
@@ -40,6 +41,14 @@ export async function redeem(
}; };
} }
await clientIp();
if (!(await rateLimit(`voucher-redeem:${userId}`, 5, 60_000)).ok) {
return {
ok: false,
message: "You're redeeming too fast. Please wait a moment and try again.",
};
}
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
+47 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyStaff } from "@/actions/applications"; import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; 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). // Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; 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(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyStaff"); 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 userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -53,6 +88,17 @@ export default async function ApplyStaffPage() {
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <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 <ContentCard
icon="🛡️" icon="🛡️"
title={t("title", { hotel: hotelName })} title={t("title", { hotel: hotelName })}
+47 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyTeam } from "@/actions/applications"; import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; 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). // Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; 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(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyTeam"); 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 userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <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 <ContentCard
icon="🤝" icon="🤝"
title={t("title", { hotel: hotelName })} title={t("title", { hotel: hotelName })}
+2
View File
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
const ERROR_NOTE: Record<string, string> = { const ERROR_NOTE: Record<string, string> = {
invalid: "That badge is no longer available.", invalid: "That badge is no longer available.",
credits: "You don't have enough credits to buy this badge.", 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.", fail: "Something went wrong. Please try again.",
}; };
function getErrorNote(error: string): string { function getErrorNote(error: string): string {
if (error === "invalid") return ERROR_NOTE.invalid; if (error === "invalid") return ERROR_NOTE.invalid;
if (error === "credits") return ERROR_NOTE.credits; if (error === "credits") return ERROR_NOTE.credits;
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
return ERROR_NOTE.fail; return ERROR_NOTE.fail;
} }
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
File diff suppressed because it is too large. Load diff
@@ -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 ( return (
<tr key={t2.id}> <tr key={t2.id}>
<td style={{ padding: "0.6rem 0.75rem" }}> <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")} {t2.subject?.trim() || t("noSubject")}
</span> </Link>
{t2.pinned ? ( {t2.pinned ? (
<span <span
className="online-badge online" className="online-badge online"
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
File diff suppressed because it is too large. Load diff
+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 { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
@@ -136,8 +137,12 @@ export default async function HelpTicketsPage({
<tbody> <tbody>
{tickets.map((t2) => ( {tickets.map((t2) => (
<tr key={String(t2.id)}> <tr key={String(t2.id)}>
<td>{String(t2.id)}</td> <td>
<td>{t2.title}</td> <Link href={`/help/tickets/${t2.id}`}>{String(t2.id)}</Link>
</td>
<td>
<Link href={`/help/tickets/${t2.id}`}>{t2.title}</Link>
</td>
<td> <td>
<span className={`online-badge${t2.open ? " online" : ""}`}> <span className={`online-badge${t2.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden /> <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 { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header"; import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
/** /**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`, * Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid. * 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 ( return (
<> <>
<TopHeader /> <TopHeader session={session} />
<SiteHeader /> <SiteHeader />
<Navigation /> <Navigation session={session} />
<main> <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="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"> <div className="col-span-12 min-w-0">
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -16,7 +16,7 @@ import { sanitize } from "@/lib/sanitize";
export const revalidate = 60; 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 { function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent = const accent =
@@ -87,7 +87,7 @@ export default async function ArticlePage({
searchParams: SearchParams; searchParams: SearchParams;
}) { }) {
const { slug } = await params; const { slug } = await params;
const { comment, error } = await searchParams; const { comment, reaction, error } = await searchParams;
const t = await getTranslations("pages.article"); const t = await getTranslations("pages.article");
const article = await cacheQuery( const article = await cacheQuery(
@@ -189,6 +189,11 @@ export default async function ArticlePage({
{t("success.posted")} {t("success.posted")}
</div> </div>
) : null} ) : null}
{reaction === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.reaction")}
</div>
) : null}
{errorMessage ? ( {errorMessage ? (
<div role="alert" style={feedbackStyle("error")}> <div role="alert" style={feedbackStyle("error")}>
{errorMessage} {errorMessage}
@@ -240,6 +245,7 @@ export default async function ArticlePage({
name="articleId" name="articleId"
value={String(articleId)} value={String(articleId)}
/> />
<input type="hidden" name="slug" value={article.slug} />
<input type="hidden" name="reaction" value={r.key} /> <input type="hidden" name="reaction" value={r.key} />
<button <button
type="submit" 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 { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyDj } from "@/actions/radio-apply"; import { applyDj } from "@/actions/radio-apply";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -7,11 +8,41 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; 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(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply"); 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); const userId = Number(session.user.id);
@@ -31,6 +62,17 @@ export default async function RadioApplyPage() {
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <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")} /> <ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
{existing ? ( {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 { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { submitRequest } from "@/actions/radio-requests"; import { submitRequest } from "@/actions/radio-requests";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -7,10 +8,43 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; 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 t = await getTranslations("pages.radioRequests");
const genericT = await getTranslations("pages.radio"); 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([ const [requests, session] = await Promise.all([
prisma.radioSongRequests prisma.radioSongRequests
@@ -37,6 +71,17 @@ export default async function RadioRequestsPage() {
return ( 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 <ContentCard
icon="🎵" icon="🎵"
title={genericT("linkRequests")} title={genericT("linkRequests")}
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
+12 -11
View File
@@ -1,9 +1,11 @@
import { Megaphone } from "lucide-react"; import { Megaphone } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; 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 { prisma } from "@/lib/prisma";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../ads-table";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,6 +28,8 @@ export default async function EditAd({
} }
if (!ad) notFound(); if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return ( return (
<main> <main>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -34,10 +38,10 @@ export default async function EditAd({
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]"> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
Edit advertisement {t("edit")} #{String(ad.id)}
</h1> </h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5"> <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> </p>
</div> </div>
</div> </div>
@@ -46,20 +50,17 @@ export default async function EditAd({
<input <input
name="image" name="image"
defaultValue={ad.image} defaultValue={ad.image}
placeholder="Image URL" placeholder={t("form.imagePlaceholder")}
required required
maxLength={255} maxLength={255}
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save {t("form.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
</button> </button>
</form> </form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main> </main>
); );
} }
File diff suppressed because it is too large. Load diff
+9 -34
View File
@@ -4,6 +4,7 @@ import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdsTable } from "./ads-table";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -51,41 +52,15 @@ export default async function AdminAds() {
{ads.length === 0 ? ( {ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div> <div className="admin-empty">{t("noAds")}</div>
) : ( ) : (
<table> <AdsTable
<thead> ads={ads.map((ad) => ({
<tr> id: String(ad.id),
<th>{t("colPreview")}</th> image: ad.image,
<th>{t("colImage")}</th> createdAt: ad.createdAt
<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) ? ad.createdAt.toISOString().slice(0, 10)
: "—"} : null,
</td> }))}
<td> />
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
)} )}
</section> </section>
</main> </main>
+2 -3
View File
@@ -1,15 +1,14 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { Session } from "next-auth";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { MobileNav } from "@/components/mobile-nav"; import { MobileNav } from "@/components/mobile-nav";
import { NavDropdown } from "@/components/nav-dropdown"; import { NavDropdown } from "@/components/nav-dropdown";
import { NavbarColorPicker } from "@/components/navbar-color-picker"; import { NavbarColorPicker } from "@/components/navbar-color-picker";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { auth } from "@/lib/auth";
export async function Navigation() { export async function Navigation({ session }: { session: Session | null }) {
const session = await auth();
const t = await getTranslations("nav"); const t = await getTranslations("nav");
const isStaff = (session?.user?.rank ?? 0) >= 7; const isStaff = (session?.user?.rank ?? 0) >= 7;
+1 -3
View File
@@ -1,15 +1,13 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache"; import { cached } from "@/lib/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() { export async function SiteHeader() {
const t = await getTranslations("header"); const t = await getTranslations("header");
const [_session, hotelName, header, logo] = await Promise.all([ const [hotelName, header, logo] = await Promise.all([
auth(),
siteSettings.get("hotel_name", "Atom"), siteSettings.get("hotel_name", "Atom"),
siteSettings.get("cms_header", "/assets/images/background.png"), siteSettings.get("cms_header", "/assets/images/background.png"),
siteSettings.get("cms_logo", ""), siteSettings.get("cms_logo", ""),
+3 -3
View File
@@ -1,6 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { auth, signOut } from "@/lib/auth"; import type { Session } from "next-auth";
import { signOut } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -37,8 +38,7 @@ function Currency({
); );
} }
export async function TopHeader() { export async function TopHeader({ session }: { session: Session | null }) {
const session = await auth();
if (!session?.user?.id) return null; if (!session?.user?.id) return null;
const id = Number(session.user.id); const id = Number(session.user.id);
+56 -4
View File
File diff suppressed because it is too large. Load diff
+156 -10
View File
File diff suppressed because it is too large. Load diff
+174 -27
View File
File diff suppressed because it is too large. Load diff
+156 -9
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -9,5 +9,7 @@ declare module "next-auth" {
declare module "next-auth/jwt" { declare module "next-auth/jwt" {
interface JWT { interface JWT {
rank?: number; rank?: number;
jwtVersion?: number;
invalid?: boolean;
} }
} }