Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

+2 -8
View File
@@ -25,17 +25,11 @@ export default async function RadioApplyPage() {
})
.catch(() => null);
const submittedDate = existing?.createdAt
? existing.createdAt.toISOString().slice(0, 10)
: 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")}
/>
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
{existing ? (
<ContentCard icon="📋" title={t("latestTitle")}>
+2 -8
View File
@@ -10,11 +10,7 @@ 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 }>;
}) {
export default async function RadioContestDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await getTranslations("pages.radioContests");
@@ -25,9 +21,7 @@ export default async function RadioContestDetailPage({
notFound();
}
const contest = await prisma.radioContests
.findUnique({ where: { id: contestId } })
.catch(() => null);
const contest = await prisma.radioContests.findUnique({ where: { id: contestId } }).catch(() => null);
if (!contest) notFound();
+4 -6
View File
@@ -23,11 +23,7 @@ export default async function RadioContestsPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎉"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard icon="🎉" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={contests.length === 0}>
{contests.length === 0 ? (
@@ -52,7 +48,9 @@ export default async function RadioContestsPage() {
</p>
) : null}
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{c.startDate ? `${formatDate(c.startDate)} — ${c.endDate ? formatDate(c.endDate) : "ongoing"}` : t("dateUnknown")}
{c.startDate
? `${formatDate(c.startDate)} — ${c.endDate ? formatDate(c.endDate) : "ongoing"}`
: t("dateUnknown")}
</p>
</Link>
);
+5 -9
View File
@@ -10,11 +10,7 @@ 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 }>;
}) {
export default async function RadioGiveawayDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await getTranslations("pages.radioGiveaways");
@@ -25,14 +21,14 @@ export default async function RadioGiveawayDetailPage({
notFound();
}
const giveaway = await prisma.radioGiveaways
.findUnique({ where: { id: giveawayId } })
.catch(() => null);
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);
const prizeStr =
giveaway.prize ||
(giveaway.prizeAmount > 0 ? `${giveaway.prizeAmount} ${giveaway.prizeCurrency || "credits"}` : null);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
+15 -8
View File
@@ -17,17 +17,21 @@ export default async function RadioGiveawaysPage() {
where: { isActive: true },
orderBy: { startDate: "desc" },
take: 50,
select: { id: true, title: true, prize: true, prizeAmount: true, prizeCurrency: true, startDate: true, endDate: true },
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 icon="🎁" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={giveaways.length === 0}>
{giveaways.length === 0 ? (
@@ -36,7 +40,8 @@ export default async function RadioGiveawaysPage() {
<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);
const prizeStr =
g.prize || (g.prizeAmount > 0 ? `${g.prizeAmount} ${g.prizeCurrency || "credits"}` : null);
return (
<Link
key={id}
@@ -53,7 +58,9 @@ export default async function RadioGiveawaysPage() {
</p>
) : null}
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{g.startDate ? `${formatDate(g.startDate)} — ${g.endDate ? formatDate(g.endDate) : "ongoing"}` : t("dateUnknown")}
{g.startDate
? `${formatDate(g.startDate)} — ${g.endDate ? formatDate(g.endDate) : "ongoing"}`
: t("dateUnknown")}
</p>
</Link>
);
+16 -10
View File
@@ -43,7 +43,9 @@ export default async function RadioLayout({ children }: { children: ReactNode })
marginBottom: "0.25rem",
}}
>
<span aria-hidden style={{ fontSize: "1.2rem" }}>📻</span>
<span aria-hidden style={{ fontSize: "1.2rem" }}>
📻
</span>
Radio
</Link>
{RADIO_SIDEBAR_LINKS.map((link) => (
@@ -63,18 +65,22 @@ export default async function RadioLayout({ children }: { children: ReactNode })
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")}
<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>
<section style={{ flex: 1, minWidth: 0 }}>{children}</section>
</div>
);
}
+3 -12
View File
@@ -48,11 +48,7 @@ export default async function RadioLeaderboardPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🏆"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={rows.length === 0}>
{rows.length === 0 ? (
@@ -89,16 +85,11 @@ export default async function RadioLeaderboardPage() {
/>
</td>
<td>
<Link
href={`/u/${encodeURIComponent(row.username)}`}
style={{ fontWeight: 700 }}
>
<Link href={`/u/${encodeURIComponent(row.username)}`} style={{ fontWeight: 700 }}>
{row.username}
</Link>
</td>
<td style={{ textAlign: "right", fontWeight: 800 }}>
{row.points.toLocaleString()}
</td>
<td style={{ textAlign: "right", fontWeight: 800 }}>{row.points.toLocaleString()}</td>
</tr>
);
})}
+16 -28
View File
@@ -9,15 +9,7 @@ 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",
];
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
@@ -55,9 +47,7 @@ export default async function RadioPage() {
})
.catch(() => []);
const todaysSlots = schedules.filter(
(s) => s.day.toLowerCase() === today.toLowerCase(),
);
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
@@ -86,11 +76,20 @@ export default async function RadioPage() {
>
<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" }}>
<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>}
{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")}
@@ -98,12 +97,7 @@ export default async function RadioPage() {
</div>
<div className="card" style={{ display: "flex", alignItems: "center" }}>
{streamUrl ? (
<audio
controls
src={streamUrl}
style={{ width: "100%" }}
preload="none"
>
<audio controls src={streamUrl} style={{ width: "100%" }} preload="none">
{t("audioUnsupported")}
</audio>
) : (
@@ -116,11 +110,7 @@ export default async function RadioPage() {
</ContentCard>
{/* ── Sub-page links ──────────────────────────────────────── */}
<ContentCard
icon="🎚️"
title={t("exploreTitle")}
subtitle={t("exploreSubtitle")}
>
<ContentCard icon="🎚️" title={t("exploreTitle")} subtitle={t("exploreSubtitle")}>
<div className="card-grid sm-2 lg-3">
{RADIO_LINKS.map((l) => (
<Link
@@ -171,9 +161,7 @@ export default async function RadioPage() {
<td>
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
</td>
<td>
{s.showName || <span className="muted">{t("untitledShow")}</span>}
</td>
<td>{s.showName || <span className="muted">{t("untitledShow")}</span>}</td>
<td>
{djName ? (
<Link href={`/u/${djName}`}>{djName}</Link>
+7 -7
View File
@@ -39,18 +39,16 @@ export default async function RadioRequestsPage() {
return (
<>
<ContentCard
icon="🎵"
title={genericT("linkRequests")}
subtitle={genericT("linkRequestsText")}
/>
<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>
<label htmlFor="songTitle" className="muted">
{t("songLabel")}
</label>
<input
id="songTitle"
name="songTitle"
@@ -62,7 +60,9 @@ export default async function RadioRequestsPage() {
/>
</div>
<div>
<label htmlFor="artist" className="muted">{t("artistLabel")}</label>
<label htmlFor="artist" className="muted">
{t("artistLabel")}
</label>
<input
id="artist"
name="artist"
+4 -19
View File
@@ -6,15 +6,7 @@ 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;
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).
@@ -58,8 +50,7 @@ export default async function RadioSchedulePage() {
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 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}`,
@@ -77,11 +68,7 @@ export default async function RadioSchedulePage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🗓️"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard icon="🗓️" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={!hasAny}>
{!hasAny ? (
@@ -121,9 +108,7 @@ export default async function RadioSchedulePage() {
<td>{slot.dj}</td>
<td>
{slot.showName || <span className="muted">—</span>}
{slot.description ? (
<div className="muted">{slot.description}</div>
) : null}
{slot.description ? <div className="muted">{slot.description}</div> : null}
</td>
</tr>
));
+2 -9
View File
@@ -22,10 +22,7 @@ export default async function RadioShoutsPage() {
take: 50,
})
.catch(() => []),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
auth(),
]);
@@ -45,11 +42,7 @@ export default async function RadioShoutsPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="📣"
title={t("title")}
subtitle={t("subtitle")}
/>
<ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} />
{isLoggedIn ? (
<ContentCard icon="✍️" title={t("postTitle")} subtitle={t("postSubtitle")}>