feat: mobile nav, radio layout/requests, leaderboard tabs, client i18n, habbo background, navbar fixes
This commit is contained in:
1 parent
16096eff0f
commit
d6ccc250f6
20 files changed
+936
-140
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
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;
|
||||
|
||||
const SIDEBAR_KEYS = RADIO_SIDEBAR_LINKS.map((l) => l.key);
|
||||
|
||||
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)",
|
||||
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)",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { submitRequest } from "@/actions/radio-requests";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
||||
|
||||
function formatDay(d: Date): string {
|
||||
return DAYS[d.getUTCDay()] ?? "";
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user