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

+61 -59
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,48 +80,59 @@ 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> 🛡️
</span>
)}
<div className="content-card-head-text">
<p className="content-card-title" style={{ color: team?.staffColor || undefined }}>
{rankName}
</p>
{team?.jobDescription ? ( {team?.jobDescription ? (
<p className="muted" style={{ margin: 0 }}> <p className="content-card-subtitle">{team.jobDescription}</p>
{team.jobDescription}
</p>
) : null} ) : null}
</div> </div>
</div> </div>
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p> <div className="content-card-body">
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
Application deadline: {formatDeadline(position.applyTo)} <p className="muted" style={{ margin: "0 0 0.75rem" }}>
</p> Application deadline: {formatDeadline(position.applyTo)}
</p>
{alreadyApplied ? ( {alreadyApplied ? (
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}> <button
You have already applied for {rankName} type="button"
</button> className="btn btn-danger"
) : ( disabled
<form action={applyStaff}> style={{ width: "100%" }}
{/* rank_id is the position's permission id; the applicant is >
re-read from the session inside the action. */} You have already applied for {rankName}
<input type="hidden" name="rankId" value={String(position.permissionId)} />
<label htmlFor={`content-${position.id}`} className="muted">
About you
</label>
<textarea
id={`content-${position.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={`Tell us why you'd be a great ${rankName}…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-secondary" style={{ width: "100%" }}>
Apply for {rankName}
</button> </button>
</form> ) : (
)} <form action={applyStaff}>
{/* rank_id is the position's permission id; the applicant is
re-read from the session inside the action. */}
<input type="hidden" name="rankId" value={String(position.permissionId)} />
<label htmlFor={`content-${position.id}`} className="muted">
About you
</label>
<textarea
id={`content-${position.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={`Tell us why you'd be a great ${rankName}…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-secondary" style={{ width: "100%" }}>
Apply for {rankName}
</button>
</form>
)}
</div>
</article> </article>
); );
})} })}
+56 -56
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,45 +66,54 @@ 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> </p>
{team.jobDescription ? ( {team.jobDescription ? (
<p className="muted" style={{ margin: 0 }}> <p className="content-card-subtitle">{team.jobDescription}</p>
{team.jobDescription}
</p>
) : null} ) : null}
</div> </div>
</div> </div>
{alreadyApplied ? ( <div className="content-card-body">
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}> {alreadyApplied ? (
Your application is pending <button
</button> type="button"
) : ( className="btn btn-danger"
<form action={applyTeam}> disabled
{/* The team id is the application's rank flag; the applicant style={{ width: "100%" }}
is re-read from the session inside the action. */} >
<input type="hidden" name="teamId" value={String(team.id)} /> Your application is pending
<label htmlFor={`content-${team.id}`} className="muted">
About you
</label>
<textarea
id={`content-${team.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={`Tell us why you'd be a great fit for ${team.rankName}…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
Apply for {team.rankName}
</button> </button>
</form> ) : (
)} <form action={applyTeam}>
{/* The team id is the application's rank flag; the applicant
is re-read from the session inside the action. */}
<input type="hidden" name="teamId" value={String(team.id)} />
<label htmlFor={`content-${team.id}`} className="muted">
About you
</label>
<textarea
id={`content-${team.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={`Tell us why you'd be a great fit for ${team.rankName}…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
Apply for {team.rankName}
</button>
</form>
)}
</div>
</article> </article>
); );
})} })}
+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>
); );
} }
+57 -67
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,78 +51,67 @@ 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"}`
}
/>
{friends.length === 0 ? ( <ContentCard padded={friends.length === 0}>
<div className="card"> {friends.length === 0 ? (
<p className="muted" style={{ margin: 0 }}> <EmptyState icon="🤝">
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="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
<div className="grid cols-3"> {friends.map((friend) => {
{friends.map((friend) => { const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, { size: "s",
size: "s", headOnly: true,
headOnly: true, });
}); const isOnline = friend.online === "1";
const isOnline = friend.online === "1"; return (
return ( <div
<div key={friend.id}
key={friend.id} className="card hover"
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 */} <img
<img className="avatar"
className="avatar" src={avatar}
src={avatar} alt={`${friend.username} avatar`}
alt={`${friend.username} avatar`} width={50}
width={50} height={50}
height={50} />
/> <div style={{ minWidth: 0, flex: 1 }}>
<div style={{ minWidth: 0, flex: 1 }}> <h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<p style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.4rem" }}> <Link href={`/u/${friend.username}`}>{friend.username}</Link>
<span </h3>
aria-hidden <p
title={isOnline ? "Online" : "Offline"} className="muted"
style={{ style={{
width: 9, margin: "0 0 0.4rem",
height: 9, overflow: "hidden",
borderRadius: "999px", textOverflow: "ellipsis",
flexShrink: 0, whiteSpace: "nowrap",
background: isOnline
? "var(--color-accent)"
: "var(--color-text-muted)",
}} }}
/> >
<Link href={`/u/${friend.username}`}> {friend.motto || "No motto"}
<strong>{friend.username}</strong> </p>
</Link> <OnlineBadge online={isOnline} />
</p> </div>
<p
className="muted"
style={{
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{friend.motto || "No motto"}
</p>
</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;
}
+44 -42
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,54 +34,55 @@ 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}>
{/* Guild target drives the write; the author is taken from the session
inside the action, never from this form. */}
<input type="hidden" name="guildId" value={String(guild.id)} />
<form action={postThread} className="card" style={{ marginTop: "1rem" }}> <label htmlFor="thread-subject" className="muted">
{/* Guild target drives the write; the author is taken from the session Subject
inside the action, never from this form. */} </label>
<input type="hidden" name="guildId" value={String(guild.id)} /> <input
id="thread-subject"
name="subject"
required
maxLength={255}
placeholder="Thread subject"
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }}
/>
<label htmlFor="thread-subject" className="muted"> <label htmlFor="thread-message" className="muted">
Subject Message
</label> </label>
<input <textarea
id="thread-subject" id="thread-message"
name="subject" name="message"
required required
maxLength={255} rows={8}
placeholder="Thread subject" placeholder="Write your opening post…"
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }} style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }}
/> />
<label htmlFor="thread-message" className="muted"> <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
Message <button type="submit" className="btn btn-primary">
</label> Post thread
<textarea </button>
id="thread-message" <Link href={`/guilds/${guild.id}/forum`} className="btn btn-outline">
name="message" Cancel
required </Link>
rows={8} </div>
placeholder="Write your opening post…" </form>
style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }} </ContentCard>
/>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary">
Post thread
</button>
<Link href={`/guilds/${guild.id}/forum`} className="btn btn-outline">
Cancel
</Link>
</div>
</form>
</main> </main>
); );
} }
+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>
); );
} }
+85 -62
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,74 +119,96 @@ 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 ? (
> // eslint-disable-next-line @next/next/no-img-element
{guild.badge ? ( <img
// eslint-disable-next-line @next/next/no-img-element src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
<img alt={`${guild.name} badge`}
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`} width={48}
alt={`${guild.name} badge`} height={48}
width={48} style={{ flex: "0 0 auto" }}
height={48} />
style={{ flex: "0 0 auto" }} ) : null}
/> <div style={{ minWidth: 0, flex: 1 }}>
) : null} <h1 style={{ margin: "0 0 0.35rem" }}>{guild.name || "Unnamed guild"}</h1>
<div style={{ minWidth: 0 }}> <p style={{ margin: 0 }}>
<h1 style={{ margin: "0 0 0.25rem" }}>{guild.name || "Unnamed guild"}</h1> {guild.description || <span className="muted">No description</span>}
<p style={{ margin: "0 0 0.5rem" }}> </p>
{guild.description || <span className="muted">No description</span>} </div>
</p>
<p className="muted" style={{ margin: 0 }}>
{members.length} {members.length === 1 ? "member" : "members"}
{created ? ` · Founded ${created}` : ""}
</p>
</div> </div>
</div>
<h2 style={{ marginTop: "1.5rem" }}>Members</h2> <div className="stat-grid" style={{ marginTop: "1.1rem" }}>
{members.length === 0 ? ( <StatBlock
<p className="muted">No members.</p> icon="👥"
) : ( value={members.length}
<div className="grid cols-3"> label={members.length === 1 ? "Member" : "Members"}
{members.map((m) => ( />
<div {created ? <StatBlock icon="📅" value={created} label="Founded" /> : null}
key={m.userId} <StatBlock
className="card" icon="🚪"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }} value={
> <Link href={`/guilds/${guild.id}/forum`} style={{ textDecoration: "none" }}>
{/* eslint-disable-next-line @next/next/no-img-element */} Forum →
<img </Link>
className="avatar" }
src={avatarImageUrl(imagerBase ?? "", m.look, { size: "l" })} label="Discussion"
alt={`${m.username} avatar`} />
width={50}
height={75}
style={{ flex: "0 0 auto" }}
/>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.15rem" }}>
<Link href={`/u/${m.username}`}>{m.username}</Link>
</h3>
{m.isOwner ? (
<p className="muted" style={{ margin: "0 0 0.15rem" }}>
Owner
</p>
) : null}
<p className="muted" style={{ margin: 0 }}>
{m.motto || "No motto"}
</p>
</div>
</div>
))}
</div> </div>
)} </ContentCard>
<ContentCard
icon="👥"
title="Members"
subtitle={`${members.length} ${members.length === 1 ? "member" : "members"}`}
padded={members.length === 0}
>
{members.length === 0 ? (
<EmptyState icon="👥">No members.</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{members.map((m) => (
<div
key={m.userId}
className="card hover"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatarImageUrl(imagerBase ?? "", m.look, { size: "l" })}
alt={`${m.username} avatar`}
width={50}
height={75}
style={{ flex: "0 0 auto" }}
/>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.2rem", fontSize: "1rem" }}>
<Link href={`/u/${m.username}`}>{m.username}</Link>
</h3>
{m.isOwner ? (
<span
className="online-badge online"
style={{ marginBottom: "0.25rem" }}
>
👑 Owner
</span>
) : null}
<p className="muted" style={{ margin: m.isOwner ? "0.25rem 0 0" : 0 }}>
{m.motto || "No motto"}
</p>
</div>
</div>
))}
</div>
)}
</ContentCard>
</main> </main>
); );
} }
+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
@@ -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,122 +88,138 @@ 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
{categories.length === 0 ? ( icon="🧭"
<p className="muted">No help topics yet.</p> title="Support topics"
) : ( subtitle="Browse guides and answers to common questions"
<div className="grid cols-2"> padded={categories.length === 0}
{categories.map((c) => ( >
<article key={String(c.id)} className="card hover"> {categories.length === 0 ? (
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}> <EmptyState icon="🧭">No help topics yet.</EmptyState>
{c.imageUrl ? ( ) : (
// eslint-disable-next-line @next/next/no-img-element <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
<img {categories.map((c) => (
src={c.imageUrl} <article key={String(c.id)} className="card hover">
alt="" <div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
width={48} {c.imageUrl ? (
height={48} // eslint-disable-next-line @next/next/no-img-element
style={{ flexShrink: 0, objectFit: "contain" }} <img
/> src={c.imageUrl}
) : null} alt=""
<div style={{ flex: 1 }}> width={48}
<h3 style={{ margin: "0 0 0.35rem" }}>{c.name}</h3> height={48}
<p className="muted" style={{ margin: 0 }}> style={{ flexShrink: 0, objectFit: "contain" }}
{c.content} />
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
) : null} ) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>{c.name}</h3>
<p className="muted" style={{ margin: 0 }}>
{c.content}
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
) : null}
</div>
</div> </div>
</div>
</article>
))}
</div>
)}
<h2 style={{ marginTop: "2rem" }}>Hotel rules</h2>
{ruleCategories.length === 0 ? (
<p className="muted">No rules published yet.</p>
) : (
<div className="grid">
{ruleCategories.map((cat) => {
const catRules = rulesByCategory.get(String(cat.id)) ?? [];
return (
<article key={String(cat.id)} className="card">
<div
style={{
display: "flex",
alignItems: "baseline",
gap: "0.6rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0 }}>{cat.name}</h3>
{cat.badge ? <span className="muted">{cat.badge}</span> : null}
</div>
{cat.description ? (
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
{cat.description}
</p>
) : null}
{catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}>
No rules in this category.
</p>
) : (
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}>
{catRules.map((r) => (
<li key={String(r.id)} style={{ marginBottom: "0.5rem" }}>
{r.paragraph ? (
<strong style={{ marginRight: "0.4rem" }}>{r.paragraph}</strong>
) : null}
{r.rule}
</li>
))}
</ul>
)}
</article> </article>
); ))}
})} </div>
</div> )}
)} </ContentCard>
<ContentCard
icon="📜"
title="Hotel rules"
subtitle="Please read these before hanging out in the hotel"
padded={ruleCategories.length === 0}
>
{ruleCategories.length === 0 ? (
<EmptyState icon="📜">No rules published yet.</EmptyState>
) : (
<div style={{ display: "grid", gap: "1rem", padding: "1rem" }}>
{ruleCategories.map((cat) => {
const catRules = rulesByCategory.get(String(cat.id)) ?? [];
return (
<article key={String(cat.id)} className="card">
<div
style={{
display: "flex",
alignItems: "baseline",
gap: "0.6rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>{cat.name}</h3>
{cat.badge ? <span className="muted">{cat.badge}</span> : null}
</div>
{cat.description ? (
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
{cat.description}
</p>
) : null}
{catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}>
No rules in this category.
</p>
) : (
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}>
{catRules.map((r) => (
<li key={String(r.id)} style={{ marginBottom: "0.5rem" }}>
{r.paragraph ? (
<strong style={{ marginRight: "0.4rem" }}>{r.paragraph}</strong>
) : null}
{r.rule}
</li>
))}
</ul>
)}
</article>
);
})}
</div>
)}
</ContentCard>
</main> </main>
); );
} }
+71 -42
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,51 +27,79 @@ export default async function HelpTicketsPage() {
tickets = []; tickets = [];
} }
const openCount = tickets.filter((t) => t.open).length;
const closedCount = tickets.length - openCount;
return ( return (
<main> <main style={{ display: "grid", gap: "1.5rem" }}>
<h1>Help Tickets</h1> <ContentCard
<p className="muted">Submit a ticket to staff and track your open requests.</p> icon="🎫"
title="Help Tickets"
<form action={createTicket} className="card" style={{ marginBottom: "1.5rem" }}> subtitle="Submit a ticket to staff and track your open requests."
<h3 style={{ marginTop: 0 }}>New ticket</h3> >
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}> <div className="stat-grid">
<input name="title" placeholder="Subject" maxLength={255} required /> <StatBlock icon="🎫" value={tickets.length} label="Total tickets" />
<textarea <StatBlock icon="📬" value={openCount} label="Open" />
name="content" <StatBlock icon="✅" value={closedCount} label="Closed" />
placeholder="Describe your issue…"
rows={5}
maxLength={5000}
required
/>
<div>
<button type="submit" className="btn btn-primary">
Submit ticket
</button>
</div>
</div> </div>
</form> </ContentCard>
<table> <ContentCard icon="✏️" title="New ticket" subtitle="Describe your issue and staff will follow up">
<thead> <form action={createTicket}>
<tr> <div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<th>ID</th> <input name="title" placeholder="Subject" maxLength={255} required />
<th>Subject</th> <textarea
<th>Status</th> name="content"
<th>Created</th> placeholder="Describe your issue…"
</tr> rows={5}
</thead> maxLength={5000}
<tbody> required
{tickets.map((t) => ( />
<tr key={String(t.id)}> <div>
<td>{String(t.id)}</td> <button type="submit" className="btn btn-primary">
<td>{t.title}</td> Submit ticket
<td>{t.open ? "Open" : "Closed"}</td> </button>
<td className="muted">{formatDate(t.createdAt)}</td> </div>
</tr> </div>
))} </form>
</tbody> </ContentCard>
</table>
{tickets.length === 0 ? <p className="muted">You have no tickets yet.</p> : null} <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>
<thead>
<tr>
<th>ID</th>
<th>Subject</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>{t.title}</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>
</tr>
))}
</tbody>
</table>
)}
</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>
); );
} }
+54 -67
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,76 +59,62 @@ 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
value={new Set(offers.map((o) => o.userId)).size.toLocaleString()}
label="Sellers"
icon="👥"
/>
<StatBlock value={`${total.toLocaleString()} cr`} label="Total value" icon="🪙" />
</div>
</ContentCard>
<div className="grid cols-3" style={{ marginBottom: "1.5rem" }}> <ContentCard
<div className="card"> icon="📋"
<p className="muted" style={{ margin: 0 }}> title="Active listings"
Active offers subtitle={offers.length > 0 ? "Most recently listed first" : undefined}
</p> padded={offers.length === 0}
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}> >
{offers.length} {offers.length === 0 ? (
</p> <EmptyState icon="🛍️">
</div>
<div className="card">
<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>
{offers.length === 0 ? (
<div className="card">
<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 style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<div className="card" style={{ padding: "0.5rem 1.25rem 0.75rem" }}> <table>
<table> <thead>
<thead> <tr>
<tr> <th>Offer</th>
<th>Offer</th> <th>Item</th>
<th>Item</th> <th>Seller</th>
<th>Seller</th> <th>Price</th>
<th>Price</th> <th>Listed</th>
<th>Listed</th>
</tr>
</thead>
<tbody>
{offers.map((o) => (
<tr key={o.id}>
<td className="muted">#{o.id}</td>
<td>Item #{o.itemId}</td>
<td>{sellers.get(o.userId) ?? `User #${o.userId}`}</td>
<td style={{ fontWeight: 700 }}>{o.price.toLocaleString()} cr</td>
<td className="muted" title={listedAt(o.timestamp)}>
{listedAgo(o.timestamp)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {offers.map((o) => (
</div> <tr key={o.id}>
)} <td className="muted">#{o.id}</td>
<td>Item #{o.itemId}</td>
<td>{sellers.get(o.userId) ?? `User #${o.userId}`}</td>
<td style={{ fontWeight: 700 }}>{o.price.toLocaleString()} cr</td>
<td className="muted" title={listedAt(o.timestamp)}>
{listedAgo(o.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
</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>
); );
} }
+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>
); );
} }
+92 -90
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,101 +45,103 @@ 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> <div className="card-grid sm-2">
<label htmlFor="realName" className="muted"> <div>
Real name <label htmlFor="realName" className="muted">
Real name
</label>
<input
id="realName"
name="realName"
type="text"
required
maxLength={255}
placeholder="Your full name"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="age" className="muted">
Age
</label>
<input
id="age"
name="age"
type="number"
required
min={13}
max={120}
placeholder="18"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted">
Availability
</label>
<textarea
id="availability"
name="availability"
required
rows={3}
placeholder="Which days and times can you broadcast?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="musicStyle" className="muted">
Music style
</label> </label>
<input <input
id="realName" id="musicStyle"
name="realName" name="musicStyle"
type="text" type="text"
required
maxLength={255} maxLength={255}
placeholder="Your full name" placeholder="e.g. House, Pop, Hip-Hop"
style={{ width: "100%", marginTop: "0.3rem" }} style={{ width: "100%", marginTop: "0.3rem" }}
/> />
</div> </div>
<div>
<label htmlFor="age" className="muted"> <div style={{ marginTop: "1rem" }}>
Age <label htmlFor="experience" className="muted">
Experience <span className="muted">(optional)</span>
</label> </label>
<input <textarea
id="age" id="experience"
name="age" name="experience"
type="number" rows={3}
required placeholder="Any prior DJ or hosting experience?"
min={13} style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
max={120}
placeholder="18"
style={{ width: "100%", marginTop: "0.3rem" }}
/> />
</div> </div>
</div>
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted"> <label htmlFor="motivation" className="muted">
Availability Motivation
</label> </label>
<textarea <textarea
id="availability" id="motivation"
name="availability" name="motivation"
required required
rows={3} rows={4}
placeholder="Which days and times can you broadcast?" placeholder="Why do you want to join the radio team?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }} style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/> />
</div> </div>
<div style={{ marginTop: "1rem" }}> <button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
<label htmlFor="musicStyle" className="muted"> Submit application
Music style </button>
</label> </form>
<input </ContentCard>
id="musicStyle"
name="musicStyle"
type="text"
maxLength={255}
placeholder="e.g. House, Pop, Hip-Hop"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="experience" className="muted">
Experience <span className="muted">(optional)</span>
</label>
<textarea
id="experience"
name="experience"
rows={3}
placeholder="Any prior DJ or hosting experience?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="motivation" className="muted">
Motivation
</label>
<textarea
id="motivation"
name="motivation"
required
rows={4}
placeholder="Why do you want to join the radio team?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
Submit application
</button>
</form>
</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 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
@@ -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,60 +110,81 @@ 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> <th>Time</th>
<th>Time</th> <th>Show</th>
<th>Show</th> <th>DJ</th>
<th>DJ</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {todaysSlots.map((s) => {
{todaysSlots.map((s) => { const djName = djById.get(Number(s.userId));
const djName = djById.get(Number(s.userId)); return (
return ( <tr key={String(s.id)}>
<tr key={String(s.id)}> <td>
<td> {formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)} </td>
</td> <td>
<td> {s.showName || <span className="muted">Untitled show</span>}
{s.showName || <span className="muted">Untitled show</span>} </td>
</td> <td>
<td> {djName ? (
{djName ? ( <Link href={`/u/${djName}`}>{djName}</Link>
<Link href={`/u/${djName}`}>{djName}</Link> ) : (
) : ( <span className="muted">{`User #${s.userId}`}</span>
<span className="muted">{`User #${s.userId}`}</span> )}
)} </td>
</td> </tr>
</tr> );
); })}
})} </tbody>
</tbody> </table>
</table>
</div>
)} )}
</section> </ContentCard>
</main> </main>
); );
} }
+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>
); );
} }
+75 -62
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,76 +41,88 @@ 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. */}
<label htmlFor="shout-message" className="muted"> <form action={postShout}>
Post a shout <label htmlFor="shout-message" className="muted">
</label> Your message
<textarea </label>
id="shout-message" <textarea
name="message" id="shout-message"
required name="message"
maxLength={255} required
rows={3} maxLength={255}
placeholder="Send a shout to the DJ…" rows={3}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }} placeholder="Send a shout to the DJ…"
/> style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
<button type="submit" className="btn btn-primary"> />
Shout <button type="submit" className="btn btn-primary">
</button> Shout
</form> </button>
</form>
</ContentCard>
) : ( ) : (
<p className="muted">Log in to post a shout.</p> <ContentCard padded>
<EmptyState icon="🔒">Log in to post a shout.</EmptyState>
</ContentCard>
)} )}
{shouts.length === 0 ? ( <ContentCard
<p className="muted">No shouts yet. Be the first!</p> icon="💬"
) : ( title="Latest shouts"
<div className="grid"> subtitle="The most recent messages from listeners"
{shouts.map((s) => { padded={shouts.length === 0}
const author = authorById.get(Number(s.userId)); >
const authorAvatar = author {shouts.length === 0 ? (
? avatarImageUrl(imagerBase ?? "", author.look, { <EmptyState icon="📣">No shouts yet. Be the first!</EmptyState>
size: "s", ) : (
headOnly: true, <div className="card-grid">
}) {shouts.map((s) => {
: null; const author = authorById.get(Number(s.userId));
return ( const authorAvatar = author
<div ? avatarImageUrl(imagerBase ?? "", author.look, {
key={String(s.id)} size: "s",
className="card" headOnly: true,
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }} })
> : null;
{authorAvatar ? ( return (
/* eslint-disable-next-line @next/next/no-img-element */ <div
<img key={String(s.id)}
className="avatar" className="card"
src={authorAvatar} style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
alt={author?.username ?? "author"} >
width={40} {authorAvatar ? (
height={40} /* eslint-disable-next-line @next/next/no-img-element */
/> <img
) : null} className="avatar"
<div style={{ flex: 1 }}> src={authorAvatar}
<p style={{ margin: "0 0 0.25rem" }}> alt={author?.username ?? "author"}
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "} width={40}
<span className="muted">{formatDate(s.createdAt)}</span> height={40}
</p> />
<p style={{ margin: 0 }}>{s.message}</p> ) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
<span className="muted">{formatDate(s.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{s.message}</p>
</div>
</div> </div>
</div> );
); })}
})} </div>
</div> )}
)} </ContentCard>
</main> </main>
); );
} }
+42 -44
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>
{users.length === 0 ? ( <ContentCard padded={users.length === 0}>
<p className="muted">No players to rank yet.</p> {users.length === 0 ? (
) : ( <EmptyState icon="🏆">No players to rank yet.</EmptyState>
<div className="grid cols-3"> ) : (
{users.map((u, i) => { <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" }); {users.map((u, i) => {
return ( const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" });
<div return (
key={u.username} <div
className="card hover" key={u.username}
style={{ display: "flex", gap: "1rem", alignItems: "center" }} className="card hover"
> style={{ display: "flex", gap: "0.9rem", alignItems: "center" }}
<span style={{ fontSize: "1.5rem", fontWeight: 800, minWidth: "2rem" }}> >
#{i + 1} <RankBadge position={i + 1} />
</span> {/* biome-ignore lint/performance/noImgElement: external avatar imager */}
{/* 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={50}
width={64} height={90}
height={110} />
/> <div style={{ minWidth: 0, flex: 1 }}>
<div style={{ minWidth: 0 }}> <h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
<h3 style={{ margin: "0 0 0.2rem" }}> <Link href={`/u/${u.username}`}>{u.username}</Link>
<Link href={`/u/${u.username}`}>{u.username}</Link> </h3>
</h3> <p className="currency" style={{ margin: "0 0 0.3rem" }}>
<p className="currency" style={{ margin: 0 }}> <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>
); );
} }
+81 -80
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,87 +86,87 @@ 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 {badgeSrc ? (
className="hero" // eslint-disable-next-line @next/next/no-img-element
style={{ display: "flex", alignItems: "center", gap: "1rem" }} <img src={badgeSrc} alt={`${cat.name} badge`} width={32} height={32} />
) : null}
{cat.name}
</span>
}
subtitle={`${rares.length} ${rares.length === 1 ? "rare" : "rares"} in this category`}
action={
<Link className="btn btn-outline" href="/rares">
← All rares
</Link>
}
padded={rares.length === 0}
> >
{badgeSrc ? ( {rares.length === 0 ? (
// eslint-disable-next-line @next/next/no-img-element <EmptyState icon="💎">No rares listed in this category yet.</EmptyState>
<img src={badgeSrc} alt={`${cat.name} badge`} width={48} height={48} /> ) : (
) : null} <div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<div> <table>
<h1 style={{ margin: 0 }}>{cat.name}</h1> <thead>
<p className="muted" style={{ marginBottom: 0 }}> <tr>
{rares.length} {rares.length === 1 ? "rare" : "rares"} in this category <th style={{ width: 56 }}></th>
</p> <th>Rare</th>
</div> <th>Credits</th>
</div> <th>Currency</th>
</tr>
{rares.length === 0 ? ( </thead>
<p className="muted">No rares listed in this category yet.</p> <tbody>
) : ( {rares.map((r) => {
<div className="card" style={{ padding: 0, overflowX: "auto" }}> const iconSrc =
<table> iconBase && r.furnitureIcon ? `${iconBase}/${r.furnitureIcon}` : "";
<thead> return (
<tr> <tr key={String(r.id)}>
<th style={{ width: 56 }}></th> <td>
<th>Rare</th> {iconSrc ? (
<th>Credits</th> // eslint-disable-next-line @next/next/no-img-element
<th>Currency</th> <img
</tr> src={iconSrc}
</thead> alt=""
<tbody> width={40}
{rares.map((r) => { height={40}
const iconSrc = style={{ objectFit: "contain" }}
iconBase && r.furnitureIcon ? `${iconBase}/${r.furnitureIcon}` : ""; />
return ( ) : (
<tr key={String(r.id)}> <span
<td> className="avatar"
{iconSrc ? ( style={{ display: "inline-block", width: 40, height: 40 }}
// eslint-disable-next-line @next/next/no-img-element />
<img )}
src={iconSrc} </td>
alt="" <td>
width={40} <strong>{r.name}</strong>
height={40} </td>
style={{ objectFit: "contain" }} <td>
/> {r.creditValue && r.creditValue !== "" ? (
) : ( `${r.creditValue} credits`
<span ) : (
className="avatar" <span className="muted">—</span>
style={{ display: "inline-block", width: 40, height: 40 }} )}
/> </td>
)} <td>
</td> {r.currencyValue && r.currencyValue !== "" ? (
<td> `${r.currencyValue} ${prettyCurrency(r.currencyType)}`
<strong>{r.name}</strong> ) : (
</td> <span className="muted">—</span>
<td> )}
{r.creditValue && r.creditValue !== "" ? ( </td>
`${r.creditValue} credits` </tr>
) : ( );
<span className="muted">—</span> })}
)} </tbody>
</td> </table>
<td> </div>
{r.currencyValue && r.currencyValue !== "" ? ( )}
`${r.currencyValue} ${prettyCurrency(r.currencyType)}` </ContentCard>
) : (
<span className="muted">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</main> </main>
); );
} }
+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>
); );
} }
+68 -53
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,67 +48,81 @@ 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="🔒"
{sp.error === "badcode" ? ( title="Two-factor authentication"
<p style={{ color: "var(--color-danger)" }}>That code wasn't valid — try again.</p> subtitle="Add a second layer of security with an authenticator app"
) : null} >
{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" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
That code wasn't valid — try again.
</p>
) : 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 style={{ marginTop: 0 }}>
<p> <strong>2FA is enabled</strong> on your account.
<strong>2FA is enabled</strong> on your account. </p>
</p> <form action={disableTwoFactor}>
<form action={disableTwoFactor}> <button type="submit" className="btn btn-danger">
<button type="submit" className="btn btn-danger"> 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. </p>
</p> <p>
<p> Manual key: <code style={{ userSelect: "all" }}>{secret}</code>
Manual key: <code style={{ userSelect: "all" }}>{secret}</code> </p>
</p> <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
<form action={confirmTwoFactor} style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}> action={confirmTwoFactor}
<input name="code" placeholder="6-digit code" inputMode="numeric" required /> style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
<button type="submit" className="btn btn-primary"> >
Confirm <input name="code" placeholder="6-digit code" inputMode="numeric" required />
</button> <button type="submit" className="btn btn-primary">
</form> Confirm
</div> </button>
) : ( </form>
<div className="card"> </>
<p style={{ marginTop: 0 }}> ) : (
Add a second layer of security with an authenticator app. <>
</p> <p style={{ marginTop: 0 }}>
<form action={beginTwoFactor}> Add a second layer of security with an authenticator app.
<button type="submit" className="btn btn-primary"> </p>
Enable 2FA <form action={beginTwoFactor}>
</button> <button type="submit" className="btn btn-primary">
</form> Enable 2FA
</div> </button>
)} </form>
</>
)}
</ContentCard>
</main> </main>
); );
} }
+65 -65
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,85 +51,82 @@ 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>
<p className="muted" style={{ marginTop: 0 }}>
Link a social account so you can sign in with one click. We match it to
your hotel account by e-mail, so use the address on file:{" "}
<strong>{session.user.name}</strong>.
</p>
{lookupFailed ? ( <ContentCard
<div className="card" style={{ marginBottom: "1.5rem" }}> icon="🔗"
<p className="muted" style={{ margin: 0 }}> title="Connected accounts"
We couldn't read your linked accounts right now. You can still connect subtitle="Link a social account so you can sign in with one click"
below — your status will show once it's available. >
<p className="muted" style={{ marginTop: 0 }}>
We match it to your hotel account by e-mail, so use the address on file:{" "}
<strong>{session.user.name}</strong>.
</p>
{lookupFailed ? (
<p className="muted" style={{ marginBottom: 0 }}>
We couldn't read your linked accounts right now. You can still connect 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={
!p.available
? "Not available on this hotel."
: isLinked
? "Connected — sign in with this account."
: "Not connected."
}
> >
<div style={{ flex: 1 }}> {isLinked ? (
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3> <span
<p className="muted" style={{ margin: 0 }}> className="btn btn-outline"
{!p.available style={{ cursor: "default", pointerEvents: "none" }}
? "Not available on this hotel." aria-disabled="true"
: isLinked >
? "Connected — you can sign in with this account." Connected
: "Not connected."} </span>
</p> ) : p.available ? (
</div> // Plain link into NextAuth's built-in sign-in handler. Re-using
<div> // the same OAuth flow as /login binds the provider to the
{isLinked ? ( // matching hotel account (see auth.ts signIn callback).
<span <a
className="btn btn-outline" className="btn btn-primary"
style={{ cursor: "default", pointerEvents: "none" }} href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
aria-disabled="true" callbackUrl,
> )}`}
Connected >
</span> Connect {p.label}
) : p.available ? ( </a>
// Plain link into NextAuth's built-in sign-in handler. Re-using ) : (
// the same OAuth flow as /login binds the provider to the <span
// matching hotel account (see auth.ts signIn callback). className="btn"
<a style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
className="btn btn-primary" aria-disabled="true"
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent( >
callbackUrl, Unavailable
)}`} </span>
> )}
Connect {p.label} </ContentCard>
</a>
) : (
<span
className="btn"
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
aria-disabled="true"
>
Unavailable
</span>
)}
</div>
</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>
); );
+49 -41
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,29 +43,39 @@ 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 */}
> <img
{/* eslint-disable-next-line @next/next/no-img-element */} className="avatar"
<img className="avatar" src={avatar} alt={`${user.username} avatar`} width={100} height={150} /> src={avatar}
<div> alt={`${user.username} avatar`}
<h2 style={{ margin: "0 0 0.25rem" }}>{user.username}</h2> width={100}
<p style={{ margin: "0 0 0.5rem" }}> height={150}
{user.motto || <span className="muted">No motto</span>} />
</p> <div style={{ minWidth: 0 }}>
<p className="muted" style={{ margin: 0 }}> <p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
{user.mail || "No e-mail on file"} {user.motto || <span className="muted">No motto</span>}
</p> </p>
<p className="muted" style={{ margin: 0 }}>
{user.mail || "No e-mail on file"}
</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 your hotel account.
Link Discord or Google to sign in with one click. </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>
); );
+128 -119
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,136 +105,144 @@ 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
href="/shop"
className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}
> >
All <Link href="/shop" className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}>
</Link> All
{categories.map((c) => (
<Link
key={String(c.id)}
href={`/shop?category=${String(c.id)}`}
className={`btn ${categoryId === String(c.id) ? "btn-primary" : "btn-outline"}`}
>
{c.name}
</Link> </Link>
))} {categories.map((c) => (
</div> <Link
) : null} key={String(c.id)}
href={`/shop?category=${String(c.id)}`}
{articles.length === 0 ? ( className={`btn ${categoryId === String(c.id) ? "btn-primary" : "btn-outline"}`}
<p className="muted">No packages available right now.</p>
) : (
<div className="grid cols-3">
{articles.map((a) => (
<article key={String(a.id)} className="card hover">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "0.6rem",
}}
> >
{c.name}
</Link>
))}
</div>
) : 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 ? (
<EmptyState icon="🛒">No packages available right now.</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => (
<article key={String(a.id)} className="card hover">
<div <div
style={{ style={{
width: 56, display: "flex",
height: 56, alignItems: "center",
borderRadius: "var(--radius-sm)", gap: "0.75rem",
display: "grid", marginBottom: "0.6rem",
placeItems: "center",
background: "#f8fafc",
border: `2px solid ${a.color || "var(--color-golden)"}`,
flexShrink: 0,
overflow: "hidden",
}} }}
> >
{a.iconUrl ? ( <div
// Article icons are arbitrary external/Imgur URLs from the style={{
// emulator data, so a plain <img> is correct here. width: 56,
// eslint-disable-next-line @next/next/no-img-element height: 56,
<img borderRadius: "var(--radius-sm)",
src={a.iconUrl} display: "grid",
alt="" placeItems: "center",
width={48} background: "#f8fafc",
height={48} border: `2px solid ${a.color || "var(--border-color)"}`,
style={{ objectFit: "contain", display: "block" }} flexShrink: 0,
/> overflow: "hidden",
}}
>
{a.iconUrl ? (
// Article icons are arbitrary external/Imgur URLs from the
// emulator data, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.iconUrl}
alt=""
width={48}
height={48}
style={{ objectFit: "contain", display: "block" }}
/>
) : null}
</div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{a.name}</h3>
<span className="muted">{priceLabel(a.costs)}</span>
</div>
</div>
<p style={{ margin: "0 0 0.75rem" }}>{excerpt(a.info, 160)}</p>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginBottom: "0.85rem",
}}
>
{a.credits ? (
<span className="currency">
<span className="coin credits">cr</span>
{a.credits.toLocaleString()}
</span>
) : null}
{a.duckets ? (
<span className="currency">
<span className="coin duckets">du</span>
{a.duckets.toLocaleString()}
</span>
) : null}
{a.diamonds ? (
<span className="currency">
<span className="coin diamonds">di</span>
{a.diamonds.toLocaleString()}
</span>
) : null} ) : null}
</div> </div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{a.name}</h3> <div
<span className="muted">{priceLabel(a.costs)}</span> style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true">
Buy
</span>
</div> </div>
</div> </article>
))}
<p style={{ margin: "0 0 0.75rem" }}>{excerpt(a.info, 160)}</p> </div>
)}
<div </ContentCard>
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginBottom: "0.85rem",
}}
>
{a.credits ? (
<span className="currency">
<span className="coin credits">cr</span>
{a.credits.toLocaleString()}
</span>
) : null}
{a.duckets ? (
<span className="currency">
<span className="coin duckets">du</span>
{a.duckets.toLocaleString()}
</span>
) : null}
{a.diamonds ? (
<span className="currency">
<span className="coin diamonds">di</span>
{a.diamonds.toLocaleString()}
</span>
) : null}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true">
Buy
</span>
</div>
</article>
))}
</div>
)}
</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>
); );
+51 -42
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,53 +54,61 @@ 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">
{teams.map((t) => ( <div className="card-grid sm-2 lg-3">
<div key={t.id} className="card"> {teams.map((t) => (
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3> <div key={t.id} className="card">
{t.jobDescription ? ( <h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3>
<p className="muted" style={{ margin: 0 }}> {t.jobDescription ? (
{t.jobDescription} <p className="muted" style={{ margin: 0 }}>
</p> {t.jobDescription}
) : null} </p>
</div> ) : null}
))} </div>
</div> ))}
</div>
</ContentCard>
) : null} ) : null}
{staff.length === 0 ? ( <ContentCard
<p className="muted">No staff members to show.</p> icon="👥"
) : ( title="Team members"
<table> subtitle={
<thead> staff.length === 0
<tr> ? "Nobody to show right now"
<th>Username</th> : `${staff.length} member${staff.length === 1 ? "" : "s"} keeping things running`
<th>Rank</th> }
<th>Motto</th> padded={staff.length === 0}
</tr> >
</thead> {staff.length === 0 ? (
<tbody> <EmptyState icon="🛡️">No staff members to show.</EmptyState>
{staff.map((u) => ( ) : (
<tr key={u.username}> <table>
<td> <thead>
<Link href={`/u/${u.username}`}>{u.username}</Link> <tr>
</td> <th>Username</th>
<td>{u.rank}</td> <th>Rank</th>
<td className="muted">{u.motto || "—"}</td> <th>Motto</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {staff.map((u) => (
)} <tr key={u.username}>
<td>
<Link href={`/u/${u.username}`}>{u.username}</Link>
</td>
<td>{u.rank}</td>
<td className="muted">{u.motto || "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</ContentCard>
</main> </main>
); );
} }
+41 -31
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 ───────────────────────────────────── */}
{/* eslint-disable-next-line @next/next/no-img-element */} <ContentCard padded={false}>
<img src={avatar} alt={`${user.username} avatar`} width={100} height={150} /> <div
<div> className="content-card-body"
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1> style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}
<p style={{ margin: "0 0 0.5rem" }}> >
{user.motto || <span className="muted">No motto</span>} {/* eslint-disable-next-line @next/next/no-img-element */}
</p> <img src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
<p className="muted" style={{ margin: 0 }}> <div style={{ minWidth: 0, flex: 1 }}>
Rank {user.rank} · {user.credits} credits · {user.online === "1" ? "Online" : "Offline"} · <h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
Registered {registered} <p style={{ margin: "0 0 0.6rem" }}>
</p> {user.motto || <span className="muted">No motto</span>}
</p>
<OnlineBadge online={user.online === "1"} />
</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>
); );
} }
+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>
); );
} }
+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>
);
}