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
+2264 -1714

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+38 -36
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,44 +14,45 @@ 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>
{badges.length === 0 ? ( <ContentCard padded={badges.length === 0}>
<p className="muted">No badges available yet.</p> {badges.length === 0 ? (
) : ( <EmptyState icon="🏅">No badges available yet.</EmptyState>
<div className="grid cols-3"> ) : (
{badges.map((badge) => ( <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
<div {badges.map((badge) => (
key={String(badge.id)} <div
className="card hover" key={String(badge.id)}
style={{ display: "flex", gap: "0.9rem", alignItems: "flex-start" }} className="card hover"
> style={{ display: "flex", gap: "0.9rem", alignItems: "flex-start" }}
{/* eslint-disable-next-line @next/next/no-img-element */} >
<img {/* eslint-disable-next-line @next/next/no-img-element */}
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`} <img
alt={badge.badgeName} src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
title={badge.badgeKey} alt={badge.badgeName}
width={40} title={badge.badgeKey}
height={40} width={40}
style={{ flexShrink: 0 }} height={40}
/> style={{ flexShrink: 0 }}
<div style={{ minWidth: 0 }}> />
<h3 style={{ margin: "0 0 0.25rem" }}>{badge.badgeName}</h3> <div style={{ minWidth: 0 }}>
<p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}> <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>{badge.badgeName}</h3>
{badge.badgeKey} <p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}>
</p> {badge.badgeKey}
<p style={{ margin: 0, fontSize: "0.9rem" }}>{badge.badgeDescription}</p> </p>
<p style={{ margin: 0, fontSize: "0.9rem" }}>{badge.badgeDescription}</p>
</div>
</div> </div>
</div> ))}
))} </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>
); );
} }
+21 -11
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 ? (
<iframe <ContentCard padded={false}>
title="Hotel client" <iframe
src={src} title="Hotel client"
style={{ width: "100%", height: "80vh", border: 0, borderRadius: 10 }} src={src}
/> 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">
No client URL configured (set <code>nitro_client_url</code> in settings). SSO ticket <EmptyState icon="🔌">
issued for this session: <code>{ticket}</code> No client URL configured (set <code>nitro_client_url</code> in settings). SSO ticket
</p> issued for this session: <code>{ticket}</code>
</EmptyState>
</ContentCard>
)} )}
</main> </main>
); );
+49 -14
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}
<p className="muted" style={{ margin: "0 0 0.75rem" }}> href={l.href}
{l.text} className="content-card hover-lift"
</p> style={{ color: "inherit", textDecoration: "none" }}
<span className="btn btn-outline">Open →</span> >
<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" }}>
{l.text}
</p>
<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>
); );
} }
File diff suppressed because it is too large. Load diff
+161
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+23 -18
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>
}
/>
{threads.length === 0 ? ( <ContentCard padded={threads.length === 0}>
<p className="muted">No threads in this forum yet.</p> {threads.length === 0 ? (
) : ( <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>
); );
} }
File diff suppressed because it is too large. Load diff
+49 -39
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,45 +29,54 @@ 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
{guilds.length === 0 ? ( icon="🚪"
<p className="muted">No guilds yet.</p> title="Guilds"
) : ( subtitle="Browse the groups players have created and joined"
<div className="grid cols-3"> />
{guilds.map((g) => (
<Link <ContentCard padded={guilds.length === 0}>
key={g.id} {guilds.length === 0 ? (
href={`/guilds/${g.id}`} <EmptyState icon="🚪">No guilds yet.</EmptyState>
className="card hover" ) : (
style={{ <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
display: "flex", {guilds.map((g) => (
gap: "0.85rem", <Link
alignItems: "flex-start", key={g.id}
color: "inherit", href={`/guilds/${g.id}`}
textDecoration: "none", className="card hover"
}} style={{
> display: "flex",
{g.badge ? ( gap: "0.85rem",
// eslint-disable-next-line @next/next/no-img-element alignItems: "flex-start",
<img color: "inherit",
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`} textDecoration: "none",
alt={`${g.name} badge`} }}
width={40} >
height={40} {g.badge ? (
style={{ flex: "0 0 auto" }} // eslint-disable-next-line @next/next/no-img-element
/> <img
) : null} src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
<div style={{ minWidth: 0 }}> alt={`${g.name} badge`}
<h3 style={{ margin: "0 0 0.25rem" }}>{g.name || "Unnamed guild"}</h3> width={40}
<p className="muted" style={{ margin: 0 }}> height={40}
{g.description ? excerpt(g.description, 120) : "No description"} style={{ flex: "0 0 auto" }}
</p> />
</div> ) : null}
</Link> <div style={{ minWidth: 0 }}>
))} <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
</div> {g.name || "Unnamed guild"}
)} </h3>
<p className="muted" style={{ margin: 0 }}>
{g.description ? excerpt(g.description, 120) : "No description"}
</p>
</div>
</Link>
))}
</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>
); );
+129 -112
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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>
); );
} }
File diff suppressed because it is too large. Load diff
+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>
); );
} }
+33 -27
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
{article.image ? ( </Link>
// eslint-disable-next-line @next/next/no-img-element }
<img >
src={article.image} {article.image ? (
alt="" // eslint-disable-next-line @next/next/no-img-element
style={{ width: "100%", borderRadius: 10, margin: "1rem 0" }} <img
/> src={article.image}
) : null} alt=""
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div> style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
/>
) : null}
<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>
); );
} }
+31 -19
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,25 +22,36 @@ export default async function NewsPage() {
} }
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>News</h1> <ContentCard
{articles.length === 0 ? ( icon="📰"
<p className="muted">No articles yet.</p> title="News"
) : ( subtitle="The latest announcements, events and updates from the hotel"
<div className="grid"> />
{articles.map((a) => (
<article key={a.slug} className="card"> <ContentCard padded={articles.length === 0}>
<h3 style={{ margin: "0 0 0.25rem" }}> {articles.length === 0 ? (
<Link href={`/news/${a.slug}`}>{a.title}</Link> <EmptyState icon="📰">No articles yet.</EmptyState>
</h3> ) : (
<p className="muted" style={{ marginTop: 0 }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{formatDate(a.createdAt)} {articles.map((a) => (
</p> <article
<p style={{ margin: 0 }}>{excerpt(a.shortStory, 200)}</p> key={a.slug}
</article> className="card hover"
))} style={{ display: "flex", flexDirection: "column" }}
</div> >
)} <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3>
<p className="muted" style={{ margin: "0 0 0.5rem", fontSize: "0.85rem" }}>
{formatDate(a.createdAt)}
</p>
<p style={{ margin: 0 }}>{excerpt(a.shortStory, 200)}</p>
</article>
))}
</div>
)}
</ContentCard>
</main> </main>
); );
} }
+50 -27
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
{articles.length === 0 ? ( icon="📰"
<p className="muted">No articles yet.</p> title="Latest news"
) : ( subtitle="Fresh from the hotel"
<div className="grid cols-2"> action={
{articles.map((a) => ( <Link className="btn btn-outline" href="/news">
<article key={a.slug} className="card hover article"> All news →
{a.image ? <img className="article-img" src={a.image} alt="" /> : <div className="article-img" />} </Link>
<div className="body"> }
<h3 style={{ margin: "0 0 0.3rem" }}> >
<Link href={`/news/${a.slug}`}>{a.title}</Link> {articles.length === 0 ? (
</h3> <EmptyState icon="📰">No articles yet.</EmptyState>
<p className="muted" style={{ margin: 0 }}> ) : (
{excerpt(a.shortStory, 120)} <div className="card-grid sm-2 lg-4">
</p> {articles.map((a) => (
</div> <article
</article> key={a.slug}
))} className="card hover"
</div> 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>
</h3>
<p className="muted" style={{ margin: 0 }}>
{excerpt(a.shortStory, 110)}
</p>
</div>
</article>
))}
</div>
)}
</ContentCard>
</main> </main>
); );
} }
+37 -34
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,41 +30,43 @@ 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>
{photos.length === 0 ? ( <ContentCard padded={photos.length === 0}>
<p className="muted">No photos yet.</p> {photos.length === 0 ? (
) : ( <EmptyState icon="📸">No photos yet.</EmptyState>
<div className="grid cols-3"> ) : (
{photos.map((p) => ( <div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
<article key={String(p.id)} className="card"> {photos.map((p) => (
{/* eslint-disable-next-line @next/next/no-img-element */} <article key={String(p.id)} className="card hover" style={{ padding: 0, overflow: "hidden" }}>
<img {/* eslint-disable-next-line @next/next/no-img-element */}
src={p.url} <img
alt={`Photo by user #${p.userId}`} src={p.url}
style={{ alt={`Photo by user #${p.userId}`}
width: "100%", style={{
height: "auto", width: "100%",
borderRadius: 4, height: "auto",
display: "block", display: "block",
}} }}
/> />
<p className="muted" style={{ margin: "0.5rem 0 0" }}> <div style={{ padding: "0.7rem 0.85rem" }}>
{formatDate(p.timestamp)} <p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
</p> {formatDate(p.timestamp)}
<p className="muted" style={{ margin: 0 }}> </p>
by user #{p.userId} <p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
</p> by user #{p.userId}
</article> </p>
))} </div>
</div> </article>
)} ))}
</div>
)}
</ContentCard>
</main> </main>
); );
} }
File diff suppressed because it is too large. Load diff
+33 -26
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>
{contests.length === 0 ? ( <ContentCard padded={contests.length === 0}>
<p className="muted">No contests yet.</p> {contests.length === 0 ? (
) : ( <EmptyState icon="🎉">No contests yet.</EmptyState>
<div className="grid cols-2"> ) : (
{contests.map((c) => { <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
const id = c.id.toString(); {contests.map((c) => {
return ( const id = c.id.toString();
<article key={id} className="card hover"> return (
<h3 style={{ margin: "0 0 0.25rem" }}> <Link
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link> key={id}
</h3> href={`/radio/contests/${id}`}
<p className="muted" style={{ margin: 0 }}> className="card hover"
{formatDate(c.createdAt) || "Date unknown"} style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
</p> >
</article> <h3 style={{ margin: 0, fontSize: "1rem" }}>
); <span aria-hidden>🎉</span> Contest #{id}
})} </h3>
</div> <p className="muted" style={{ margin: 0 }}>
)} {formatDate(c.createdAt) || "Date unknown"}
</p>
</Link>
);
})}
</div>
)}
</ContentCard>
</main> </main>
); );
} }
+33 -26
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>
{giveaways.length === 0 ? ( <ContentCard padded={giveaways.length === 0}>
<p className="muted">No giveaways yet.</p> {giveaways.length === 0 ? (
) : ( <EmptyState icon="🎁">No giveaways yet.</EmptyState>
<div className="grid cols-2"> ) : (
{giveaways.map((g) => { <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
const id = g.id.toString(); {giveaways.map((g) => {
return ( const id = g.id.toString();
<article key={id} className="card hover"> return (
<h3 style={{ margin: "0 0 0.25rem" }}> <Link
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link> key={id}
</h3> href={`/radio/giveaways/${id}`}
<p className="muted" style={{ margin: 0 }}> className="card hover"
{formatDate(g.createdAt) || "Date unknown"} style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
</p> >
</article> <h3 style={{ margin: 0, fontSize: "1rem" }}>
); <span aria-hidden>🎁</span> Giveaway #{id}
})} </h3>
</div> <p className="muted" style={{ margin: 0 }}>
)} {formatDate(g.createdAt) || "Date unknown"}
</p>
</Link>
);
})}
</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>
); );
} }
+88 -64
View File
File diff suppressed because it is too large. Load diff
+13 -11
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"
/>
{!hasAny ? ( <ContentCard padded={!hasAny}>
<p className="muted">No shows scheduled yet.</p> {!hasAny ? (
) : ( <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>
); );
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+38 -35
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",
}}
> >
{badgeSrc ? ( <div className="content-card-head">
// eslint-disable-next-line @next/next/no-img-element {badgeSrc ? (
<img // eslint-disable-next-line @next/next/no-img-element
src={badgeSrc} <img
alt={`${c.name} badge`} src={badgeSrc}
width={40} alt={`${c.name} badge`}
height={40} width={36}
style={{ flexShrink: 0 }} height={36}
/> style={{ flexShrink: 0 }}
) : ( />
<span className="avatar" style={{ width: 40, height: 40, flexShrink: 0 }} /> ) : (
)} <span className="content-card-icon" aria-hidden>
<span style={{ display: "flex", flexDirection: "column" }}> 💎
<strong>{c.name}</strong> </span>
<span className="muted"> )}
{count} {count === 1 ? "rare" : "rares"} <div className="content-card-head-text">
</span> <p className="content-card-title">{c.name}</p>
</span> <p className="content-card-subtitle">
{count} {count === 1 ? "rare" : "rares"}
</p>
</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>
); );
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+128 -119
View File
File diff suppressed because it is too large. Load diff
+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>
); );
+51 -42
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+39 -48
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> {status === "verified" ? (
<div className="card" style={{ marginTop: "1rem" }}> <ContentCard icon="✅" title="You're all set" subtitle="Email verified">
{status === "verified" ? ( <p className="muted" style={{ marginTop: 0 }}>
<> Your email address has been verified. You can now log in and play.
<h2 style={{ marginTop: 0 }}>You're all set</h2> </p>
<p className="muted"> <Link href="/login" className="btn btn-primary">
Your email address has been verified. You can now log in and play. Go to login
</p> </Link>
<p style={{ marginTop: "1rem" }}> </ContentCard>
<Link href="/login" className="btn btn-primary"> ) : status === "already" ? (
Go to login <ContentCard icon="✅" title="Already verified" subtitle="Nothing left to do">
</Link> <p className="muted" style={{ marginTop: 0 }}>
</p> This email address has already been confirmed.
</> </p>
) : status === "already" ? ( <Link href="/login" className="btn btn-primary">
<> Go to login
<h2 style={{ marginTop: 0 }}>Already verified</h2> </Link>
<p className="muted">This email address has already been confirmed.</p> </ContentCard>
<p style={{ marginTop: "1rem" }}> ) : status === "unavailable" ? (
<Link href="/login" className="btn btn-primary"> <ContentCard icon="⏳" title="Try again shortly" subtitle="Server unreachable">
Go to login <p className="muted" style={{ margin: 0 }}>
</Link> We couldn't reach the server to verify your email. Please retry the link in a moment.
</p> </p>
</> </ContentCard>
) : status === "unavailable" ? ( ) : (
<> <ContentCard icon="📧" title="Email verification" subtitle="Invalid or expired link">
<h2 style={{ marginTop: 0 }}>Try again shortly</h2> <EmptyState icon="🔗">
<p className="muted"> This verification link is not valid. Make sure you opened the full link from your email,
We couldn't reach the server to verify your email. Please retry the link in a or request a new one.
moment. </EmptyState>
</p> <div style={{ textAlign: "center" }}>
</> <Link href="/login" className="btn btn-outline">
) : ( Back to login
<> </Link>
<h2 style={{ marginTop: 0 }}>Invalid or expired link</h2> </div>
<p className="muted"> </ContentCard>
This verification link is not valid. Make sure you opened the full link from your )}
email, or request a new one.
</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-outline">
Back to login
</Link>
</p>
</>
)}
</div>
</main> </main>
); );
} }
File diff suppressed because it is too large. Load diff