Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s
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:
1 parent
a1dc7ff0a7
commit
367f69a439
60 files changed
+29
-30
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} – {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user