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

@@ -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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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
+10 -35
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> ? ad.createdAt.toISOString().slice(0, 10)
<th /> : null,
</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>
)} )}
</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;
} }
} }