Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s

Public chrome and width live only under (site). /admin and /client inherit the root layout alone, so housekeeping width is no longer tied to the public template.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 21:12:52 +02:00
1 parent a1dc7ff0a7
commit 367f69a439
60 files changed
+29 -30

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { applyDj } from "@/actions/radio-apply";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function RadioApplyPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply");
const userId = Number(session.user.id);
// Surface the applicant's most recent application (if any) so they aren't
// left guessing. Isolated query: a DB hiccup degrades this to null, not 500.
const existing = await prisma.radioApplications
.findFirst({
where: { userId: BigInt(userId) },
orderBy: { id: "desc" },
select: { id: true, status: true, createdAt: true },
})
.catch(() => null);
const submittedDate = existing?.createdAt
? existing.createdAt.toISOString().slice(0, 10)
: null;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
{existing ? (
<ContentCard icon="📋" title={t("latestTitle")}>
<p style={{ margin: 0 }}>
<strong>{t("statusLabel")}</strong> {existing.status}
{submittedDate ? (
<span className="muted">
{" "}
{t("submittedOn", { date: submittedDate })}
</span>
) : null}
</p>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{t("submitAgain")}
</p>
</ContentCard>
) : null}
<ContentCard
icon="📝"
title={t("formTitle")}
subtitle={t("formSubtitle")}
>
<form action={applyDj}>
<div className="card-grid sm-2">
<div>
<label htmlFor="realName" className="muted">
{t("realNameLabel")}
</label>
<input
id="realName"
name="realName"
type="text"
required
maxLength={255}
placeholder={t("realNamePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="age" className="muted">
{t("ageLabel")}
</label>
<input
id="age"
name="age"
type="number"
required
min={13}
max={120}
placeholder="18"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted">
{t("availabilityLabel")}
</label>
<textarea
id="availability"
name="availability"
required
rows={3}
placeholder={t("availabilityPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="musicStyle" className="muted">
{t("musicStyleLabel")}
</label>
<input
id="musicStyle"
name="musicStyle"
type="text"
maxLength={255}
placeholder={t("musicStylePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="experience" className="muted">
{t("experienceLabel")}{" "}
<span className="muted">{t("optional")}</span>
</label>
<textarea
id="experience"
name="experience"
rows={3}
placeholder={t("experiencePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="motivation" className="muted">
{t("motivationLabel")}
</label>
<textarea
id="motivation"
name="motivation"
required
rows={4}
placeholder={t("motivationPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: "1.25rem" }}
>
{t("submitButton")}
</button>
</form>
</ContentCard>
</main>
);
}
@@ -0,0 +1,92 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
}
export default async function RadioContestDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
await getTranslations("pages.radioContests");
let contestId: bigint;
try {
contestId = BigInt(id);
} catch {
notFound();
}
const contest = await prisma.radioContests
.findUnique({ where: { id: contestId } })
.catch(() => null);
if (!contest) notFound();
const active = contest.isActive ? "Active" : "Ended";
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/radio/contests">← Back to contests</Link>
</p>
<ContentCard
icon="🎉"
title={contest.title || `Contest #${contest.id}`}
subtitle={active}
>
<table style={{ width: "100%" }}>
<tbody>
{contest.description ? (
<tr>
<th style={{ verticalAlign: "top", width: 120 }}>
Description
</th>
<td>{contest.description}</td>
</tr>
) : null}
{contest.prize ? (
<tr>
<th>Prize</th>
<td>{contest.prize}</td>
</tr>
) : null}
<tr>
<th>Period</th>
<td>
{contest.startDate ? formatDate(contest.startDate) : "—"}
{" — "}
{contest.endDate ? formatDate(contest.endDate) : "ongoing"}
</td>
</tr>
<tr>
<th>Created</th>
<td>
{formatDate(contest.createdAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
<tr>
<th>Updated</th>
<td>
{formatDate(contest.updatedAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
</tbody>
</table>
</ContentCard>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function RadioContestsPage() {
const t = await getTranslations("pages.radioContests");
const contests = await prisma.radioContests
.findMany({
where: { isActive: true },
orderBy: { startDate: "desc" },
take: 50,
select: {
id: true,
title: true,
prize: true,
startDate: true,
endDate: true,
},
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎉" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={contests.length === 0}>
{contests.length === 0 ? (
<EmptyState icon="🎉">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{contests.map((c) => {
const id = c.id.toString();
return (
<Link
key={id}
href={`/radio/contests/${id}`}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.3rem",
}}
>
<h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎉</span>{" "}
{c.title || t("contestLabel", { id })}
</h3>
{c.prize ? (
<p
className="muted"
style={{ margin: 0, fontSize: "0.9rem" }}
>
Prize: {c.prize}
</p>
) : null}
<p
className="muted"
style={{ margin: 0, fontSize: "0.85rem" }}
>
{c.startDate
? `${formatDate(c.startDate)} — ${c.endDate ? formatDate(c.endDate) : "ongoing"}`
: t("dateUnknown")}
</p>
</Link>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
@@ -0,0 +1,97 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
}
export default async function RadioGiveawayDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
await getTranslations("pages.radioGiveaways");
let giveawayId: bigint;
try {
giveawayId = BigInt(id);
} catch {
notFound();
}
const giveaway = await prisma.radioGiveaways
.findUnique({ where: { id: giveawayId } })
.catch(() => null);
if (!giveaway) notFound();
const active = giveaway.isActive ? "Active" : "Ended";
const prizeStr =
giveaway.prize ||
(giveaway.prizeAmount > 0
? `${giveaway.prizeAmount} ${giveaway.prizeCurrency || "credits"}`
: null);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/radio/giveaways">← Back to giveaways</Link>
</p>
<ContentCard
icon="🎁"
title={giveaway.title || `Giveaway #${giveaway.id}`}
subtitle={active}
>
<table style={{ width: "100%" }}>
<tbody>
{giveaway.description ? (
<tr>
<th style={{ verticalAlign: "top", width: 120 }}>
Description
</th>
<td>{giveaway.description}</td>
</tr>
) : null}
{prizeStr ? (
<tr>
<th>Prize</th>
<td>{prizeStr}</td>
</tr>
) : null}
<tr>
<th>Period</th>
<td>
{giveaway.startDate ? formatDate(giveaway.startDate) : "—"}
{" — "}
{giveaway.endDate ? formatDate(giveaway.endDate) : "ongoing"}
</td>
</tr>
<tr>
<th>Created</th>
<td>
{formatDate(giveaway.createdAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
<tr>
<th>Updated</th>
<td>
{formatDate(giveaway.updatedAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
</tbody>
</table>
</ContentCard>
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function RadioGiveawaysPage() {
const t = await getTranslations("pages.radioGiveaways");
const giveaways = await prisma.radioGiveaways
.findMany({
where: { isActive: true },
orderBy: { startDate: "desc" },
take: 50,
select: {
id: true,
title: true,
prize: true,
prizeAmount: true,
prizeCurrency: true,
startDate: true,
endDate: true,
},
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎁" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={giveaways.length === 0}>
{giveaways.length === 0 ? (
<EmptyState icon="🎁">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{giveaways.map((g) => {
const id = g.id.toString();
const prizeStr =
g.prize ||
(g.prizeAmount > 0
? `${g.prizeAmount} ${g.prizeCurrency || "credits"}`
: null);
return (
<Link
key={id}
href={`/radio/giveaways/${id}`}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.3rem",
}}
>
<h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎁</span>{" "}
{g.title || t("giveawayLabel", { id })}
</h3>
{prizeStr ? (
<p
className="muted"
style={{ margin: 0, fontSize: "0.9rem" }}
>
Prize: {prizeStr}
</p>
) : null}
<p
className="muted"
style={{ margin: 0, fontSize: "0.85rem" }}
>
{g.startDate
? `${formatDate(g.startDate)} — ${g.endDate ? formatDate(g.endDate) : "ongoing"}`
: t("dateUnknown")}
</p>
</Link>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
const RADIO_SIDEBAR_LINKS = [
{ key: "schedule", href: "/radio/schedule", icon: "🗓️" },
{ key: "shouts", href: "/radio/shouts", icon: "📣" },
{ key: "requests", href: "/radio/requests", icon: "🎵" },
{ key: "apply", href: "/radio/apply", icon: "🎤" },
{ key: "leaderboard", href: "/radio/leaderboard", icon: "🏆" },
] as const;
export default async function RadioLayout({
children,
}: {
children: ReactNode;
}) {
const t = await getTranslations("pages.radio");
return (
<div
className="radio-layout"
style={{ display: "flex", gap: "1.5rem", alignItems: "flex-start" }}
>
<aside
className="radio-sidebar"
style={{
flex: "none",
width: "200px",
position: "sticky",
top: "5rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
<Link
href="/radio"
className="radio-sidebar-head"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.55rem 0.75rem",
fontWeight: 700,
fontSize: "1rem",
color: "var(--color-text-readable, var(--color-text))",
textDecoration: "none",
borderBottom:
"1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
marginBottom: "0.25rem",
}}
>
<span aria-hidden style={{ fontSize: "1.2rem" }}>
📻
</span>
Radio
</Link>
{RADIO_SIDEBAR_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="radio-sidebar-link"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0.75rem",
borderRadius: "8px",
color: "var(--color-text-readable, var(--color-text))",
textDecoration: "none",
fontSize: "0.9rem",
transition: "background 0.15s",
}}
>
<span aria-hidden style={{ flex: "none", fontSize: "1rem" }}>
{link.icon}
</span>
{link.key === "schedule"
? t("linkSchedule")
: link.key === "shouts"
? t("linkShouts")
: link.key === "requests"
? t("linkRequests")
: link.key === "apply"
? t("linkApply")
: t("linkLeaderboard")}
</Link>
))}
</aside>
<section style={{ flex: 1, minWidth: 0 }}>{children}</section>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
// The radio_listener_points table in the live AtomCMS DB carries user_id +
// points columns, but the generated Prisma model is a stub (id/timestamps only),
// so it has no `points`/`userId` delegate fields. We therefore read it via
// $queryRaw, joining users for the username/look — the same approach used for
// the keyless/@@ignore'd emulator tables (see src/app/admin/logs). MySQL may
// return BigInt columns as bigint, so values are coerced defensively.
type LeaderRow = {
username: string;
look: string;
points: number | bigint;
};
async function loadRows(): Promise<
{ username: string; look: string; points: number }[]
> {
try {
const rows = await prisma.$queryRaw<LeaderRow[]>`
SELECT u.username AS username, u.look AS look, p.points AS points
FROM radio_listener_points p
INNER JOIN users u ON u.id = p.user_id
ORDER BY p.points DESC
LIMIT 50
`;
return rows.map((r) => ({
username: r.username,
look: r.look,
points: Number(r.points),
}));
} catch {
return [];
}
}
export default async function RadioLeaderboardPage() {
const t = await getTranslations("pages.radioLeaderboard");
const [rows, imagerBase] = await Promise.all([
loadRows(),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={rows.length === 0}>
{rows.length === 0 ? (
<EmptyState icon="🏆">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3.5rem" }}>{t("colRank")}</th>
<th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>{t("colListener")}</th>
<th style={{ textAlign: "right" }}>{t("colPoints")}</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
size: "s",
headOnly: true,
});
return (
<tr key={`${row.username}-${i}`}>
<td>
<RankBadge position={i + 1} />
</td>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${row.username} avatar`}
width={40}
height={40}
/>
</td>
<td>
<Link
href={`/u/${encodeURIComponent(row.username)}`}
style={{ fontWeight: 700 }}
>
{row.username}
</Link>
</td>
<td style={{ textAlign: "right", fontWeight: 800 }}>
{row.points.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+252
View File
@@ -0,0 +1,252 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Radio config + today's schedule are read live from the DB / settings table.
export const revalidate = 300;
// Canonical weekday order so today's slots come out chronologically and we can
// match the current day against whatever casing the `day` column stores.
const WEEKDAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// radio_schedules.start_time / end_time are stored as TIME and surface through
// Prisma as a Date with the time component on a 1970 epoch date — render just
// the HH:MM portion in UTC to avoid timezone drift.
function formatTime(d: Date | null | undefined): string {
if (!d) return "";
return d.toISOString().slice(11, 16);
}
export default async function RadioPage() {
const t = await getTranslations("pages.radio");
// Sub-pages linked from the radio home hub.
const RADIO_LINKS: {
label: string;
href: string;
icon: string;
text: string;
}[] = [
{
label: t("linkSchedule"),
href: "/radio/schedule",
icon: "🗓️",
text: t("linkScheduleText"),
},
{
label: t("linkShouts"),
href: "/radio/shouts",
icon: "📣",
text: t("linkShoutsText"),
},
{
label: t("linkRequests"),
href: "/radio/requests",
icon: "🎵",
text: t("linkRequestsText"),
},
{
label: t("linkApply"),
href: "/radio/apply",
icon: "🎤",
text: t("linkApplyText"),
},
{
label: t("linkLeaderboard"),
href: "/radio/leaderboard",
icon: "🏆",
text: t("linkLeaderboardText"),
},
];
// ── Radio config from website_settings (graceful fallbacks) ──
const [streamUrl, currentDj, currentSong] = await Promise.all([
siteSettings.get("radio_stream_url", ""),
siteSettings.get("radio_current_dj", ""),
siteSettings.get("radio_current_song", ""),
]);
// ── Today's schedule (isolated; a DB hiccup degrades to empty) ──
const today = WEEKDAYS[new Date().getUTCDay()] ?? "";
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
orderBy: { startTime: "asc" },
})
.catch(() => []);
const todaysSlots = schedules.filter(
(s) => s.day.toLowerCase() === today.toLowerCase(),
);
// Resolve DJ usernames for today's slots in one query. radio_schedules.user_id
// is an UnsignedBigInt while users.id is an Int, so narrow to Number for the
// lookup (DJ ids comfortably fit in a JS-safe integer).
const djIds = Array.from(new Set(todaysSlots.map((s) => Number(s.userId))));
const djs = djIds.length
? await prisma.user
.findMany({
where: { id: { in: djIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const djById = new Map(djs.map((d) => [d.id, d.username]));
const isLive = Boolean(streamUrl);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{/* ── Header: live stream + now playing ─────────────────────── */}
<ContentCard
icon="📻"
title={t("title")}
subtitle={t("subtitle")}
action={<OnlineBadge online={isLive} />}
>
<div className="card-grid sm-2" style={{ alignItems: "stretch" }}>
<div
className="card"
style={{ display: "grid", gap: "0.35rem", alignContent: "center" }}
>
<p
className="muted"
style={{
margin: 0,
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{t("nowPlaying")}
</p>
<p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}>
{currentSong ? (
currentSong
) : (
<span className="muted" style={{ fontWeight: 400 }}>
{t("nothingPlaying")}
</span>
)}
</p>
<p className="muted" style={{ margin: 0 }}>
{currentDj ? t("onAir", { dj: currentDj }) : t("noDj")}
</p>
</div>
<div
className="card"
style={{ display: "flex", alignItems: "center" }}
>
{streamUrl ? (
<audio
controls
src={streamUrl}
style={{ width: "100%" }}
preload="none"
>
{t("audioUnsupported")}
</audio>
) : (
<p className="muted" style={{ margin: 0 }}>
{t("streamOffline")}
</p>
)}
</div>
</div>
</ContentCard>
{/* ── Sub-page links ──────────────────────────────────────── */}
<ContentCard
icon="🎚️"
title={t("exploreTitle")}
subtitle={t("exploreSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{RADIO_LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.4rem",
}}
>
<p style={{ margin: 0, fontWeight: 700 }}>
<span aria-hidden>{l.icon}</span> {l.label}
</p>
<p className="muted" style={{ margin: 0 }}>
{l.text}
</p>
</Link>
))}
</div>
</ContentCard>
{/* ── Today's schedule ────────────────────────────────────── */}
<ContentCard
icon="🗓️"
title={today ? t("todayTitleWithDay", { day: today }) : t("todayTitle")}
subtitle={t("todaySubtitle")}
action={
<Link className="btn btn-outline" href="/radio/schedule">
{t("fullWeek")}
</Link>
}
padded={todaysSlots.length === 0}
>
{todaysSlots.length === 0 ? (
<EmptyState icon="📻">{t("noShowsToday")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colTime")}</th>
<th>{t("colShow")}</th>
<th>{t("colDj")}</th>
</tr>
</thead>
<tbody>
{todaysSlots.map((s) => {
const djName = djById.get(Number(s.userId));
return (
<tr key={String(s.id)}>
<td>
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
</td>
<td>
{s.showName || (
<span className="muted">{t("untitledShow")}</span>
)}
</td>
<td>
{djName ? (
<Link href={`/u/${djName}`}>{djName}</Link>
) : (
<span className="muted">
{t("userNumber", { id: String(s.userId) })}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { getTranslations } from "next-intl/server";
import { submitRequest } from "@/actions/radio-requests";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function RadioRequestsPage() {
const t = await getTranslations("pages.radioRequests");
const genericT = await getTranslations("pages.radio");
const [requests, session] = await Promise.all([
prisma.radioSongRequests
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []),
auth(),
]);
const isLoggedIn = Boolean(session?.user?.id);
const userIds = Array.from(new Set(requests.map((r) => Number(r.userId))));
const users = userIds.length
? await prisma.user
.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const userById = new Map(users.map((u) => [u.id, u.username]));
return (
<>
<ContentCard
icon="🎵"
title={genericT("linkRequests")}
subtitle={genericT("linkRequestsText")}
/>
{isLoggedIn ? (
<ContentCard
icon="✍️"
title={t("formTitle")}
subtitle={t("formSubtitle")}
>
<form action={submitRequest}>
<div className="card-grid sm-2">
<div>
<label htmlFor="songTitle" className="muted">
{t("songLabel")}
</label>
<input
id="songTitle"
name="songTitle"
type="text"
required
maxLength={255}
placeholder={t("songPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="artist" className="muted">
{t("artistLabel")}
</label>
<input
id="artist"
name="artist"
type="text"
maxLength={255}
placeholder={t("artistPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: "1rem" }}
>
{t("submitButton")}
</button>
</form>
</ContentCard>
) : (
<ContentCard padded>
<EmptyState icon="🔒">{t("loginToRequest")}</EmptyState>
</ContentCard>
)}
<ContentCard
icon="📋"
title={t("latestTitle")}
subtitle={t("latestSubtitle")}
padded={requests.length === 0}
>
{requests.length === 0 ? (
<EmptyState icon="🎵">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colSong")}</th>
<th>{t("colArtist")}</th>
<th>{t("colRequester")}</th>
<th>{t("colDate")}</th>
<th>{t("colStatus")}</th>
</tr>
</thead>
<tbody>
{requests.map((r) => {
const requester = userById.get(Number(r.userId));
const isPlayed = r.isPlayed;
const isApproved = r.isApproved;
let status = t("statusPending");
if (isPlayed) status = t("statusPlayed");
else if (isApproved) status = t("statusApproved");
return (
<tr key={String(r.id)}>
<td>{r.songTitle || <span className="muted">—</span>}</td>
<td>{r.artist || <span className="muted">—</span>}</td>
<td>
{requester ? (
<span>{requester}</span>
) : (
<span className="muted">#{String(r.userId)}</span>
)}
</td>
<td className="muted">
{r.submittedAt ? formatDate(r.submittedAt) : "—"}
</td>
<td>{status}</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
// Weekly order so the table reads Monday → Sunday regardless of insert order.
// The `day` column stores a free-form string; we key case-insensitively.
const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] as const;
// `start_time` / `end_time` are MySQL TIME columns; Prisma hydrates them as a
// Date on the 1970-01-01 epoch. Pull just HH:MM in UTC (Time has no zone).
function formatTime(d: Date | null | undefined): string {
if (!d) return "";
return d.toISOString().slice(11, 16);
}
type Slot = {
id: string;
dj: string;
showName: string | null;
description: string | null;
start: string;
end: string;
};
export default async function RadioSchedulePage() {
const t = await getTranslations("pages.radioSchedule");
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
orderBy: { startTime: "asc" },
})
.catch(() => []);
// Resolve DJ usernames. schedule.userId is BigInt; User.id is Int — convert.
const djIds = Array.from(new Set(schedules.map((s) => Number(s.userId))));
const djs = djIds.length
? await prisma.user
.findMany({
where: { id: { in: djIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const djById = new Map(djs.map((u) => [u.id, u.username]));
// Bucket slots by normalised day name.
const byDay = new Map<string, Slot[]>();
for (const day of DAYS) byDay.set(day, []);
for (const s of schedules) {
const key =
DAYS.find((d) => d.toLowerCase() === s.day.trim().toLowerCase()) ?? s.day;
const slot: Slot = {
id: String(s.id),
dj: djById.get(Number(s.userId)) ?? `User #${s.userId}`,
showName: s.showName,
description: s.description,
start: formatTime(s.startTime),
end: formatTime(s.endTime),
};
const bucket = byDay.get(key);
if (bucket) bucket.push(slot);
else byDay.set(key, [slot]);
}
const hasAny = schedules.length > 0;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🗓️" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={!hasAny}>
{!hasAny ? (
<EmptyState icon="🗓️">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colDay")}</th>
<th>{t("colTime")}</th>
<th>{t("colDj")}</th>
<th>{t("colShow")}</th>
</tr>
</thead>
<tbody>
{DAYS.map((day) => {
const slots = byDay.get(day) ?? [];
if (slots.length === 0) {
return (
<tr key={day}>
<td>
<strong>{day}</strong>
</td>
<td colSpan={3} className="muted">
{t("noShows")}
</td>
</tr>
);
}
return slots.map((slot, i) => (
<tr key={slot.id}>
<td>{i === 0 ? <strong>{day}</strong> : ""}</td>
<td>
{slot.start}
{slot.end ? ` – ${slot.end}` : ""}
</td>
<td>{slot.dj}</td>
<td>
{slot.showName || <span className="muted">—</span>}
{slot.description ? (
<div className="muted">{slot.description}</div>
) : null}
</td>
</tr>
));
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { getTranslations } from "next-intl/server";
import { postShout } from "@/actions/radio-shouts";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function RadioShoutsPage() {
const t = await getTranslations("pages.radioShouts");
const [shouts, imagerBase, session] = await Promise.all([
prisma.radioShouts
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
auth(),
]);
const isLoggedIn = Boolean(session?.user?.id);
// Resolve author usernames/looks. shout.userId is BigInt; User.id is Int.
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
const authors = authorIds.length
? await prisma.user
.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true, look: true },
})
.catch(() => [])
: [];
const authorById = new Map(authors.map((a) => [a.id, a]));
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} />
{isLoggedIn ? (
<ContentCard
icon="✍️"
title={t("postTitle")}
subtitle={t("postSubtitle")}
>
{/* The author is taken from the session inside the action, never from
this form — no user id is submitted. */}
<form action={postShout}>
<label htmlFor="shout-message" className="muted">
{t("messageLabel")}
</label>
<textarea
id="shout-message"
name="message"
required
maxLength={255}
rows={3}
placeholder={t("messagePlaceholder")}
style={{
width: "100%",
margin: "0.4rem 0 0.75rem",
resize: "vertical",
}}
/>
<button type="submit" className="btn btn-primary">
{t("shoutButton")}
</button>
</form>
</ContentCard>
) : (
<ContentCard padded>
<EmptyState icon="🔒">{t("loginToPost")}</EmptyState>
</ContentCard>
)}
<ContentCard
icon="💬"
title={t("latestTitle")}
subtitle={t("latestSubtitle")}
padded={shouts.length === 0}
>
{shouts.length === 0 ? (
<EmptyState icon="📣">{t("empty")}</EmptyState>
) : (
<div className="card-grid">
{shouts.map((s) => {
const author = authorById.get(Number(s.userId));
const authorAvatar = author
? avatarImageUrl(imagerBase ?? "", author.look, {
size: "s",
headOnly: true,
})
: null;
return (
<div
key={String(s.id)}
className="card"
style={{
display: "flex",
gap: "0.75rem",
alignItems: "flex-start",
}}
>
{authorAvatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
className="avatar"
src={authorAvatar}
alt={author?.username ?? "author"}
width={40}
height={40}
/>
) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
<span className="muted">{formatDate(s.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{s.message}</p>
</div>
</div>
);
})}
</div>
)}
</ContentCard>
</main>
);
}