Security (launch blockers): - src/middleware.ts (edge): forwards x-pathname + real client IP. - access-guard.ts (Node, from root layout): routes non-staff to /maintenance when maintenance mode is on, banned users to /banned. New /banned + /maintenance pages (the consumers the admin toggle was missing). Admin layout enforces force_staff_2fa before /admin. - staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions. Infra (parallel agents): alert service (alert_logs + Discord embed + email), PayPal top-up (create/capture API routes + /shop/topup), cron worker (scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check, bans-cleanup), social connections page, admin radio settings/banners/ranks. Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways, apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav link added. .env.example documents the new optional vars. (radio song-requests dropped: its table is a stub in AtomCMS — columns added by un-modeled alter-migrations.) Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import Link from "next/link";
|
|
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 dynamic = "force-dynamic";
|
|
|
|
// Sub-pages linked from the radio home hub.
|
|
const RADIO_LINKS: { label: string; href: string }[] = [
|
|
{ label: "Schedule", href: "/radio/schedule" },
|
|
{ label: "Shouts", href: "/radio/shouts" },
|
|
{ label: "Requests", href: "/radio/requests" },
|
|
{ label: "Apply as DJ", href: "/radio/apply" },
|
|
{ label: "Leaderboard", href: "/radio/leaderboard" },
|
|
];
|
|
|
|
// 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() {
|
|
// ── 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]));
|
|
|
|
return (
|
|
<main>
|
|
{/* ── Hero: live stream + now playing ─────────────────────── */}
|
|
<div className="hero">
|
|
<h1 style={{ marginTop: 0 }}>Radio</h1>
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
|
Tune in to the hotel radio — live DJ sets, requests and shoutouts.
|
|
</p>
|
|
|
|
<div className="grid cols-2" style={{ marginTop: "1rem" }}>
|
|
<div>
|
|
<p style={{ margin: "0 0 0.25rem" }}>
|
|
<strong>Now playing</strong>
|
|
</p>
|
|
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}>
|
|
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>}
|
|
</p>
|
|
<p className="muted" style={{ margin: 0 }}>
|
|
{currentDj ? `On air: ${currentDj}` : "No DJ on air."}
|
|
</p>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center" }}>
|
|
{streamUrl ? (
|
|
<audio
|
|
controls
|
|
src={streamUrl}
|
|
style={{ width: "100%" }}
|
|
preload="none"
|
|
>
|
|
Your browser does not support the audio element.
|
|
</audio>
|
|
) : (
|
|
<p className="muted" style={{ margin: 0 }}>
|
|
The stream is currently offline.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Sub-page links ──────────────────────────────────────── */}
|
|
<section style={{ marginBottom: "1.5rem" }}>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
|
|
{RADIO_LINKS.map((l) => (
|
|
<Link key={l.href} href={l.href} className="btn btn-outline">
|
|
{l.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Today's schedule ────────────────────────────────────── */}
|
|
<section>
|
|
<h2>Today’s schedule{today ? ` · ${today}` : ""}</h2>
|
|
{todaysSlots.length === 0 ? (
|
|
<p className="muted">No shows scheduled for today.</p>
|
|
) : (
|
|
<div className="card">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Show</th>
|
|
<th>DJ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{todaysSlots.map((s) => {
|
|
const djName = djById.get(Number(s.userId));
|
|
return (
|
|
<tr key={String(s.id)}>
|
|
<td>
|
|
{formatTime(s.startTime)} – {formatTime(s.endTime)}
|
|
</td>
|
|
<td>
|
|
{s.showName || <span className="muted">Untitled show</span>}
|
|
</td>
|
|
<td>
|
|
{djName ? (
|
|
<Link href={`/u/${djName}`}>{djName}</Link>
|
|
) : (
|
|
<span className="muted">{`User #${s.userId}`}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|