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:
Simo committed 2026-06-28 19:28:36 +02:00
1 parent 0de0ac6a3c
commit e9ea19795a
47 files changed
+1288 -738

No files matched your search

+33 -31
View File
@@ -1,8 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { applyStaff } from "@/actions/applications";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -48,39 +49,29 @@ export default async function ApplyStaffPage() {
const appliedRankIds = new Set(myApps.map((a) => a.rankId)); const appliedRankIds = new Set(myApps.map((a) => a.rankId));
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for {hotelName} staff</h1> icon="🛡️"
<p className="muted" style={{ margin: 0 }}> title={`Apply for ${hotelName} staff`}
We open staff applications every now and then. If you come across a position you feel you subtitle="We open staff applications every now and then. If you come across a position you feel you would fit perfectly into, do not hesitate to apply for it."
would fit perfectly into, do not hesitate to apply for it. />
</p>
</div>
{positions.length === 0 ? ( {positions.length === 0 ? (
<div className="card"> <ContentCard icon="🛡️" title="No positions open">
<h3 style={{ marginTop: 0 }}>No positions open</h3> <EmptyState icon="🛡️">
<p className="muted" style={{ margin: 0 }}>
There are currently no positions open. Please come back at a later time to check if we There are currently no positions open. Please come back at a later time to check if we
have any openings by then. Thank you for your interest. have any openings by then. Thank you for your interest.
</p> </EmptyState>
</div> </ContentCard>
) : ( ) : (
<div className="grid cols-2"> <div className="card-grid sm-2">
{positions.map((position) => { {positions.map((position) => {
const team = teamByRankId.get(position.permissionId); const team = teamByRankId.get(position.permissionId);
const rankName = team?.rankName ?? `Rank #${position.permissionId}`; const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
const alreadyApplied = appliedRankIds.has(position.permissionId); const alreadyApplied = appliedRankIds.has(position.permissionId);
return ( return (
<article key={String(position.id)} className="card"> <article key={String(position.id)} className="content-card">
<div <div className="content-card-head">
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
{team?.badge ? ( {team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img <img
@@ -89,24 +80,34 @@ export default async function ApplyStaffPage() {
width={40} width={40}
height={40} height={40}
/> />
) : null} ) : (
<div> <span className="content-card-icon" aria-hidden>
<h3 style={{ margin: 0, color: team?.staffColor || undefined }}>{rankName}</h3> 🛡️
{team?.jobDescription ? ( </span>
<p className="muted" style={{ margin: 0 }}> )}
{team.jobDescription} <div className="content-card-head-text">
<p className="content-card-title" style={{ color: team?.staffColor || undefined }}>
{rankName}
</p> </p>
{team?.jobDescription ? (
<p className="content-card-subtitle">{team.jobDescription}</p>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="content-card-body">
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p> <p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p className="muted" style={{ margin: "0 0 0.75rem" }}>
Application deadline: {formatDeadline(position.applyTo)} Application deadline: {formatDeadline(position.applyTo)}
</p> </p>
{alreadyApplied ? ( {alreadyApplied ? (
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}> <button
type="button"
className="btn btn-danger"
disabled
style={{ width: "100%" }}
>
You have already applied for {rankName} You have already applied for {rankName}
</button> </button>
) : ( ) : (
@@ -131,6 +132,7 @@ export default async function ApplyStaffPage() {
</button> </button>
</form> </form>
)} )}
</div>
</article> </article>
); );
})} })}
+32 -32
View File
@@ -1,8 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { applyTeam } from "@/actions/applications";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -35,38 +36,28 @@ export default async function ApplyTeamPage() {
const appliedTeamIds = new Set(myApps.map((a) => a.rankId)); const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for the {hotelName} team</h1> icon="🤝"
<p className="muted" style={{ margin: 0 }}> title={`Apply for the ${hotelName} team`}
We open team applications periodically. If you see a team you fit, do not hesitate to subtitle="We open team applications periodically. If you see a team you fit, do not hesitate to apply!"
apply! />
</p>
</div>
{teams.length === 0 ? ( {teams.length === 0 ? (
<div className="card"> <ContentCard icon="🤝" title="No team positions open">
<h3 style={{ marginTop: 0 }}>No team positions open</h3> <EmptyState icon="🤝">
<p className="muted" style={{ margin: 0 }}>
There are currently no open team positions. Please come back later to check for new There are currently no open team positions. Please come back later to check for new
openings. Thank you! openings. Thank you!
</p> </EmptyState>
</div> </ContentCard>
) : ( ) : (
<div className="grid cols-2"> <div className="card-grid sm-2">
{teams.map((team) => { {teams.map((team) => {
const teamId = Number(team.id); const teamId = Number(team.id);
const alreadyApplied = appliedTeamIds.has(teamId); const alreadyApplied = appliedTeamIds.has(teamId);
return ( return (
<article key={String(team.id)} className="card"> <article key={String(team.id)} className="content-card">
<div <div className="content-card-head">
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
{team.badge ? ( {team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img <img
@@ -75,21 +66,29 @@ export default async function ApplyTeamPage() {
width={40} width={40}
height={40} height={40}
/> />
) : null} ) : (
<div> <span className="content-card-icon" aria-hidden>
<h3 style={{ margin: 0, color: team.staffColor || undefined }}> 🤝
</span>
)}
<div className="content-card-head-text">
<p className="content-card-title" style={{ color: team.staffColor || undefined }}>
{team.rankName} {team.rankName}
</h3>
{team.jobDescription ? (
<p className="muted" style={{ margin: 0 }}>
{team.jobDescription}
</p> </p>
{team.jobDescription ? (
<p className="content-card-subtitle">{team.jobDescription}</p>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="content-card-body">
{alreadyApplied ? ( {alreadyApplied ? (
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}> <button
type="button"
className="btn btn-danger"
disabled
style={{ width: "100%" }}
>
Your application is pending Your application is pending
</button> </button>
) : ( ) : (
@@ -114,6 +113,7 @@ export default async function ApplyTeamPage() {
</button> </button>
</form> </form>
)} )}
</div>
</article> </article>
); );
})} })}
+12 -10
View File
@@ -1,3 +1,4 @@
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,18 +14,18 @@ export default async function BadgesPage() {
.catch(() => []); .catch(() => []);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: "0 0 0.35rem" }}>Badges</h1> icon="🏅"
<p className="muted" style={{ margin: 0 }}> title="Badges"
The badges you can earn and show off around the hotel. subtitle="The badges you can earn and show off around the hotel"
</p> />
</div>
<ContentCard padded={badges.length === 0}>
{badges.length === 0 ? ( {badges.length === 0 ? (
<p className="muted">No badges available yet.</p> <EmptyState icon="🏅">No badges available yet.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{badges.map((badge) => ( {badges.map((badge) => (
<div <div
key={String(badge.id)} key={String(badge.id)}
@@ -41,7 +42,7 @@ export default async function BadgesPage() {
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{badge.badgeName}</h3> <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>{badge.badgeName}</h3>
<p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}> <p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}>
{badge.badgeKey} {badge.badgeKey}
</p> </p>
@@ -51,6 +52,7 @@ export default async function BadgesPage() {
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+8 -6
View File
@@ -1,3 +1,4 @@
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -34,18 +35,19 @@ export default async function BannedPage() {
: `Expires ${new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " ")}.`; : `Expires ${new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " ")}.`;
return ( return (
<main style={{ maxWidth: 540, margin: "2rem auto" }}> <main style={{ maxWidth: 560, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}> <ContentCard icon="🚫" title="You are banned" subtitle="Account suspended">
<h1 style={{ marginTop: 0, color: "var(--color-danger)" }}>You are banned</h1> <p style={{ marginTop: 0 }}>Your account has been suspended from the hotel.</p>
<p>Your account has been suspended from the hotel.</p>
{reason ? ( {reason ? (
<p> <p>
<strong>Reason:</strong> {reason} <strong>Reason:</strong> {reason}
</p> </p>
) : null} ) : null}
{expiryText ? <p className="muted">{expiryText}</p> : null} {expiryText ? <p className="muted">{expiryText}</p> : null}
<p className="muted">If you believe this is a mistake, contact the staff team.</p> <p className="muted" style={{ marginBottom: 0 }}>
</div> If you believe this is a mistake, contact the staff team.
</p>
</ContentCard>
</main> </main>
); );
} }
+15 -5
View File
@@ -1,5 +1,6 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { issueSsoTicket } from "@/lib/auth/sso-ticket"; import { issueSsoTicket } from "@/lib/auth/sso-ticket";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -30,19 +31,28 @@ export default async function ClientPage() {
: ""; : "";
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Launching {hotelName ?? "Atom"}…</h1> <ContentCard
icon="🏨"
title={`Launching ${hotelName ?? "Atom"}…`}
subtitle="Your single sign-on ticket is ready"
/>
{src ? ( {src ? (
<ContentCard padded={false}>
<iframe <iframe
title="Hotel client" title="Hotel client"
src={src} src={src}
style={{ width: "100%", height: "80vh", border: 0, borderRadius: 10 }} style={{ width: "100%", height: "80vh", border: 0, borderRadius: 10, display: "block" }}
/> />
</ContentCard>
) : ( ) : (
<p className="muted"> <ContentCard icon="🔌" title="No client configured" subtitle="Set nitro_client_url in settings">
<EmptyState icon="🔌">
No client URL configured (set <code>nitro_client_url</code> in settings). SSO ticket No client URL configured (set <code>nitro_client_url</code> in settings). SSO ticket
issued for this session: <code>{ticket}</code> issued for this session: <code>{ticket}</code>
</p> </EmptyState>
</ContentCard>
)} )}
</main> </main>
); );
+45 -10
View File
@@ -1,43 +1,78 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard } from "@/components/public/ui";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const LINKS = [ const LINKS = [
{ {
href: "/rankings", href: "/rankings",
icon: "🏆",
title: "Rankings", title: "Rankings",
text: "See the wealthiest players in the hotel, ranked by credits.", text: "See the wealthiest players in the hotel, ranked by credits.",
}, },
{ {
href: "/staff", href: "/staff",
icon: "🛡️",
title: "Staff", title: "Staff",
text: "Meet the team that keeps the hotel running every day.", text: "Meet the team that keeps the hotel running every day.",
}, },
{ {
href: "/news", href: "/news",
icon: "📰",
title: "News", title: "News",
text: "The latest announcements, events and updates.", text: "The latest announcements, events and updates.",
}, },
{
href: "/photos",
icon: "📸",
title: "Photos",
text: "Snapshots shared by the community from around the hotel.",
},
{
href: "/guilds",
icon: "🚪",
title: "Guilds",
text: "Browse the groups players have created and joined.",
},
{
href: "/leaderboard",
icon: "📊",
title: "Leaderboards",
text: "Who's on top this season across the hotel's stats.",
},
]; ];
export default function CommunityPage() { export default function CommunityPage() {
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: "0 0 0.35rem" }}>Community</h1> icon="🌍"
<p className="muted" style={{ margin: 0 }}> title="Community"
Everything happening around the hotel in one place. subtitle="Everything happening around the hotel in one place"
</p> />
</div>
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3">
{LINKS.map((l) => ( {LINKS.map((l) => (
<Link key={l.href} href={l.href} className="card hover" style={{ color: "inherit" }}> <Link
<h3 style={{ margin: "0 0 0.35rem" }}>{l.title}</h3> key={l.href}
href={l.href}
className="content-card hover-lift"
style={{ color: "inherit", textDecoration: "none" }}
>
<div className="content-card-head">
<span className="content-card-icon" aria-hidden>
{l.icon}
</span>
<div className="content-card-head-text">
<p className="content-card-title">{l.title}</p>
</div>
</div>
<div className="content-card-body">
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p className="muted" style={{ margin: "0 0 0.75rem" }}>
{l.text} {l.text}
</p> </p>
<span className="btn btn-outline">Open →</span> <span className="btn btn-outline">Open →</span>
</div>
</Link> </Link>
))} ))}
</div> </div>
+10 -6
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { requestReset } from "@/actions/password-reset"; import { requestReset } from "@/actions/password-reset";
import { ContentCard } from "@/components/public/ui";
export default async function ForgotPage({ export default async function ForgotPage({
searchParams, searchParams,
@@ -9,15 +10,18 @@ export default async function ForgotPage({
const { sent } = await searchParams; const { sent } = await searchParams;
return ( return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}> <ContentCard
<h1 style={{ marginTop: 0, textAlign: "center" }}>Reset password</h1> icon="🔑"
title="Reset password"
subtitle="We'll email you a secure link to set a new password."
>
{sent ? ( {sent ? (
<p className="muted" style={{ textAlign: "center" }}> <p className="muted" style={{ textAlign: "center", margin: 0 }}>
If that email is registered, a reset link has been sent. Check your inbox. If that email is registered, a reset link has been sent. Check your inbox.
</p> </p>
) : ( ) : (
<form action={requestReset} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}> <form action={requestReset} style={{ display: "grid", gap: "0.7rem" }}>
<input name="email" type="email" placeholder="Your email" autoComplete="email" required /> <input name="email" type="email" placeholder="Your email" autoComplete="email" required />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Send reset link Send reset link
@@ -27,7 +31,7 @@ export default async function ForgotPage({
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link> <Link href="/login">Back to login</Link>
</p> </p>
</div> </ContentCard>
</main> </main>
); );
} }
+21 -31
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -50,22 +51,24 @@ export default async function FriendsPage() {
]); ]);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Friends</h1> <ContentCard
<p className="muted" style={{ marginTop: "-0.25rem" }}> icon="🤝"
{friends.length === 0 title="Friends"
? "You have no friends added yet." subtitle={
: `You have ${friends.length} friend${friends.length === 1 ? "" : "s"}.`} friends.length === 0
</p> ? "You have no friends added yet"
: `You have ${friends.length} friend${friends.length === 1 ? "" : "s"}`
}
/>
<ContentCard padded={friends.length === 0}>
{friends.length === 0 ? ( {friends.length === 0 ? (
<div className="card"> <EmptyState icon="🤝">
<p className="muted" style={{ margin: 0 }}>
Add friends in the hotel and they will show up here. Add friends in the hotel and they will show up here.
</p> </EmptyState>
</div>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{friends.map((friend) => { {friends.map((friend) => {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, { const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
size: "s", size: "s",
@@ -87,28 +90,13 @@ export default async function FriendsPage() {
height={50} height={50}
/> />
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<p style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.4rem" }}> <h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<span <Link href={`/u/${friend.username}`}>{friend.username}</Link>
aria-hidden </h3>
title={isOnline ? "Online" : "Offline"}
style={{
width: 9,
height: 9,
borderRadius: "999px",
flexShrink: 0,
background: isOnline
? "var(--color-accent)"
: "var(--color-text-muted)",
}}
/>
<Link href={`/u/${friend.username}`}>
<strong>{friend.username}</strong>
</Link>
</p>
<p <p
className="muted" className="muted"
style={{ style={{
margin: 0, margin: "0 0 0.4rem",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
@@ -116,12 +104,14 @@ export default async function FriendsPage() {
> >
{friend.motto || "No motto"} {friend.motto || "No motto"}
</p> </p>
<OnlineBadge online={isOnline} />
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+161
View File
@@ -679,3 +679,164 @@ td {
margin: 0 0 0.7rem; margin: 0 0 0.7rem;
font-size: 1.15rem; font-size: 1.15rem;
} }
/* ── Public design system (faithful to the atom theme) ──────────
content-card = surface card with a primary-tinted header (icon +
title + subtitle) and a padded body — the signature atom block. */
.content-card {
display: flex;
flex-direction: column;
width: 100%;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);
border-radius: 14px;
box-shadow: var(--shadow-card);
overflow: hidden;
}
.content-card-head {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface));
border-bottom: 1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);
}
.content-card-icon {
flex: none;
width: 44px;
height: 44px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 1.25rem;
background: color-mix(in srgb, var(--color-primary) 18%, var(--color-surface));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.content-card-head-text {
min-width: 0;
flex: 1;
}
.content-card-title {
margin: 0;
font-weight: 700;
font-size: 1rem;
line-height: 1.2;
}
.content-card-subtitle {
margin: 0.15rem 0 0;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.content-card-action {
flex: none;
}
.content-card-body {
padding: 1rem;
}
/* Responsive card grids (atom: 1 / sm2 / md3 / lg4). */
.card-grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 40rem) {
.card-grid.sm-2 { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 48rem) {
.card-grid.md-2 { grid-template-columns: repeat(2, 1fr); }
.card-grid.md-3 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 64rem) {
.card-grid.lg-3 { grid-template-columns: repeat(3, 1fr); }
.card-grid.lg-4 { grid-template-columns: repeat(4, 1fr); }
}
/* Stat tiles (value in the primary colour + muted label). */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.85rem;
}
.stat-block {
background: var(--color-background);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
border-radius: 12px;
padding: 0.85rem 1rem;
text-align: center;
}
.stat-block-value {
font-size: 1.6rem;
font-weight: 800;
line-height: 1.1;
color: var(--color-primary);
}
.stat-block-label {
margin-top: 0.2rem;
font-size: 0.78rem;
color: var(--color-text-muted);
}
/* Empty states */
.empty-state {
text-align: center;
padding: 2rem 1rem;
}
.empty-state-icon {
font-size: 2rem;
margin-bottom: 0.4rem;
}
/* Online / offline pill */
.online-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.74rem;
font-weight: 700;
padding: 0.1rem 0.55rem 0.1rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 14%, transparent);
color: var(--color-text-muted);
}
.online-badge.online {
background: color-mix(in srgb, #16a34a 16%, transparent);
color: #15803d;
}
.online-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
}
.online-badge.online .online-dot {
background: #16a34a;
box-shadow: 0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent);
}
/* Leaderboard rank chip */
.rank-badge {
display: inline-grid;
place-items: center;
min-width: 2.1rem;
height: 2.1rem;
padding: 0 0.35rem;
border-radius: 999px;
font-weight: 800;
font-size: 0.95rem;
background: var(--color-background);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 18%, transparent);
}
.rank-badge.rank-1 {
background: color-mix(in srgb, #eab308 20%, transparent);
border-color: #eab308;
}
.rank-badge.rank-2 {
background: color-mix(in srgb, #94a3b8 22%, transparent);
border-color: #94a3b8;
}
.rank-badge.rank-3 {
background: color-mix(in srgb, #f97316 20%, transparent);
border-color: #f97316;
}
+10 -8
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { postThread } from "@/actions/social"; import { postThread } from "@/actions/social";
@@ -33,17 +34,17 @@ export default async function NewThreadPage({
if (!guild) notFound(); if (!guild) notFound();
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ marginTop: 0 }}> <p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>← Back to forum</Link> <Link href={`/guilds/${guild.id}/forum`}>← Back to forum</Link>
</p> </p>
<h1 style={{ marginBottom: "0.25rem" }}>New thread</h1> <ContentCard
<p className="muted" style={{ marginTop: 0 }}> icon="✍️"
in {guild.name || "Unnamed guild"} forum title="New thread"
</p> subtitle={`in ${guild.name || "Unnamed guild"} forum`}
>
<form action={postThread} className="card" style={{ marginTop: "1rem" }}> <form action={postThread}>
{/* Guild target drives the write; the author is taken from the session {/* Guild target drives the write; the author is taken from the session
inside the action, never from this form. */} inside the action, never from this form. */}
<input type="hidden" name="guildId" value={String(guild.id)} /> <input type="hidden" name="guildId" value={String(guild.id)} />
@@ -81,6 +82,7 @@ export default async function NewThreadPage({
</Link> </Link>
</div> </div>
</form> </form>
</ContentCard>
</main> </main>
); );
} }
+20 -15
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -89,22 +90,26 @@ export default async function GuildForumPage({
const usernameById = new Map(users.map((u) => [u.id, u.username])); const usernameById = new Map(users.map((u) => [u.id, u.username]));
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ marginTop: 0 }}> <p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}`}>← Back to guild</Link> <Link href={`/guilds/${guild.id}`}>← Back to guild</Link>
</p> </p>
<h1 style={{ marginBottom: "0.25rem" }}> <ContentCard
{guild.name || "Unnamed guild"} — Forum icon="🗣️"
</h1> title={`${guild.name || "Unnamed guild"} — Forum`}
<p className="muted" style={{ marginTop: 0 }}> subtitle={`${threads.length} ${threads.length === 1 ? "thread" : "threads"}`}
{threads.length} {threads.length === 1 ? "thread" : "threads"} action={
</p> <Link href={`/guilds/${guild.id}/forum/new`} className="btn btn-primary">
+ New thread
</Link>
}
/>
<ContentCard padded={threads.length === 0}>
{threads.length === 0 ? ( {threads.length === 0 ? (
<p className="muted">No threads in this forum yet.</p> <EmptyState icon="🗣️">No threads in this forum yet.</EmptyState>
) : ( ) : (
<div className="card" style={{ padding: 0 }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -125,13 +130,13 @@ export default async function GuildForumPage({
{t.subject?.trim() || "(no subject)"} {t.subject?.trim() || "(no subject)"}
</span> </span>
{t.pinned ? ( {t.pinned ? (
<span className="muted" style={{ marginLeft: "0.5rem" }}> <span className="online-badge online" style={{ marginLeft: "0.5rem" }}>
· Pinned 📌 Pinned
</span> </span>
) : null} ) : null}
{t.locked ? ( {t.locked ? (
<span className="muted" style={{ marginLeft: "0.5rem" }}> <span className="online-badge" style={{ marginLeft: "0.5rem" }}>
· Locked 🔒 Locked
</span> </span>
) : null} ) : null}
</td> </td>
@@ -155,8 +160,8 @@ export default async function GuildForumPage({
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+45 -22
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -118,15 +119,13 @@ export default async function GuildPage({
const created = formatTimestamp(guild.dateCreated); const created = formatTimestamp(guild.dateCreated);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ marginTop: 0 }}> <p style={{ margin: 0 }}>
<Link href="/guilds">← All guilds</Link> <Link href="/guilds">← All guilds</Link>
</p> </p>
<div <ContentCard>
className="card" <div style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}>
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
>
{guild.badge ? ( {guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
@@ -137,27 +136,47 @@ export default async function GuildPage({
style={{ flex: "0 0 auto" }} style={{ flex: "0 0 auto" }}
/> />
) : null} ) : null}
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0, flex: 1 }}>
<h1 style={{ margin: "0 0 0.25rem" }}>{guild.name || "Unnamed guild"}</h1> <h1 style={{ margin: "0 0 0.35rem" }}>{guild.name || "Unnamed guild"}</h1>
<p style={{ margin: "0 0 0.5rem" }}> <p style={{ margin: 0 }}>
{guild.description || <span className="muted">No description</span>} {guild.description || <span className="muted">No description</span>}
</p> </p>
<p className="muted" style={{ margin: 0 }}>
{members.length} {members.length === 1 ? "member" : "members"}
{created ? ` · Founded ${created}` : ""}
</p>
</div> </div>
</div> </div>
<h2 style={{ marginTop: "1.5rem" }}>Members</h2> <div className="stat-grid" style={{ marginTop: "1.1rem" }}>
<StatBlock
icon="👥"
value={members.length}
label={members.length === 1 ? "Member" : "Members"}
/>
{created ? <StatBlock icon="📅" value={created} label="Founded" /> : null}
<StatBlock
icon="🚪"
value={
<Link href={`/guilds/${guild.id}/forum`} style={{ textDecoration: "none" }}>
Forum →
</Link>
}
label="Discussion"
/>
</div>
</ContentCard>
<ContentCard
icon="👥"
title="Members"
subtitle={`${members.length} ${members.length === 1 ? "member" : "members"}`}
padded={members.length === 0}
>
{members.length === 0 ? ( {members.length === 0 ? (
<p className="muted">No members.</p> <EmptyState icon="👥">No members.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{members.map((m) => ( {members.map((m) => (
<div <div
key={m.userId} key={m.userId}
className="card" className="card hover"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }} style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -170,15 +189,18 @@ export default async function GuildPage({
style={{ flex: "0 0 auto" }} style={{ flex: "0 0 auto" }}
/> />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.15rem" }}> <h3 style={{ margin: "0 0 0.2rem", fontSize: "1rem" }}>
<Link href={`/u/${m.username}`}>{m.username}</Link> <Link href={`/u/${m.username}`}>{m.username}</Link>
</h3> </h3>
{m.isOwner ? ( {m.isOwner ? (
<p className="muted" style={{ margin: "0 0 0.15rem" }}> <span
Owner className="online-badge online"
</p> style={{ marginBottom: "0.25rem" }}
>
👑 Owner
</span>
) : null} ) : null}
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: m.isOwner ? "0.25rem 0 0" : 0 }}>
{m.motto || "No motto"} {m.motto || "No motto"}
</p> </p>
</div> </div>
@@ -186,6 +208,7 @@ export default async function GuildPage({
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+15 -5
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -28,12 +29,18 @@ export default async function GuildsPage() {
const guilds = await getGuilds(); const guilds = await getGuilds();
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Guilds</h1> <ContentCard
icon="🚪"
title="Guilds"
subtitle="Browse the groups players have created and joined"
/>
<ContentCard padded={guilds.length === 0}>
{guilds.length === 0 ? ( {guilds.length === 0 ? (
<p className="muted">No guilds yet.</p> <EmptyState icon="🚪">No guilds yet.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{guilds.map((g) => ( {guilds.map((g) => (
<Link <Link
key={g.id} key={g.id}
@@ -58,7 +65,9 @@ export default async function GuildsPage() {
/> />
) : null} ) : null}
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{g.name || "Unnamed guild"}</h3> <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
{g.name || "Unnamed guild"}
</h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{g.description ? excerpt(g.description, 120) : "No description"} {g.description ? excerpt(g.description, 120) : "No description"}
</p> </p>
@@ -67,6 +76,7 @@ export default async function GuildsPage() {
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+13 -12
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -93,16 +94,12 @@ export default async function HelpCategoryPage({
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== ""); const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: "0 0 0.75rem" }}> <p style={{ margin: 0 }}>
<Link href="/help">← Help Center</Link> <Link href="/help">← Help Center</Link>
</p> </p>
<div className="hero"> <ContentCard icon="❓" title={title} subtitle="Help Center topic">
<h1 style={{ margin: 0 }}>{title}</h1>
</div>
<article className="card">
{imageSrc ? ( {imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
@@ -120,6 +117,7 @@ export default async function HelpCategoryPage({
{/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */} {/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */}
<div <div
style={{ lineHeight: 1.7 }} style={{ lineHeight: 1.7 }}
// biome-ignore lint/security/noDangerouslySetInnerHtml: author-supplied help content, matches AtomCMS Blade
dangerouslySetInnerHTML={{ __html: content }} dangerouslySetInnerHTML={{ __html: content }}
/> />
@@ -137,12 +135,15 @@ export default async function HelpCategoryPage({
</a> </a>
</div> </div>
) : null} ) : null}
</article> </ContentCard>
{siblings.length > 0 ? ( {siblings.length > 0 ? (
<section style={{ marginTop: "2rem" }}> <ContentCard
<h2>More help topics</h2> icon="🧭"
<div className="grid cols-2"> title="More help topics"
subtitle="Jump to another support topic"
>
<div className="card-grid sm-2 lg-3">
{siblings.map((s) => ( {siblings.map((s) => (
<Link <Link
key={String(s.id)} key={String(s.id)}
@@ -154,7 +155,7 @@ export default async function HelpCategoryPage({
</Link> </Link>
))} ))}
</div> </div>
</section> </ContentCard>
) : null} ) : null}
</main> </main>
); );
+33 -16
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -87,19 +88,28 @@ export default async function HelpCenterPage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<section className="hero"> <ContentCard
<h1 style={{ marginTop: 0 }}>Help Center</h1> icon="❓"
<p className="muted" style={{ fontSize: 16, margin: 0 }}> title="Help Center"
Find guides, support topics, and the hotel rules — everything you need to get help. subtitle="Find guides, support topics, and the hotel rules — everything you need to get help."
</p> action={
</section> <Link className="btn btn-outline" href="/help/tickets">
My tickets →
</Link>
}
/>
<h2>Support topics</h2> <ContentCard
icon="🧭"
title="Support topics"
subtitle="Browse guides and answers to common questions"
padded={categories.length === 0}
>
{categories.length === 0 ? ( {categories.length === 0 ? (
<p className="muted">No help topics yet.</p> <EmptyState icon="🧭">No help topics yet.</EmptyState>
) : ( ) : (
<div className="grid cols-2"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => ( {categories.map((c) => (
<article key={String(c.id)} className="card hover"> <article key={String(c.id)} className="card hover">
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}> <div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
@@ -113,8 +123,8 @@ export default async function HelpCenterPage() {
style={{ flexShrink: 0, objectFit: "contain" }} style={{ flexShrink: 0, objectFit: "contain" }}
/> />
) : null} ) : null}
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.35rem" }}>{c.name}</h3> <h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>{c.name}</h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{c.content} {c.content}
</p> </p>
@@ -155,12 +165,18 @@ export default async function HelpCenterPage() {
))} ))}
</div> </div>
)} )}
</ContentCard>
<h2 style={{ marginTop: "2rem" }}>Hotel rules</h2> <ContentCard
icon="📜"
title="Hotel rules"
subtitle="Please read these before hanging out in the hotel"
padded={ruleCategories.length === 0}
>
{ruleCategories.length === 0 ? ( {ruleCategories.length === 0 ? (
<p className="muted">No rules published yet.</p> <EmptyState icon="📜">No rules published yet.</EmptyState>
) : ( ) : (
<div className="grid"> <div style={{ display: "grid", gap: "1rem", padding: "1rem" }}>
{ruleCategories.map((cat) => { {ruleCategories.map((cat) => {
const catRules = rulesByCategory.get(String(cat.id)) ?? []; const catRules = rulesByCategory.get(String(cat.id)) ?? [];
return ( return (
@@ -173,7 +189,7 @@ export default async function HelpCenterPage() {
flexWrap: "wrap", flexWrap: "wrap",
}} }}
> >
<h3 style={{ margin: 0 }}>{cat.name}</h3> <h3 style={{ margin: 0, fontSize: "1.05rem" }}>{cat.name}</h3>
{cat.badge ? <span className="muted">{cat.badge}</span> : null} {cat.badge ? <span className="muted">{cat.badge}</span> : null}
</div> </div>
{cat.description ? ( {cat.description ? (
@@ -203,6 +219,7 @@ export default async function HelpCenterPage() {
})} })}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+37 -8
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createTicket } from "@/actions/help-tickets"; import { createTicket } from "@/actions/help-tickets";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -26,13 +27,25 @@ export default async function HelpTicketsPage() {
tickets = []; tickets = [];
} }
return ( const openCount = tickets.filter((t) => t.open).length;
<main> const closedCount = tickets.length - openCount;
<h1>Help Tickets</h1>
<p className="muted">Submit a ticket to staff and track your open requests.</p>
<form action={createTicket} className="card" style={{ marginBottom: "1.5rem" }}> return (
<h3 style={{ marginTop: 0 }}>New ticket</h3> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎫"
title="Help Tickets"
subtitle="Submit a ticket to staff and track your open requests."
>
<div className="stat-grid">
<StatBlock icon="🎫" value={tickets.length} label="Total tickets" />
<StatBlock icon="📬" value={openCount} label="Open" />
<StatBlock icon="✅" value={closedCount} label="Closed" />
</div>
</ContentCard>
<ContentCard icon="✏️" title="New ticket" subtitle="Describe your issue and staff will follow up">
<form action={createTicket}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}> <div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<input name="title" placeholder="Subject" maxLength={255} required /> <input name="title" placeholder="Subject" maxLength={255} required />
<textarea <textarea
@@ -49,7 +62,17 @@ export default async function HelpTicketsPage() {
</div> </div>
</div> </div>
</form> </form>
</ContentCard>
<ContentCard
icon="📋"
title="Your tickets"
subtitle="Every request you have sent to staff"
padded={tickets.length === 0}
>
{tickets.length === 0 ? (
<EmptyState icon="🎫">You have no tickets yet.</EmptyState>
) : (
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -64,13 +87,19 @@ export default async function HelpTicketsPage() {
<tr key={String(t.id)}> <tr key={String(t.id)}>
<td>{String(t.id)}</td> <td>{String(t.id)}</td>
<td>{t.title}</td> <td>{t.title}</td>
<td>{t.open ? "Open" : "Closed"}</td> <td>
<span className={`online-badge${t.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{t.open ? "Open" : "Closed"}
</span>
</td>
<td className="muted">{formatDate(t.createdAt)}</td> <td className="muted">{formatDate(t.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{tickets.length === 0 ? <p className="muted">You have no tickets yet.</p> : null} )}
</ContentCard>
</main> </main>
); );
} }
+14 -12
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -80,13 +81,14 @@ export default async function LeaderboardPage({
]); ]);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Leaderboard</h1> <ContentCard
<p className="muted" style={{ marginTop: 0 }}> icon="📊"
Top 20 players by {activeTab.label.toLowerCase()}. title="Leaderboard"
</p> subtitle={`Top 20 players by ${activeTab.label.toLowerCase()}`}
/>
<div className="nav" style={{ gap: "0.5rem", marginBottom: "1.25rem" }}> <div className="nav" style={{ gap: "0.5rem" }}>
{TABS.map((t) => ( {TABS.map((t) => (
<Link <Link
key={t.key} key={t.key}
@@ -98,11 +100,9 @@ export default async function LeaderboardPage({
))} ))}
</div> </div>
<div className="card"> <ContentCard padded={rows.length === 0}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="muted" style={{ margin: 0 }}> <EmptyState icon="📊">No rankings to show yet.</EmptyState>
No rankings to show yet.
</p>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -121,7 +121,9 @@ export default async function LeaderboardPage({
}); });
return ( return (
<tr key={`${row.username}-${i}`}> <tr key={`${row.username}-${i}`}>
<td style={{ fontWeight: 800 }}>{i + 1}</td> <td>
<RankBadge position={i + 1} />
</td>
<td> <td>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
@@ -149,7 +151,7 @@ export default async function LeaderboardPage({
</tbody> </tbody>
</table> </table>
)} )}
</div> </ContentCard>
</main> </main>
); );
} }
+18 -10
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck"; import { precheckLogin } from "@/actions/auth-precheck";
import { ContentCard } from "@/components/public/ui";
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -46,13 +47,17 @@ export default function LoginPage() {
} }
return ( return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}> <ContentCard
<h1 style={{ marginTop: 0, textAlign: "center" }}>Sign in</h1> icon={needs2fa ? "🔒" : "🔑"}
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}> title="Sign in"
{needs2fa ? "Enter the 6-digit code from your authenticator." : "Welcome back — log in to enter the hotel."} subtitle={
</p> needs2fa
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}> ? "Enter the 6-digit code from your authenticator."
: "Welcome back — log in to enter the hotel."
}
>
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
<input <input
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@@ -111,12 +116,15 @@ export default function LoginPage() {
) : null} ) : null}
{error ? ( {error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p> <p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>
{error}
</p>
) : null} ) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
No account? <Link href="/register">Create one</Link> · <Link href="/forgot">Forgot password?</Link> No account? <Link href="/register">Create one</Link> ·{" "}
<Link href="/forgot">Forgot password?</Link>
</p> </p>
</div> </ContentCard>
</main> </main>
); );
} }
+12 -6
View File
@@ -1,3 +1,4 @@
import { ContentCard } from "@/components/public/ui";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,12 +13,17 @@ export default async function MaintenancePage() {
]); ]);
return ( return (
<main style={{ maxWidth: 540, margin: "2rem auto" }}> <main style={{ maxWidth: 560, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}> <ContentCard
<h1 style={{ marginTop: 0 }}>{hotel} is under maintenance 🛠️</h1> icon="🛠️"
<p>{message}</p> title={`${hotel} is under maintenance`}
<p className="muted">Staff can still log in.</p> subtitle="We'll be back shortly"
</div> >
<p style={{ marginTop: 0 }}>{message}</p>
<p className="muted" style={{ marginBottom: 0 }}>
Staff can still log in.
</p>
</ContentCard>
</main> </main>
); );
} }
+26 -39
View File
@@ -1,3 +1,4 @@
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -58,50 +59,35 @@ export default async function MarketplacePage() {
const total = offers.reduce((sum, o) => sum + o.price, 0); const total = offers.reduce((sum, o) => sum + o.price, 0);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<section className="hero"> <ContentCard
<h1 style={{ marginTop: 0 }}>Marketplace</h1> icon="🛍️"
<p className="muted" style={{ marginBottom: 0 }}> title="Marketplace"
Browse furniture and rares listed by players. Listings are placed in-game subtitle="Browse furniture and rares listed by players. Listings are placed in-game and update live — head into the hotel to make an offer."
and update live &mdash; head into the hotel to make an offer. >
</p> <div className="stat-grid">
</section> <StatBlock value={offers.length.toLocaleString()} label="Active offers" icon="🏷️" />
<StatBlock
<div className="grid cols-3" style={{ marginBottom: "1.5rem" }}> value={new Set(offers.map((o) => o.userId)).size.toLocaleString()}
<div className="card"> label="Sellers"
<p className="muted" style={{ margin: 0 }}> icon="👥"
Active offers />
</p> <StatBlock value={`${total.toLocaleString()} cr`} label="Total value" icon="🪙" />
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
{offers.length}
</p>
</div> </div>
<div className="card"> </ContentCard>
<p className="muted" style={{ margin: 0 }}>
Sellers
</p>
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
{new Set(offers.map((o) => o.userId)).size}
</p>
</div>
<div className="card">
<p className="muted" style={{ margin: 0 }}>
Total value
</p>
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
{total.toLocaleString()} cr
</p>
</div>
</div>
<ContentCard
icon="📋"
title="Active listings"
subtitle={offers.length > 0 ? "Most recently listed first" : undefined}
padded={offers.length === 0}
>
{offers.length === 0 ? ( {offers.length === 0 ? (
<div className="card"> <EmptyState icon="🛍️">
<p className="muted" style={{ margin: 0 }}>
There are no active marketplace offers right now. Check back later. There are no active marketplace offers right now. Check back later.
</p> </EmptyState>
</div>
) : ( ) : (
<div className="card" style={{ padding: "0.5rem 1.25rem 0.75rem" }}> <div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -128,6 +114,7 @@ export default async function MarketplacePage() {
</table> </table>
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+33 -23
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -79,23 +80,28 @@ export default async function MessagesPage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Messages</h1> <ContentCard
<p className="muted" style={{ marginTop: "-0.25rem" }}> icon="✉️"
Friend requests and offline messages waiting for you. title="Messages"
</p> subtitle="Friend requests and offline messages waiting for you"
/>
{/* ── Pending friend requests ───────────────────────────── */} {/* ── Pending friend requests ───────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}> <ContentCard
<h2>Friend requests</h2> icon="🙋"
title="Friend requests"
subtitle={
requests.length === 0
? "Nobody's asked to be your friend yet"
: `${requests.length} pending request${requests.length === 1 ? "" : "s"}`
}
padded={requests.length === 0}
>
{requests.length === 0 ? ( {requests.length === 0 ? (
<div className="card"> <EmptyState icon="🙋">No pending friend requests.</EmptyState>
<p className="muted" style={{ margin: 0 }}>
No pending friend requests.
</p>
</div>
) : ( ) : (
<div className="grid"> <div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{requests.map((req) => { {requests.map((req) => {
const from = userById.get(req.userFromId); const from = userById.get(req.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", { const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
@@ -141,19 +147,23 @@ export default async function MessagesPage() {
})} })}
</div> </div>
)} )}
</section> </ContentCard>
{/* ── Offline messages ──────────────────────────────────── */} {/* ── Offline messages ──────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}> <ContentCard
<h2>Offline messages</h2> icon="💬"
title="Offline messages"
subtitle={
messages.length === 0
? "Your inbox is empty"
: `${messages.length} message${messages.length === 1 ? "" : "s"} received`
}
padded={messages.length === 0}
>
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="card"> <EmptyState icon="💬">You have no offline messages.</EmptyState>
<p className="muted" style={{ margin: 0 }}>
You have no offline messages.
</p>
</div>
) : ( ) : (
<div className="grid"> <div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{messages.map((msg) => { {messages.map((msg) => {
const from = userById.get(msg.userFromId); const from = userById.get(msg.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", { const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
@@ -194,7 +204,7 @@ export default async function MessagesPage() {
})} })}
</div> </div>
)} )}
</section> </ContentCard>
</main> </main>
); );
} }
+25 -19
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { postComment } from "@/actions/article-comments"; import { postComment } from "@/actions/article-comments";
@@ -86,28 +87,30 @@ export default async function ArticlePage({
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<p className="muted"> <ContentCard
<Link href="/news">← News</Link> icon="📰"
</p> title={article.title}
<h1>{article.title}</h1> subtitle={article.createdAt ? article.createdAt.toISOString().slice(0, 10) : undefined}
{article.createdAt ? ( action={
<p className="muted">{article.createdAt.toISOString().slice(0, 10)}</p> <Link className="btn btn-outline" href="/news">
) : null} ← News
</Link>
}
>
{article.image ? ( {article.image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={article.image} src={article.image}
alt="" alt=""
style={{ width: "100%", borderRadius: 10, margin: "1rem 0" }} style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
/> />
) : null} ) : null}
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div> <div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
</ContentCard>
{/* ── Reactions ─────────────────────────────────────────── */} {/* ── Reactions ─────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}> <ContentCard icon="💬" title="Reactions" subtitle="Tell us what you think">
<h2>Reactions</h2>
{loggedIn ? ( {loggedIn ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.map((r) => { {REACTIONS.map((r) => {
@@ -136,9 +139,9 @@ export default async function ArticlePage({
})} })}
</div> </div>
) : reactionCounts.size === 0 ? ( ) : reactionCounts.size === 0 ? (
<p className="muted"> <EmptyState icon="💬">
No reactions yet. <Link href="/login">Log in</Link> to react. No reactions yet. <Link href="/login">Log in</Link> to react.
</p> </EmptyState>
) : ( ) : (
<> <>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
@@ -156,13 +159,16 @@ export default async function ArticlePage({
</p> </p>
</> </>
)} )}
</section> </ContentCard>
{/* ── Comments ──────────────────────────────────────────── */} {/* ── Comments ──────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}> <ContentCard
<h2>Comments</h2> icon="🗨️"
title="Comments"
subtitle={comments.length > 0 ? `${comments.length} so far` : "Join the conversation"}
>
{comments.length === 0 ? ( {comments.length === 0 ? (
<p className="muted">No comments yet. Be the first to comment.</p> <EmptyState icon="🗨️">No comments yet. Be the first to comment.</EmptyState>
) : ( ) : (
<div className="grid" style={{ marginBottom: "1.5rem" }}> <div className="grid" style={{ marginBottom: "1.5rem" }}>
{comments.map((c) => { {comments.map((c) => {
@@ -213,7 +219,7 @@ export default async function ArticlePage({
<Link href="/login">Log in</Link> to leave a comment. <Link href="/login">Log in</Link> to leave a comment.
</p> </p>
)} )}
</section> </ContentCard>
</main> </main>
); );
} }
+19 -7
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -21,18 +22,28 @@ export default async function NewsPage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>News</h1> <ContentCard
icon="📰"
title="News"
subtitle="The latest announcements, events and updates from the hotel"
/>
<ContentCard padded={articles.length === 0}>
{articles.length === 0 ? ( {articles.length === 0 ? (
<p className="muted">No articles yet.</p> <EmptyState icon="📰">No articles yet.</EmptyState>
) : ( ) : (
<div className="grid"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => ( {articles.map((a) => (
<article key={a.slug} className="card"> <article
<h3 style={{ margin: "0 0 0.25rem" }}> key={a.slug}
className="card hover"
style={{ display: "flex", flexDirection: "column" }}
>
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link> <Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3> </h3>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ margin: "0 0 0.5rem", fontSize: "0.85rem" }}>
{formatDate(a.createdAt)} {formatDate(a.createdAt)}
</p> </p>
<p style={{ margin: 0 }}>{excerpt(a.shortStory, 200)}</p> <p style={{ margin: 0 }}>{excerpt(a.shortStory, 200)}</p>
@@ -40,6 +51,7 @@ export default async function NewsPage() {
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+38 -15
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -20,13 +21,16 @@ export default async function HomePage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<section className="hero"> <ContentCard
<h1 style={{ fontSize: 36, marginTop: 0 }}>Welcome to {hotelName}</h1> icon="🏨"
<p className="muted" style={{ fontSize: 16 }}> title={`Welcome to ${hotelName}`}
Your retro hotel, now powered by Next.js. Hang out, collect furni, and meet friends. subtitle="Your retro hotel, now powered by Next.js"
>
<p className="muted" style={{ margin: "0 0 0.9rem" }}>
Hang out, collect furni, and meet friends.
</p> </p>
<div style={{ display: "flex", gap: "0.6rem", marginTop: "0.5rem" }}> <div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
<Link className="btn btn-primary" href="/client"> <Link className="btn btn-primary" href="/client">
Enter hotel Enter hotel
</Link> </Link>
@@ -34,28 +38,47 @@ export default async function HomePage() {
Latest news Latest news
</Link> </Link>
</div> </div>
</section> </ContentCard>
<h2>Latest news</h2> <ContentCard
icon="📰"
title="Latest news"
subtitle="Fresh from the hotel"
action={
<Link className="btn btn-outline" href="/news">
All news →
</Link>
}
>
{articles.length === 0 ? ( {articles.length === 0 ? (
<p className="muted">No articles yet.</p> <EmptyState icon="📰">No articles yet.</EmptyState>
) : ( ) : (
<div className="grid cols-2"> <div className="card-grid sm-2 lg-4">
{articles.map((a) => ( {articles.map((a) => (
<article key={a.slug} className="card hover article"> <article
{a.image ? <img className="article-img" src={a.image} alt="" /> : <div className="article-img" />} key={a.slug}
<div className="body"> className="card hover"
<h3 style={{ margin: "0 0 0.3rem" }}> style={{ padding: 0, overflow: "hidden" }}
>
{a.image ? (
// biome-ignore lint/performance/noImgElement: external article image
<img className="article-img" src={a.image} alt="" />
) : (
<div className="article-img" />
)}
<div style={{ padding: "0.85rem" }}>
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link> <Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3> </h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{excerpt(a.shortStory, 120)} {excerpt(a.shortStory, 110)}
</p> </p>
</div> </div>
</article> </article>
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+16 -13
View File
@@ -1,3 +1,4 @@
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,20 +30,20 @@ export default async function PhotosPage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1>Photos</h1> icon="📸"
<p className="muted" style={{ marginTop: 0 }}> title="Photos"
The latest snapshots taken in the hotel. subtitle="The latest snapshots taken in the hotel"
</p> />
</div>
<ContentCard padded={photos.length === 0}>
{photos.length === 0 ? ( {photos.length === 0 ? (
<p className="muted">No photos yet.</p> <EmptyState icon="📸">No photos yet.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p) => ( {photos.map((p) => (
<article key={String(p.id)} className="card"> <article key={String(p.id)} className="card hover" style={{ padding: 0, overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={p.url} src={p.url}
@@ -50,20 +51,22 @@ export default async function PhotosPage() {
style={{ style={{
width: "100%", width: "100%",
height: "auto", height: "auto",
borderRadius: 4,
display: "block", display: "block",
}} }}
/> />
<p className="muted" style={{ margin: "0.5rem 0 0" }}> <div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{formatDate(p.timestamp)} {formatDate(p.timestamp)}
</p> </p>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
by user #{p.userId} by user #{p.userId}
</p> </p>
</div>
</article> </article>
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+14 -12
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { applyDj } from "@/actions/radio-apply"; import { applyDj } from "@/actions/radio-apply";
@@ -26,18 +27,17 @@ export default async function RadioApplyPage() {
: null; : null;
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: "0 0 0.25rem" }}>Apply to be a Radio DJ</h1> icon="🎤"
<p className="muted" style={{ margin: 0 }}> title="Apply to be a Radio DJ"
Tell us about yourself and why you&apos;d be a great fit for the airwaves. subtitle="Tell us about yourself and why you'd be a great fit for the airwaves"
</p> />
</div>
{existing ? ( {existing ? (
<div className="card" style={{ marginBottom: "1.25rem" }}> <ContentCard icon="📋" title="Your latest application">
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<strong>Your latest application:</strong> {existing.status} <strong>Status:</strong> {existing.status}
{submittedDate ? ( {submittedDate ? (
<span className="muted"> · submitted {submittedDate}</span> <span className="muted"> · submitted {submittedDate}</span>
) : null} ) : null}
@@ -45,11 +45,12 @@ export default async function RadioApplyPage() {
<p className="muted" style={{ margin: "0.4rem 0 0" }}> <p className="muted" style={{ margin: "0.4rem 0 0" }}>
You can submit again below if your details have changed. You can submit again below if your details have changed.
</p> </p>
</div> </ContentCard>
) : null} ) : null}
<form action={applyDj} className="card"> <ContentCard icon="📝" title="Application form" subtitle="All fields marked required must be filled in">
<div className="grid cols-2"> <form action={applyDj}>
<div className="card-grid sm-2">
<div> <div>
<label htmlFor="realName" className="muted"> <label htmlFor="realName" className="muted">
Real name Real name
@@ -140,6 +141,7 @@ export default async function RadioApplyPage() {
Submit application Submit application
</button> </button>
</form> </form>
</ContentCard>
</main> </main>
); );
} }
+20 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,33 +18,39 @@ export default async function RadioContestsPage() {
.catch(() => []); .catch(() => []);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: 0 }}>Radio Contests</h1> icon="🎉"
<p className="muted" style={{ margin: "0.5rem 0 0" }}> title="Radio Contests"
Tune in and take part in our latest on-air contests. subtitle="Tune in and take part in our latest on-air contests"
</p> />
</div>
<ContentCard padded={contests.length === 0}>
{contests.length === 0 ? ( {contests.length === 0 ? (
<p className="muted">No contests yet.</p> <EmptyState icon="🎉">No contests yet.</EmptyState>
) : ( ) : (
<div className="grid cols-2"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{contests.map((c) => { {contests.map((c) => {
const id = c.id.toString(); const id = c.id.toString();
return ( return (
<article key={id} className="card hover"> <Link
<h3 style={{ margin: "0 0 0.25rem" }}> key={id}
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link> 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> </h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{formatDate(c.createdAt) || "Date unknown"} {formatDate(c.createdAt) || "Date unknown"}
</p> </p>
</article> </Link>
); );
})} })}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+20 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,33 +18,39 @@ export default async function RadioGiveawaysPage() {
.catch(() => []); .catch(() => []);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: 0 }}>Radio Giveaways</h1> icon="🎁"
<p className="muted" style={{ margin: "0.5rem 0 0" }}> title="Radio Giveaways"
Listen in for your chance to win in our radio giveaways. subtitle="Listen in for your chance to win in our radio giveaways"
</p> />
</div>
<ContentCard padded={giveaways.length === 0}>
{giveaways.length === 0 ? ( {giveaways.length === 0 ? (
<p className="muted">No giveaways yet.</p> <EmptyState icon="🎁">No giveaways yet.</EmptyState>
) : ( ) : (
<div className="grid cols-2"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{giveaways.map((g) => { {giveaways.map((g) => {
const id = g.id.toString(); const id = g.id.toString();
return ( return (
<article key={id} className="card hover"> <Link
<h3 style={{ margin: "0 0 0.25rem" }}> key={id}
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link> 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> </h3>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{formatDate(g.createdAt) || "Date unknown"} {formatDate(g.createdAt) || "Date unknown"}
</p> </p>
</article> </Link>
); );
})} })}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+14 -12
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -43,22 +44,21 @@ export default async function RadioLeaderboardPage() {
]); ]);
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Radio Listener Leaderboard</h1> <ContentCard
<p className="muted" style={{ marginTop: 0 }}> icon="🏆"
Top 50 listeners by points earned tuning in to the radio. title="Radio Listener Leaderboard"
</p> subtitle="Top 50 listeners by points earned tuning in to the radio"
/>
<div className="card"> <ContentCard padded={rows.length === 0}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="muted" style={{ margin: 0 }}> <EmptyState icon="🏆">No listener points to show yet.</EmptyState>
No listener points to show yet.
</p>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: "3rem" }}>#</th> <th style={{ width: "3.5rem" }}>Rank</th>
<th style={{ width: "3.5rem" }}>Avatar</th> <th style={{ width: "3.5rem" }}>Avatar</th>
<th>Listener</th> <th>Listener</th>
<th style={{ textAlign: "right" }}>Points</th> <th style={{ textAlign: "right" }}>Points</th>
@@ -72,7 +72,9 @@ export default async function RadioLeaderboardPage() {
}); });
return ( return (
<tr key={`${row.username}-${i}`}> <tr key={`${row.username}-${i}`}>
<td style={{ fontWeight: 800 }}>{i + 1}</td> <td>
<RankBadge position={i + 1} />
</td>
<td> <td>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
@@ -100,7 +102,7 @@ export default async function RadioLeaderboardPage() {
</tbody> </tbody>
</table> </table>
)} )}
</div> </ContentCard>
</main> </main>
); );
} }
+57 -33
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -6,12 +7,12 @@ import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Sub-pages linked from the radio home hub. // Sub-pages linked from the radio home hub.
const RADIO_LINKS: { label: string; href: string }[] = [ const RADIO_LINKS: { label: string; href: string; icon: string; text: string }[] = [
{ label: "Schedule", href: "/radio/schedule" }, { label: "Schedule", href: "/radio/schedule", icon: "🗓️", text: "Our weekly line-up of DJs and shows." },
{ label: "Shouts", href: "/radio/shouts" }, { label: "Shouts", href: "/radio/shouts", icon: "📣", text: "Shout-outs and requests from the community." },
{ label: "Requests", href: "/radio/requests" }, { label: "Requests", href: "/radio/requests", icon: "🎵", text: "Ask the DJ to spin your favourite track." },
{ label: "Apply as DJ", href: "/radio/apply" }, { label: "Apply as DJ", href: "/radio/apply", icon: "🎤", text: "Join the team and take to the airwaves." },
{ label: "Leaderboard", href: "/radio/leaderboard" }, { 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 // 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])); const djById = new Map(djs.map((d) => [d.id, d.username]));
return ( const isLive = Boolean(streamUrl);
<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" }}> return (
<div> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: "0 0 0.25rem" }}> {/* ── Header: live stream + now playing ─────────────────────── */}
<strong>Now playing</strong> <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>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}> <p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}>
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>} {currentSong ? currentSong : <span className="muted" style={{ fontWeight: 400 }}>Nothing playing right now.</span>}
</p> </p>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{currentDj ? `On air: ${currentDj}` : "No DJ on air."} {currentDj ? `On air: ${currentDj}` : "No DJ on air."}
</p> </p>
</div> </div>
<div style={{ display: "flex", alignItems: "center" }}> <div className="card" style={{ display: "flex", alignItems: "center" }}>
{streamUrl ? ( {streamUrl ? (
<audio <audio
controls controls
@@ -107,26 +110,48 @@ export default async function RadioPage() {
)} )}
</div> </div>
</div> </div>
</div> </ContentCard>
{/* ── Sub-page links ──────────────────────────────────────── */} {/* ── Sub-page links ──────────────────────────────────────── */}
<section style={{ marginBottom: "1.5rem" }}> <ContentCard
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}> icon="🎚️"
title="Explore the radio"
subtitle="Schedule, shouts, requests and more"
>
<div className="card-grid sm-2 lg-3">
{RADIO_LINKS.map((l) => ( {RADIO_LINKS.map((l) => (
<Link key={l.href} href={l.href} className="btn btn-outline"> <Link
{l.label} 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> </Link>
))} ))}
</div> </div>
</section> </ContentCard>
{/* ── Today's schedule ────────────────────────────────────── */} {/* ── Today's schedule ────────────────────────────────────── */}
<section> <ContentCard
<h2>Today&rsquo;s schedule{today ? ` · ${today}` : ""}</h2> 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 ? ( {todaysSlots.length === 0 ? (
<p className="muted">No shows scheduled for today.</p> <EmptyState icon="📻">No shows scheduled for today.</EmptyState>
) : ( ) : (
<div className="card">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -158,9 +183,8 @@ export default async function RadioPage() {
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section> </ContentCard>
</main> </main>
); );
} }
+10 -8
View File
@@ -1,3 +1,4 @@
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -72,16 +73,17 @@ export default async function RadioSchedulePage() {
const hasAny = schedules.length > 0; const hasAny = schedules.length > 0;
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Radio Schedule</h1> <ContentCard
<p className="muted" style={{ marginTop: 0 }}> icon="🗓️"
Our weekly line-up of DJs and shows. title="Radio Schedule"
</p> subtitle="Our weekly line-up of DJs and shows"
/>
<ContentCard padded={!hasAny}>
{!hasAny ? ( {!hasAny ? (
<p className="muted">No shows scheduled yet.</p> <EmptyState icon="🗓️">No shows scheduled yet.</EmptyState>
) : ( ) : (
<div className="card">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -125,8 +127,8 @@ export default async function RadioSchedulePage() {
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+24 -11
View File
@@ -1,3 +1,4 @@
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -40,18 +41,20 @@ export default async function RadioShoutsPage() {
const authorById = new Map(authors.map((a) => [a.id, a])); const authorById = new Map(authors.map((a) => [a.id, a]));
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Radio Shouts</h1> <ContentCard
<p className="muted" style={{ marginTop: 0 }}> icon="📣"
Shout-outs and requests from the community. title="Radio Shouts"
</p> subtitle="Shout-outs and requests from the community"
/>
{isLoggedIn ? ( {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 {/* The author is taken from the session inside the action, never from
this form — no user id is submitted. */} this form — no user id is submitted. */}
<form action={postShout}>
<label htmlFor="shout-message" className="muted"> <label htmlFor="shout-message" className="muted">
Post a shout Your message
</label> </label>
<textarea <textarea
id="shout-message" id="shout-message"
@@ -66,14 +69,23 @@ export default async function RadioShoutsPage() {
Shout Shout
</button> </button>
</form> </form>
</ContentCard>
) : ( ) : (
<p className="muted">Log in to post a shout.</p> <ContentCard padded>
<EmptyState icon="🔒">Log in to post a shout.</EmptyState>
</ContentCard>
)} )}
<ContentCard
icon="💬"
title="Latest shouts"
subtitle="The most recent messages from listeners"
padded={shouts.length === 0}
>
{shouts.length === 0 ? ( {shouts.length === 0 ? (
<p className="muted">No shouts yet. Be the first!</p> <EmptyState icon="📣">No shouts yet. Be the first!</EmptyState>
) : ( ) : (
<div className="grid"> <div className="card-grid">
{shouts.map((s) => { {shouts.map((s) => {
const author = authorById.get(Number(s.userId)); const author = authorById.get(Number(s.userId));
const authorAvatar = author const authorAvatar = author
@@ -98,7 +110,7 @@ export default async function RadioShoutsPage() {
height={40} height={40}
/> />
) : null} ) : null}
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: "0 0 0.25rem" }}> <p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "} <strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
<span className="muted">{formatDate(s.createdAt)}</span> <span className="muted">{formatDate(s.createdAt)}</span>
@@ -110,6 +122,7 @@ export default async function RadioShoutsPage() {
})} })}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+18 -20
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -10,13 +11,14 @@ type TopUser = {
look: string; look: string;
credits: number; credits: number;
rank: number; rank: number;
online: string;
}; };
export default async function RankingsPage() { export default async function RankingsPage() {
let users: TopUser[] = []; let users: TopUser[] = [];
try { try {
users = await prisma.user.findMany({ users = await prisma.user.findMany({
select: { username: true, look: true, credits: true, rank: true }, select: { username: true, look: true, credits: true, rank: true, online: true },
orderBy: { credits: "desc" }, orderBy: { credits: "desc" },
take: 12, take: 12,
}); });
@@ -30,51 +32,47 @@ export default async function RankingsPage() {
); );
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard icon="🏆" title="Rankings" subtitle="The top 12 players ranked by credits" />
<h1 style={{ margin: "0 0 0.35rem" }}>Rankings</h1>
<p className="muted" style={{ margin: 0 }}>
The top 12 players ranked by credits.
</p>
</div>
<ContentCard padded={users.length === 0}>
{users.length === 0 ? ( {users.length === 0 ? (
<p className="muted">No players to rank yet.</p> <EmptyState icon="🏆">No players to rank yet.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{users.map((u, i) => { {users.map((u, i) => {
const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" }); const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" });
return ( return (
<div <div
key={u.username} key={u.username}
className="card hover" className="card hover"
style={{ display: "flex", gap: "1rem", alignItems: "center" }} style={{ display: "flex", gap: "0.9rem", alignItems: "center" }}
> >
<span style={{ fontSize: "1.5rem", fontWeight: 800, minWidth: "2rem" }}> <RankBadge position={i + 1} />
#{i + 1} {/* biome-ignore lint/performance/noImgElement: external avatar imager */}
</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
className="avatar" className="avatar"
src={avatar} src={avatar}
alt={`${u.username} avatar`} alt={`${u.username} avatar`}
width={64} width={50}
height={110} height={90}
/> />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{ margin: "0 0 0.2rem" }}> <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
<Link href={`/u/${u.username}`}>{u.username}</Link> <Link href={`/u/${u.username}`}>{u.username}</Link>
</h3> </h3>
<p className="currency" style={{ margin: 0 }}> <p className="currency" style={{ margin: "0 0 0.3rem" }}>
<span className="coin credits">c</span> <span className="coin credits">c</span>
{u.credits.toLocaleString()} {u.credits.toLocaleString()}
</p> </p>
<OnlineBadge online={u.online === "1"} />
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+21 -20
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -85,31 +86,30 @@ export default async function RareCategoryPage({
const badgeSrc = badgeBase && cat.badge ? `${badgeBase}/${cat.badge}.gif` : ""; const badgeSrc = badgeBase && cat.badge ? `${badgeBase}/${cat.badge}.gif` : "";
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: "0 0 0.75rem" }}> <ContentCard
<Link href="/rares">← All rares</Link> icon={badgeSrc ? undefined : "💎"}
</p> title={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6rem" }}>
<div
className="hero"
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
>
{badgeSrc ? ( {badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={badgeSrc} alt={`${cat.name} badge`} width={48} height={48} /> <img src={badgeSrc} alt={`${cat.name} badge`} width={32} height={32} />
) : null} ) : null}
<div> {cat.name}
<h1 style={{ margin: 0 }}>{cat.name}</h1> </span>
<p className="muted" style={{ marginBottom: 0 }}> }
{rares.length} {rares.length === 1 ? "rare" : "rares"} in this category subtitle={`${rares.length} ${rares.length === 1 ? "rare" : "rares"} in this category`}
</p> action={
</div> <Link className="btn btn-outline" href="/rares">
</div> ← All rares
</Link>
}
padded={rares.length === 0}
>
{rares.length === 0 ? ( {rares.length === 0 ? (
<p className="muted">No rares listed in this category yet.</p> <EmptyState icon="💎">No rares listed in this category yet.</EmptyState>
) : ( ) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}> <div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -166,6 +166,7 @@ export default async function RareCategoryPage({
</table> </table>
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+28 -25
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -39,18 +40,19 @@ export default async function RareValuesPage() {
const badgesBase = (await siteSettings.get("badges_path", "")) ?? ""; const badgesBase = (await siteSettings.get("badges_path", "")) ?? "";
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ margin: 0 }}>Rare values</h1> icon="💎"
<p className="muted" style={{ marginBottom: 0 }}> title="Rare values"
Get an overview of all of the rares on the hotel, grouped by category. subtitle="Get an overview of all of the rares on the hotel, grouped by category."
</p> />
</div>
{categories.length === 0 ? ( {categories.length === 0 ? (
<p className="muted">We currently have no rares listed here.</p> <ContentCard padded={false}>
<EmptyState icon="💎">We currently have no rares listed here.</EmptyState>
</ContentCard>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3">
{categories.map((c) => { {categories.map((c) => {
const id = String(c.id); const id = String(c.id);
const count = counts.get(id) ?? 0; const count = counts.get(id) ?? 0;
@@ -59,33 +61,34 @@ export default async function RareValuesPage() {
<Link <Link
key={id} key={id}
href={`/rares/${id}`} href={`/rares/${id}`}
className="card hover" className="content-card hover-lift"
style={{ style={{ color: "inherit", textDecoration: "none" }}
display: "flex",
alignItems: "center",
gap: "0.85rem",
textDecoration: "none",
color: "inherit",
}}
> >
<div className="content-card-head">
{badgeSrc ? ( {badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={badgeSrc} src={badgeSrc}
alt={`${c.name} badge`} alt={`${c.name} badge`}
width={40} width={36}
height={40} height={36}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
) : ( ) : (
<span className="avatar" style={{ width: 40, height: 40, flexShrink: 0 }} /> <span className="content-card-icon" aria-hidden>
💎
</span>
)} )}
<span style={{ display: "flex", flexDirection: "column" }}> <div className="content-card-head-text">
<strong>{c.name}</strong> <p className="content-card-title">{c.name}</p>
<span className="muted"> <p className="content-card-subtitle">
{count} {count === 1 ? "rare" : "rares"} {count} {count === 1 ? "rare" : "rares"}
</span> </p>
</span> </div>
</div>
<div className="content-card-body">
<span className="btn btn-outline">View rares →</span>
</div>
</Link> </Link>
); );
})} })}
+16 -22
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import RedeemForm from "./RedeemForm"; import RedeemForm from "./RedeemForm";
import { ContentCard, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -25,36 +26,29 @@ export default async function RedeemPage() {
if (!user) redirect("/login"); if (!user) redirect("/login");
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ marginBottom: "0.25rem" }}>Redeem a voucher</h1> icon="🎟️"
<p className="muted" style={{ margin: 0 }}> title="Redeem a voucher"
Got a voucher code? Enter it below to add the reward to your account. subtitle="Got a voucher code? Enter it below to add the reward to your account."
</p> />
</div>
<div className="grid cols-2"> <div className="card-grid sm-2">
<div className="card"> <ContentCard icon="🔑" title="Enter your code">
<h3 style={{ marginTop: 0 }}>Enter your code</h3>
<RedeemForm /> <RedeemForm />
<p className="muted" style={{ marginBottom: 0 }}> <p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
Each voucher can only be redeemed once per account. Each voucher can only be redeemed once per account.
</p> </p>
</div> </ContentCard>
<div className="card"> <ContentCard icon="💰" title="Your balance" subtitle={`Signed in as ${user.username}`}>
<h3 style={{ marginTop: 0 }}>Your balance</h3> <div className="stat-grid">
<p style={{ margin: "0 0 0.6rem" }}> <StatBlock value={user.credits.toLocaleString()} label="Credits" icon="🪙" />
Signed in as <strong>{user.username}</strong>. </div>
</p>
<span className="currency">
<span className="coin credits">cr</span>
{user.credits.toLocaleString()}
</span>
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}> <p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
Redeemed rewards are credited instantly and appear in-game the next time you log in. Redeemed rewards are credited instantly and appear in-game the next time you log in.
</p> </p>
</div> </ContentCard>
</div> </div>
</main> </main>
); );
+9 -8
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { register } from "@/actions/register"; import { register } from "@/actions/register";
import { ContentCard } from "@/components/public/ui";
export default async function RegisterPage({ export default async function RegisterPage({
searchParams, searchParams,
@@ -9,13 +10,13 @@ export default async function RegisterPage({
const { error } = await searchParams; const { error } = await searchParams;
return ( return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}> <ContentCard
<h1 style={{ marginTop: 0, textAlign: "center" }}>Create account</h1> icon="🎉"
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}> title="Create account"
Join the hotel — it only takes a moment. subtitle="Join the hotel — it only takes a moment."
</p> >
<form action={register} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}> <form action={register} style={{ display: "grid", gap: "0.7rem" }}>
<input name="username" placeholder="Username" autoComplete="username" required /> <input name="username" placeholder="Username" autoComplete="username" required />
<input name="mail" type="email" placeholder="Email" autoComplete="email" required /> <input name="mail" type="email" placeholder="Email" autoComplete="email" required />
<input <input
@@ -37,7 +38,7 @@ export default async function RegisterPage({
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
Already have an account? <Link href="/login">Sign in</Link> Already have an account? <Link href="/login">Sign in</Link>
</p> </p>
</div> </ContentCard>
</main> </main>
); );
} }
+12 -6
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { resetPassword } from "@/actions/password-reset"; import { resetPassword } from "@/actions/password-reset";
import { ContentCard } from "@/components/public/ui";
export default async function ResetPage({ export default async function ResetPage({
searchParams, searchParams,
@@ -9,10 +10,13 @@ export default async function ResetPage({
const { email = "", token = "", error } = await searchParams; const { email = "", token = "", error } = await searchParams;
return ( return (
<main style={{ maxWidth: 400, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem" }}> <ContentCard
<h1 style={{ marginTop: 0, textAlign: "center" }}>Set a new password</h1> icon="🔐"
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}> title="Set a new password"
subtitle="Choose a strong password you don't use elsewhere."
>
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem" }}>
<input type="hidden" name="email" value={email} /> <input type="hidden" name="email" value={email} />
<input type="hidden" name="token" value={token} /> <input type="hidden" name="token" value={token} />
<input <input
@@ -27,12 +31,14 @@ export default async function ResetPage({
</button> </button>
</form> </form>
{error ? ( {error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p> <p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>
{error}
</p>
) : null} ) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}> <p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
<Link href="/login">Back to login</Link> <Link href="/login">Back to login</Link>
</p> </p>
</div> </ContentCard>
</main> </main>
); );
} }
+31 -16
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor"; import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor";
import { ContentCard } from "@/components/public/ui";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { totpKeyUri } from "@/lib/auth/totp"; import { totpKeyUri } from "@/lib/auth/totp";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
@@ -47,28 +48,38 @@ export default async function TwoFactorPage({
} }
return ( return (
<main style={{ maxWidth: 520 }}> <main style={{ display: "grid", gap: "1.5rem", maxWidth: 560, margin: "0 auto" }}>
<p className="muted"> <p className="muted" style={{ margin: 0 }}>
<Link href="/settings">← Settings</Link> <Link href="/settings">← Settings</Link>
</p> </p>
<h1>Two-factor authentication</h1>
{sp.enabled ? <p style={{ color: "var(--color-accent)" }}>2FA is now enabled. 🔒</p> : null} <ContentCard
{sp.disabled ? <p className="muted">2FA has been disabled.</p> : null} icon="🔒"
title="Two-factor authentication"
subtitle="Add a second layer of security with an authenticator app"
>
{sp.enabled ? (
<p style={{ color: "var(--color-accent)", marginTop: 0 }}>2FA is now enabled. 🔒</p>
) : null}
{sp.disabled ? (
<p className="muted" style={{ marginTop: 0 }}>
2FA has been disabled.
</p>
) : null}
{sp.error === "badcode" ? ( {sp.error === "badcode" ? (
<p style={{ color: "var(--color-danger)" }}>That code wasn't valid — try again.</p> <p style={{ color: "var(--color-danger)", marginTop: 0 }}>
That code wasn't valid — try again.
</p>
) : null} ) : null}
{!hasAppKey ? ( {!hasAppKey ? (
<div className="card">
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
2FA is unavailable until <code>APP_KEY</code> is configured (the same Laravel 2FA is unavailable until <code>APP_KEY</code> is configured (the same Laravel
<code> APP_KEY</code> as your AtomCMS install, so existing secrets stay readable). <code> APP_KEY</code> as your AtomCMS install, so existing secrets stay readable).
</p> </p>
</div>
) : enabled ? ( ) : enabled ? (
<div className="card"> <>
<p> <p style={{ marginTop: 0 }}>
<strong>2FA is enabled</strong> on your account. <strong>2FA is enabled</strong> on your account.
</p> </p>
<form action={disableTwoFactor}> <form action={disableTwoFactor}>
@@ -76,9 +87,9 @@ export default async function TwoFactorPage({
Disable 2FA Disable 2FA
</button> </button>
</form> </form>
</div> </>
) : pending ? ( ) : pending ? (
<div className="card"> <>
<h3 style={{ marginTop: 0 }}>Scan or enter this key</h3> <h3 style={{ marginTop: 0 }}>Scan or enter this key</h3>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm. Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm.
@@ -89,15 +100,18 @@ export default async function TwoFactorPage({
<p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}> <p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}>
{uri} {uri}
</p> </p>
<form action={confirmTwoFactor} style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}> <form
action={confirmTwoFactor}
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
>
<input name="code" placeholder="6-digit code" inputMode="numeric" required /> <input name="code" placeholder="6-digit code" inputMode="numeric" required />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Confirm Confirm
</button> </button>
</form> </form>
</div> </>
) : ( ) : (
<div className="card"> <>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
Add a second layer of security with an authenticator app. Add a second layer of security with an authenticator app.
</p> </p>
@@ -106,8 +120,9 @@ export default async function TwoFactorPage({
Enable 2FA Enable 2FA
</button> </button>
</form> </form>
</div> </>
)} )}
</ContentCard>
</main> </main>
); );
} }
+30 -30
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { env } from "@/env"; import { env } from "@/env";
@@ -14,11 +15,13 @@ const PROVIDERS = [
{ {
key: "discord", key: "discord",
label: "Discord", label: "Discord",
icon: "🎮",
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET), available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
}, },
{ {
key: "google", key: "google",
label: "Google", label: "Google",
icon: "🔵",
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET), available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
}, },
] as const; ] as const;
@@ -48,46 +51,45 @@ export default async function ConnectionsPage() {
const callbackUrl = "/settings/connections"; const callbackUrl = "/settings/connections";
return ( return (
<main style={{ maxWidth: 560 }}> <main style={{ display: "grid", gap: "1.5rem", maxWidth: 620, margin: "0 auto" }}>
<p className="muted"> <p className="muted" style={{ margin: 0 }}>
<Link href="/settings">← Settings</Link> <Link href="/settings">← Settings</Link>
</p> </p>
<h1>Connected accounts</h1>
<ContentCard
icon="🔗"
title="Connected accounts"
subtitle="Link a social account so you can sign in with one click"
>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Link a social account so you can sign in with one click. We match it to We match it to your hotel account by e-mail, so use the address on file:{" "}
your hotel account by e-mail, so use the address on file:{" "}
<strong>{session.user.name}</strong>. <strong>{session.user.name}</strong>.
</p> </p>
{lookupFailed ? ( {lookupFailed ? (
<div className="card" style={{ marginBottom: "1.5rem" }}> <p className="muted" style={{ marginBottom: 0 }}>
<p className="muted" style={{ margin: 0 }}> We couldn't read your linked accounts right now. You can still connect below — your
We couldn't read your linked accounts right now. You can still connect status will show once it's available.
below — your status will show once it's available.
</p> </p>
</div>
) : null} ) : null}
</ContentCard>
<div className="grid" style={{ gap: "1rem" }}> <div className="card-grid sm-2">
{PROVIDERS.map((p) => { {PROVIDERS.map((p) => {
const isLinked = linked.has(p.key); const isLinked = linked.has(p.key);
return ( return (
<div <ContentCard
key={p.key} key={p.key}
className="card" icon={p.icon}
style={{ display: "flex", alignItems: "center", gap: "1rem" }} title={p.label}
> subtitle={
<div style={{ flex: 1 }}> !p.available
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3>
<p className="muted" style={{ margin: 0 }}>
{!p.available
? "Not available on this hotel." ? "Not available on this hotel."
: isLinked : isLinked
? "Connected — you can sign in with this account." ? "Connected — sign in with this account."
: "Not connected."} : "Not connected."
</p> }
</div> >
<div>
{isLinked ? ( {isLinked ? (
<span <span
className="btn btn-outline" className="btn btn-outline"
@@ -117,16 +119,14 @@ export default async function ConnectionsPage() {
Unavailable Unavailable
</span> </span>
)} )}
</div> </ContentCard>
</div>
); );
})} })}
</div> </div>
<p className="muted" style={{ marginTop: "1.5rem", marginBottom: 0 }}> <p className="muted" style={{ margin: 0 }}>
Connecting takes you to the provider to authorise, then back here. If your Connecting takes you to the provider to authorise, then back here. If your social account's
social account's e-mail doesn't match a hotel account, you'll be returned e-mail doesn't match a hotel account, you'll be returned to the login screen with a note.
to the login screen with a note.
</p> </p>
</main> </main>
); );
+32 -24
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { updateMotto } from "@/actions/user-settings"; import { updateMotto } from "@/actions/user-settings";
import { ContentCard } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -42,18 +43,25 @@ export default async function SettingsPage() {
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" }); const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Account settings</h1> <ContentCard
icon="⚙️"
title="Account settings"
subtitle="Manage your profile, security and connected accounts"
/>
<div <ContentCard icon="🙂" title={user.username} subtitle="Your public profile">
className="card" <div style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}>
style={{ display: "flex", gap: "1.5rem", alignItems: "center", marginBottom: "1.5rem" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="avatar" src={avatar} alt={`${user.username} avatar`} width={100} height={150} /> <img
<div> className="avatar"
<h2 style={{ margin: "0 0 0.25rem" }}>{user.username}</h2> src={avatar}
<p style={{ margin: "0 0 0.5rem" }}> alt={`${user.username} avatar`}
width={100}
height={150}
/>
<div style={{ minWidth: 0 }}>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
{user.motto || <span className="muted">No motto</span>} {user.motto || <span className="muted">No motto</span>}
</p> </p>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
@@ -61,10 +69,13 @@ export default async function SettingsPage() {
</p> </p>
</div> </div>
</div> </div>
</ContentCard>
<div className="card" style={{ maxWidth: 520 }}> <ContentCard icon="✏️" title="Change motto" subtitle="Updates instantly in-game if you are online">
<h3 style={{ marginTop: 0 }}>Change motto</h3> <form
<form action={updateMotto} style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}> action={updateMotto}
style={{ display: "flex", flexDirection: "column", gap: "0.75rem", maxWidth: 520 }}
>
<input <input
name="motto" name="motto"
defaultValue={user.motto} defaultValue={user.motto}
@@ -78,29 +89,26 @@ export default async function SettingsPage() {
</button> </button>
</div> </div>
</form> </form>
<p className="muted" style={{ marginBottom: 0 }}> </ContentCard>
Updates instantly in-game if you are online.
</p>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}> <div className="card-grid sm-2">
<h3 style={{ marginTop: 0 }}>Security</h3> <ContentCard icon="🔒" title="Security" subtitle="Two-factor authentication">
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Protect your account with two-factor authentication. Add a second layer of protection to your account.
</p> </p>
<Link href="/settings/2fa" className="btn btn-outline"> <Link href="/settings/2fa" className="btn btn-outline">
Two-factor authentication Two-factor authentication
</Link> </Link>
</div> </ContentCard>
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}> <ContentCard icon="🔗" title="Connected accounts" subtitle="Sign in with one click">
<h3 style={{ marginTop: 0 }}>Connected accounts</h3>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Link Discord or Google to sign in with one click. Link Discord or Google to your hotel account.
</p> </p>
<Link href="/settings/connections" className="btn btn-outline"> <Link href="/settings/connections" className="btn btn-outline">
Manage connections Manage connections
</Link> </Link>
</ContentCard>
</div> </div>
</main> </main>
); );
+26 -17
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -104,29 +105,25 @@ export default async function ShopPage({
: undefined; : undefined;
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ marginBottom: "0.25rem" }}>Shop</h1> icon="🛒"
<p className="muted" style={{ margin: 0 }}> title="Shop"
{activeCategory subtitle={
activeCategory
? `Browsing the ${activeCategory.name} packages.` ? `Browsing the ${activeCategory.name} packages.`
: "Top up your account and grab exclusive packages, currency and badges."} : "Top up your account and grab exclusive packages, currency and badges."
</p> }
</div> >
{categories.length > 0 ? ( {categories.length > 0 ? (
<div <div
style={{ style={{
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
gap: "0.5rem", gap: "0.5rem",
marginBottom: "1.5rem",
}} }}
> >
<Link <Link href="/shop" className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}>
href="/shop"
className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}
>
All All
</Link> </Link>
{categories.map((c) => ( {categories.map((c) => (
@@ -140,11 +137,22 @@ export default async function ShopPage({
))} ))}
</div> </div>
) : null} ) : null}
</ContentCard>
<ContentCard
icon="📦"
title={activeCategory ? activeCategory.name : "All packages"}
subtitle={
articles.length > 0
? `${articles.length} ${articles.length === 1 ? "package" : "packages"} available`
: undefined
}
padded={articles.length === 0}
>
{articles.length === 0 ? ( {articles.length === 0 ? (
<p className="muted">No packages available right now.</p> <EmptyState icon="🛒">No packages available right now.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => ( {articles.map((a) => (
<article key={String(a.id)} className="card hover"> <article key={String(a.id)} className="card hover">
<div <div
@@ -163,7 +171,7 @@ export default async function ShopPage({
display: "grid", display: "grid",
placeItems: "center", placeItems: "center",
background: "#f8fafc", background: "#f8fafc",
border: `2px solid ${a.color || "var(--color-golden)"}`, border: `2px solid ${a.color || "var(--border-color)"}`,
flexShrink: 0, flexShrink: 0,
overflow: "hidden", overflow: "hidden",
}} }}
@@ -234,6 +242,7 @@ export default async function ShopPage({
))} ))}
</div> </div>
)} )}
</ContentCard>
</main> </main>
); );
} }
+17 -22
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import TopUpForm from "./TopUpForm"; import TopUpForm from "./TopUpForm";
import { ContentCard, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal"; import { creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
@@ -33,23 +34,21 @@ export default async function TopUpPage({
const rate = creditsPerUnit(); const rate = creditsPerUnit();
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard
<h1 style={{ marginBottom: "0.25rem" }}>Top up with PayPal</h1> icon="💳"
<p className="muted" style={{ margin: 0 }}> title="Top up with PayPal"
Pay securely with PayPal and your credits are added the moment the payment clears. subtitle="Pay securely with PayPal and your credits are added the moment the payment clears."
</p> />
</div>
{sp.status === "cancel" ? ( {sp.status === "cancel" ? (
<div className="card" style={{ marginBottom: "1.5rem", borderColor: "var(--color-danger)" }}> <div className="card" style={{ borderColor: "var(--color-danger)" }}>
<p style={{ margin: 0 }}>Checkout was cancelled — no payment was taken.</p> <p style={{ margin: 0 }}>Checkout was cancelled — no payment was taken.</p>
</div> </div>
) : null} ) : null}
<div className="grid cols-2"> <div className="card-grid sm-2">
<div className="card"> <ContentCard icon="🪙" title="Choose an amount">
<h3 style={{ marginTop: 0 }}>Choose an amount</h3>
{configured ? ( {configured ? (
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} /> <TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
) : ( ) : (
@@ -59,22 +58,18 @@ export default async function TopUpPage({
<code>PAYPAL_API</code> for sandbox vs. live). <code>PAYPAL_API</code> for sandbox vs. live).
</p> </p>
)} )}
</div> </ContentCard>
<div className="card"> <ContentCard icon="💰" title="Your balance" subtitle={`Signed in as ${user.username}`}>
<h3 style={{ marginTop: 0 }}>Your balance</h3> <div className="stat-grid">
<p style={{ margin: "0 0 0.6rem" }}> <StatBlock value={user.credits.toLocaleString()} label="Credits" icon="🪙" />
Signed in as <strong>{user.username}</strong>. <StatBlock value={rate.toLocaleString()} label={`Credits per 1.00 ${PAYPAL_CURRENCY}`} />
</p> </div>
<span className="currency">
<span className="coin credits">cr</span>
{user.credits.toLocaleString()}
</span>
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}> <p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}. You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}.
Credits appear in-game the next time you log in. Credits appear in-game the next time you log in.
</p> </p>
</div> </ContentCard>
</div> </div>
</main> </main>
); );
+18 -9
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -53,16 +54,12 @@ export default async function StaffPage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="hero"> <ContentCard icon="🛡️" title="Staff" subtitle="The team behind the hotel" />
<h1 style={{ margin: "0 0 0.35rem" }}>Staff</h1>
<p className="muted" style={{ margin: 0 }}>
The team behind the hotel.
</p>
</div>
{teams.length > 0 ? ( {teams.length > 0 ? (
<div className="grid cols-3" style={{ marginBottom: "1.5rem" }}> <ContentCard icon="⭐" title="Ranks" subtitle="What each part of the team takes care of">
<div className="card-grid sm-2 lg-3">
{teams.map((t) => ( {teams.map((t) => (
<div key={t.id} className="card"> <div key={t.id} className="card">
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3> <h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3>
@@ -74,10 +71,21 @@ export default async function StaffPage() {
</div> </div>
))} ))}
</div> </div>
</ContentCard>
) : null} ) : null}
<ContentCard
icon="👥"
title="Team members"
subtitle={
staff.length === 0
? "Nobody to show right now"
: `${staff.length} member${staff.length === 1 ? "" : "s"} keeping things running`
}
padded={staff.length === 0}
>
{staff.length === 0 ? ( {staff.length === 0 ? (
<p className="muted">No staff members to show.</p> <EmptyState icon="🛡️">No staff members to show.</EmptyState>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -100,6 +108,7 @@ export default async function StaffPage() {
</tbody> </tbody>
</table> </table>
)} )}
</ContentCard>
</main> </main>
); );
} }
+35 -25
View File
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -88,31 +89,43 @@ export default async function ProfilePage({
const authorById = new Map(authors.map((a) => [a.id, a])); const authorById = new Map(authors.map((a) => [a.id, a]));
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<div className="card" style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}> {/* ── Profile header ───────────────────────────────────── */}
<ContentCard padded={false}>
<div
className="content-card-body"
style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={avatar} alt={`${user.username} avatar`} width={100} height={150} /> <img src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
<div> <div style={{ minWidth: 0, flex: 1 }}>
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1> <h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
<p style={{ margin: "0 0 0.5rem" }}> <p style={{ margin: "0 0 0.6rem" }}>
{user.motto || <span className="muted">No motto</span>} {user.motto || <span className="muted">No motto</span>}
</p> </p>
<p className="muted" style={{ margin: 0 }}> <OnlineBadge online={user.online === "1"} />
Rank {user.rank} · {user.credits} credits · {user.online === "1" ? "Online" : "Offline"} ·
Registered {registered}
</p>
</div> </div>
</div> </div>
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
<StatBlock icon="🏅" value={user.rank} label="Rank" />
<StatBlock icon="💰" value={user.credits.toLocaleString()} label="Credits" />
<StatBlock icon="📅" value={registered} label="Registered" />
</div>
</ContentCard>
{/* ── Badges ───────────────────────────────────────────── */} {/* ── Badges ───────────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}> <ContentCard icon="🏅" title="Badges" padded={badges.length === 0}>
<h2>Badges</h2>
{badges.length === 0 ? ( {badges.length === 0 ? (
<p className="muted">No badges yet.</p> <EmptyState icon="🏅">No badges yet.</EmptyState>
) : ( ) : (
<div <div
className="card" style={{
style={{ display: "flex", flexWrap: "wrap", gap: "0.75rem", alignItems: "center" }} display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
alignItems: "center",
padding: "1rem",
}}
> >
{badges.map((b) => ( {badges.map((b) => (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
@@ -127,15 +140,14 @@ export default async function ProfilePage({
))} ))}
</div> </div>
)} )}
</section> </ContentCard>
{/* ── Photos ───────────────────────────────────────────── */} {/* ── Photos ───────────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}> <ContentCard icon="📸" title="Photos" padded={photos.length === 0}>
<h2>Photos</h2>
{photos.length === 0 ? ( {photos.length === 0 ? (
<p className="muted">No photos yet.</p> <EmptyState icon="📸">No photos yet.</EmptyState>
) : ( ) : (
<div className="grid cols-3"> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{photos.map((p) => ( {photos.map((p) => (
<div key={p.id} className="card" style={{ padding: "0.5rem" }}> <div key={p.id} className="card" style={{ padding: "0.5rem" }}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -151,12 +163,10 @@ export default async function ProfilePage({
))} ))}
</div> </div>
)} )}
</section> </ContentCard>
{/* ── Guestbook ────────────────────────────────────────── */} {/* ── Guestbook ────────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}> <ContentCard icon="📝" title="Guestbook" subtitle={`Messages on ${user.username}'s wall`}>
<h2>Guestbook</h2>
{isLoggedIn ? ( {isLoggedIn ? (
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}> <form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>
{/* Profile owner id drives the write target; the author is taken from {/* Profile owner id drives the write target; the author is taken from
@@ -184,9 +194,9 @@ export default async function ProfilePage({
)} )}
{guestbook.length === 0 ? ( {guestbook.length === 0 ? (
<p className="muted">No guestbook entries yet.</p> <EmptyState icon="📝">No guestbook entries yet.</EmptyState>
) : ( ) : (
<div className="grid"> <div className="grid" style={{ gap: "0.75rem" }}>
{guestbook.map((g) => { {guestbook.map((g) => {
const author = authorById.get(g.userId); const author = authorById.get(g.userId);
const authorAvatar = author const authorAvatar = author
@@ -220,7 +230,7 @@ export default async function ProfilePage({
})} })}
</div> </div>
)} )}
</section> </ContentCard>
</main> </main>
); );
} }
+22 -31
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { isValidVerificationToken } from "@/actions/email-verify"; import { isValidVerificationToken } from "@/actions/email-verify";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Reads + writes the live users table — must never be statically rendered. // Reads + writes the live users table — must never be statically rendered.
@@ -50,54 +51,44 @@ export default async function VerifyPage({
} }
return ( return (
<main> <main style={{ maxWidth: 480, margin: "2rem auto" }}>
<h1>Email verification</h1>
<div className="card" style={{ marginTop: "1rem" }}>
{status === "verified" ? ( {status === "verified" ? (
<> <ContentCard icon="✅" title="You're all set" subtitle="Email verified">
<h2 style={{ marginTop: 0 }}>You're all set</h2> <p className="muted" style={{ marginTop: 0 }}>
<p className="muted">
Your email address has been verified. You can now log in and play. Your email address has been verified. You can now log in and play.
</p> </p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-primary"> <Link href="/login" className="btn btn-primary">
Go to login Go to login
</Link> </Link>
</p> </ContentCard>
</>
) : status === "already" ? ( ) : status === "already" ? (
<> <ContentCard icon="✅" title="Already verified" subtitle="Nothing left to do">
<h2 style={{ marginTop: 0 }}>Already verified</h2> <p className="muted" style={{ marginTop: 0 }}>
<p className="muted">This email address has already been confirmed.</p> This email address has already been confirmed.
<p style={{ marginTop: "1rem" }}> </p>
<Link href="/login" className="btn btn-primary"> <Link href="/login" className="btn btn-primary">
Go to login Go to login
</Link> </Link>
</p> </ContentCard>
</>
) : status === "unavailable" ? ( ) : status === "unavailable" ? (
<> <ContentCard icon="⏳" title="Try again shortly" subtitle="Server unreachable">
<h2 style={{ marginTop: 0 }}>Try again shortly</h2> <p className="muted" style={{ margin: 0 }}>
<p className="muted"> We couldn't reach the server to verify your email. Please retry the link in a moment.
We couldn't reach the server to verify your email. Please retry the link in a
moment.
</p> </p>
</> </ContentCard>
) : ( ) : (
<> <ContentCard icon="📧" title="Email verification" subtitle="Invalid or expired link">
<h2 style={{ marginTop: 0 }}>Invalid or expired link</h2> <EmptyState icon="🔗">
<p className="muted"> This verification link is not valid. Make sure you opened the full link from your email,
This verification link is not valid. Make sure you opened the full link from your or request a new one.
email, or request a new one. </EmptyState>
</p> <div style={{ textAlign: "center" }}>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-outline"> <Link href="/login" className="btn btn-outline">
Back to login Back to login
</Link> </Link>
</p>
</>
)}
</div> </div>
</ContentCard>
)}
</main> </main>
); );
} }
+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
/**
* Public-site design system, faithful to AtomCMS's "atom" theme. The signature
* element is the content-card: a surface card with a primary-tinted header
* (icon circle + title + subtitle) and a padded body. These mirror the atom
* Blade components (content-card, stat blocks, empty states) so every public
* page shares one consistent, themeable look.
*/
export function ContentCard({
icon,
title,
subtitle,
action,
children,
className,
padded = true,
}: {
icon?: string;
title?: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
children?: ReactNode;
className?: string;
padded?: boolean;
}) {
const hasHead = title != null || icon != null || subtitle != null || action != null;
return (
<div className={`content-card${className ? ` ${className}` : ""}`}>
{hasHead ? (
<div className="content-card-head">
{icon ? (
<span className="content-card-icon" aria-hidden>
{icon}
</span>
) : null}
<div className="content-card-head-text">
{title != null ? <p className="content-card-title">{title}</p> : null}
{subtitle != null ? <p className="content-card-subtitle">{subtitle}</p> : null}
</div>
{action ? <div className="content-card-action">{action}</div> : null}
</div>
) : null}
<div className={padded ? "content-card-body" : undefined}>{children}</div>
</div>
);
}
/** A value + label tile (profile stats, rare-value summaries, …). */
export function StatBlock({
value,
label,
icon,
}: {
value: ReactNode;
label: string;
icon?: string;
}) {
return (
<div className="stat-block">
<div className="stat-block-value">
{icon ? <span aria-hidden>{icon} </span> : null}
{value}
</div>
<div className="stat-block-label">{label}</div>
</div>
);
}
/** Friendly empty state with an icon and a muted line. */
export function EmptyState({ icon = "📭", children }: { icon?: string; children: ReactNode }) {
return (
<div className="empty-state">
<div className="empty-state-icon" aria-hidden>
{icon}
</div>
<p className="muted" style={{ margin: 0 }}>
{children}
</p>
</div>
);
}
/** Online / offline pill with a status dot. */
export function OnlineBadge({ online }: { online: boolean }) {
return (
<span className={`online-badge${online ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{online ? "Online" : "Offline"}
</span>
);
}
/** Leaderboard rank chip — medals for the top three, number otherwise. */
export function RankBadge({ position }: { position: number }) {
const medal = position === 1 ? "🥇" : position === 2 ? "🥈" : position === 3 ? "🥉" : null;
return (
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>
{medal ?? `#${position}`}
</span>
);
}