Adapt + improve all public pages to the atom design system
Extended the same design-system treatment to the public site, faithful to AtomCMS's "atom" theme. New src/components/public/ui.tsx provides the signature atom building blocks + a scoped CSS layer: - ContentCard: surface card with a primary-tinted header (icon circle + title + subtitle) and padded body — the atom content-card, used as every page's header and section wrapper. - StatBlock / stat-grid, EmptyState, OnlineBadge (online/offline pill), RankBadge (medals for the top 3), and responsive .card-grid helpers. Swept ~45 public pages (home/community/rankings hand-built as the reference; the rest via parallel agents that read the schema and preserved every query, server action, auth gate and field name): heroes → ContentCard headers, lists → card-grids in ContentCards, "no X" → EmptyState, status → OnlineBadge/RankBadge. The leaderboard gained Credits/Diamonds/Duckets tabs (usersCurrency). Behaviour unchanged. Verified on the prod server against amx_test: 15+ public pages render the content-cards/grids with real data, no errors; computed styles confirm the tinted header, icon circle, medal + online pills. Fixed an undefined --color-golden ref. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
1 parent
0de0ac6a3c
commit
e9ea19795a
47 files changed
+2264
-1714
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { applyDj } from "@/actions/radio-apply";
|
||||
@@ -26,18 +27,17 @@ export default async function RadioApplyPage() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>Apply to be a Radio DJ</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Tell us about yourself and why you'd be a great fit for the airwaves.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎤"
|
||||
title="Apply to be a Radio DJ"
|
||||
subtitle="Tell us about yourself and why you'd be a great fit for the airwaves"
|
||||
/>
|
||||
|
||||
{existing ? (
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<ContentCard icon="📋" title="Your latest application">
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Your latest application:</strong> {existing.status}
|
||||
<strong>Status:</strong> {existing.status}
|
||||
{submittedDate ? (
|
||||
<span className="muted"> · submitted {submittedDate}</span>
|
||||
) : null}
|
||||
@@ -45,101 +45,103 @@ export default async function RadioApplyPage() {
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
You can submit again below if your details have changed.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
<form action={applyDj} className="card">
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="realName" className="muted">
|
||||
Real name
|
||||
<ContentCard icon="📝" title="Application form" subtitle="All fields marked required must be filled in">
|
||||
<form action={applyDj}>
|
||||
<div className="card-grid sm-2">
|
||||
<div>
|
||||
<label htmlFor="realName" className="muted">
|
||||
Real name
|
||||
</label>
|
||||
<input
|
||||
id="realName"
|
||||
name="realName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Your full name"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="muted">
|
||||
Age
|
||||
</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">
|
||||
Availability
|
||||
</label>
|
||||
<textarea
|
||||
id="availability"
|
||||
name="availability"
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Which days and times can you broadcast?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="musicStyle" className="muted">
|
||||
Music style
|
||||
</label>
|
||||
<input
|
||||
id="realName"
|
||||
name="realName"
|
||||
id="musicStyle"
|
||||
name="musicStyle"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Your full name"
|
||||
placeholder="e.g. House, Pop, Hip-Hop"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="muted">
|
||||
Age
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="experience" className="muted">
|
||||
Experience <span className="muted">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
name="age"
|
||||
type="number"
|
||||
required
|
||||
min={13}
|
||||
max={120}
|
||||
placeholder="18"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
<textarea
|
||||
id="experience"
|
||||
name="experience"
|
||||
rows={3}
|
||||
placeholder="Any prior DJ or hosting experience?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="availability" className="muted">
|
||||
Availability
|
||||
</label>
|
||||
<textarea
|
||||
id="availability"
|
||||
name="availability"
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Which days and times can you broadcast?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="motivation" className="muted">
|
||||
Motivation
|
||||
</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Why do you want to join the radio team?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="musicStyle" className="muted">
|
||||
Music style
|
||||
</label>
|
||||
<input
|
||||
id="musicStyle"
|
||||
name="musicStyle"
|
||||
type="text"
|
||||
maxLength={255}
|
||||
placeholder="e.g. House, Pop, Hip-Hop"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="experience" className="muted">
|
||||
Experience <span className="muted">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="experience"
|
||||
name="experience"
|
||||
rows={3}
|
||||
placeholder="Any prior DJ or hosting experience?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="motivation" className="muted">
|
||||
Motivation
|
||||
</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Why do you want to join the radio team?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
|
||||
Submit application
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
|
||||
Submit application
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -17,33 +18,39 @@ export default async function RadioContestsPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Contests</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Tune in and take part in our latest on-air contests.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎉"
|
||||
title="Radio Contests"
|
||||
subtitle="Tune in and take part in our latest on-air contests"
|
||||
/>
|
||||
|
||||
{contests.length === 0 ? (
|
||||
<p className="muted">No contests yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{contests.map((c) => {
|
||||
const id = c.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ContentCard padded={contests.length === 0}>
|
||||
{contests.length === 0 ? (
|
||||
<EmptyState icon="🎉">No contests yet.</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> Contest #{id}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -17,33 +18,39 @@ export default async function RadioGiveawaysPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Giveaways</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Listen in for your chance to win in our radio giveaways.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎁"
|
||||
title="Radio Giveaways"
|
||||
subtitle="Listen in for your chance to win in our radio giveaways"
|
||||
/>
|
||||
|
||||
{giveaways.length === 0 ? (
|
||||
<p className="muted">No giveaways yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ContentCard padded={giveaways.length === 0}>
|
||||
{giveaways.length === 0 ? (
|
||||
<EmptyState icon="🎁">No giveaways yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
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> Giveaway #{id}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
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";
|
||||
@@ -43,22 +44,21 @@ export default async function RadioLeaderboardPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Listener Leaderboard</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Top 50 listeners by points earned tuning in to the radio.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏆"
|
||||
title="Radio Listener Leaderboard"
|
||||
subtitle="Top 50 listeners by points earned tuning in to the radio"
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<ContentCard padded={rows.length === 0}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No listener points to show yet.
|
||||
</p>
|
||||
<EmptyState icon="🏆">No listener points to show yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "3rem" }}>#</th>
|
||||
<th style={{ width: "3.5rem" }}>Rank</th>
|
||||
<th style={{ width: "3.5rem" }}>Avatar</th>
|
||||
<th>Listener</th>
|
||||
<th style={{ textAlign: "right" }}>Points</th>
|
||||
@@ -72,7 +72,9 @@ export default async function RadioLeaderboardPage() {
|
||||
});
|
||||
return (
|
||||
<tr key={`${row.username}-${i}`}>
|
||||
<td style={{ fontWeight: 800 }}>{i + 1}</td>
|
||||
<td>
|
||||
<RankBadge position={i + 1} />
|
||||
</td>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -100,7 +102,7 @@ export default async function RadioLeaderboardPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+88
-64
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -6,12 +7,12 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
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" },
|
||||
const RADIO_LINKS: { label: string; href: string; icon: string; text: string }[] = [
|
||||
{ label: "Schedule", href: "/radio/schedule", icon: "🗓️", text: "Our weekly line-up of DJs and shows." },
|
||||
{ label: "Shouts", href: "/radio/shouts", icon: "📣", text: "Shout-outs and requests from the community." },
|
||||
{ label: "Requests", href: "/radio/requests", icon: "🎵", text: "Ask the DJ to spin your favourite track." },
|
||||
{ label: "Apply as DJ", href: "/radio/apply", icon: "🎤", text: "Join the team and take to the airwaves." },
|
||||
{ label: "Leaderboard", href: "/radio/leaderboard", icon: "🏆", text: "Top listeners ranked by points earned." },
|
||||
];
|
||||
|
||||
// Canonical weekday order so today's slots come out chronologically and we can
|
||||
@@ -69,28 +70,30 @@ export default async function RadioPage() {
|
||||
: [];
|
||||
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>
|
||||
const isLive = Boolean(streamUrl);
|
||||
|
||||
<div className="grid cols-2" style={{ marginTop: "1rem" }}>
|
||||
<div>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>Now playing</strong>
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{/* ── Header: live stream + now playing ─────────────────────── */}
|
||||
<ContentCard
|
||||
icon="📻"
|
||||
title="Radio"
|
||||
subtitle="Tune in to the hotel radio — live DJ sets, requests and shoutouts."
|
||||
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" }}>
|
||||
Now playing
|
||||
</p>
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}>
|
||||
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>}
|
||||
<p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}>
|
||||
{currentSong ? currentSong : <span className="muted" style={{ fontWeight: 400 }}>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" }}>
|
||||
<div className="card" style={{ display: "flex", alignItems: "center" }}>
|
||||
{streamUrl ? (
|
||||
<audio
|
||||
controls
|
||||
@@ -107,60 +110,81 @@ export default async function RadioPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Sub-page links ──────────────────────────────────────── */}
|
||||
<section style={{ marginBottom: "1.5rem" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
|
||||
<ContentCard
|
||||
icon="🎚️"
|
||||
title="Explore the radio"
|
||||
subtitle="Schedule, shouts, requests and more"
|
||||
>
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{RADIO_LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className="btn btn-outline">
|
||||
{l.label}
|
||||
<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>
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Today's schedule ────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2>Today’s schedule{today ? ` · ${today}` : ""}</h2>
|
||||
<ContentCard
|
||||
icon="🗓️"
|
||||
title={`Today's schedule${today ? ` · ${today}` : ""}`}
|
||||
subtitle="What's on the airwaves today"
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/radio/schedule">
|
||||
Full week →
|
||||
</Link>
|
||||
}
|
||||
padded={todaysSlots.length === 0}
|
||||
>
|
||||
{todaysSlots.length === 0 ? (
|
||||
<p className="muted">No shows scheduled for today.</p>
|
||||
<EmptyState icon="📻">No shows scheduled for today.</EmptyState>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -72,16 +73,17 @@ export default async function RadioSchedulePage() {
|
||||
const hasAny = schedules.length > 0;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Schedule</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Our weekly line-up of DJs and shows.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🗓️"
|
||||
title="Radio Schedule"
|
||||
subtitle="Our weekly line-up of DJs and shows"
|
||||
/>
|
||||
|
||||
{!hasAny ? (
|
||||
<p className="muted">No shows scheduled yet.</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<ContentCard padded={!hasAny}>
|
||||
{!hasAny ? (
|
||||
<EmptyState icon="🗓️">No shows scheduled yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -125,8 +127,8 @@ export default async function RadioSchedulePage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -40,76 +41,88 @@ export default async function RadioShoutsPage() {
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Shouts</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Shout-outs and requests from the community.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📣"
|
||||
title="Radio Shouts"
|
||||
subtitle="Shout-outs and requests from the community"
|
||||
/>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<form action={postShout} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<ContentCard icon="✍️" title="Post a shout" subtitle="Send a message to the DJ on air">
|
||||
{/* The author is taken from the session inside the action, never from
|
||||
this form — no user id is submitted. */}
|
||||
<label htmlFor="shout-message" className="muted">
|
||||
Post a shout
|
||||
</label>
|
||||
<textarea
|
||||
id="shout-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder="Send a shout to the DJ…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Shout
|
||||
</button>
|
||||
</form>
|
||||
<form action={postShout}>
|
||||
<label htmlFor="shout-message" className="muted">
|
||||
Your message
|
||||
</label>
|
||||
<textarea
|
||||
id="shout-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder="Send a shout to the DJ…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Shout
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<p className="muted">Log in to post a shout.</p>
|
||||
<ContentCard padded>
|
||||
<EmptyState icon="🔒">Log in to post a shout.</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{shouts.length === 0 ? (
|
||||
<p className="muted">No shouts yet. Be the first!</p>
|
||||
) : (
|
||||
<div className="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 }}>
|
||||
<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>
|
||||
<ContentCard
|
||||
icon="💬"
|
||||
title="Latest shouts"
|
||||
subtitle="The most recent messages from listeners"
|
||||
padded={shouts.length === 0}
|
||||
>
|
||||
{shouts.length === 0 ? (
|
||||
<EmptyState icon="📣">No shouts yet. Be the first!</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user