Adapt + improve all public pages to the atom design system
Extended the same design-system treatment to the public site, faithful to AtomCMS's "atom" theme. New src/components/public/ui.tsx provides the signature atom building blocks + a scoped CSS layer: - ContentCard: surface card with a primary-tinted header (icon circle + title + subtitle) and padded body — the atom content-card, used as every page's header and section wrapper. - StatBlock / stat-grid, EmptyState, OnlineBadge (online/offline pill), RankBadge (medals for the top 3), and responsive .card-grid helpers. Swept ~45 public pages (home/community/rankings hand-built as the reference; the rest via parallel agents that read the schema and preserved every query, server action, auth gate and field name): heroes → ContentCard headers, lists → card-grids in ContentCards, "no X" → EmptyState, status → OnlineBadge/RankBadge. The leaderboard gained Credits/Diamonds/Duckets tabs (usersCurrency). Behaviour unchanged. Verified on the prod server against amx_test: 15+ public pages render the content-cards/grids with real data, no errors; computed styles confirm the tinted header, icon circle, medal + online pills. Fixed an undefined --color-golden ref. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
1 parent
0de0ac6a3c
commit
e9ea19795a
47 files changed
+2264
-1714
No files matched your search
@@ -1,8 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { applyStaff } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { applyStaff } from "@/actions/applications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -48,39 +49,29 @@ export default async function ApplyStaffPage() {
|
||||
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for {hotelName} staff</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🛡️"
|
||||
title={`Apply for ${hotelName} staff`}
|
||||
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."
|
||||
/>
|
||||
|
||||
{positions.length === 0 ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>No positions open</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<ContentCard icon="🛡️" title="No positions open">
|
||||
<EmptyState icon="🛡️">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
<div className="card-grid sm-2">
|
||||
{positions.map((position) => {
|
||||
const team = teamByRankId.get(position.permissionId);
|
||||
const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
|
||||
const alreadyApplied = appliedRankIds.has(position.permissionId);
|
||||
return (
|
||||
<article key={String(position.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<article key={String(position.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@@ -89,48 +80,59 @@ export default async function ApplyStaffPage() {
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: team?.staffColor || undefined }}>{rankName}</h3>
|
||||
) : (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
🛡️
|
||||
</span>
|
||||
)}
|
||||
<div className="content-card-head-text">
|
||||
<p className="content-card-title" style={{ color: team?.staffColor || undefined }}>
|
||||
{rankName}
|
||||
</p>
|
||||
{team?.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
<p className="content-card-subtitle">{team.jobDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
Application deadline: {formatDeadline(position.applyTo)}
|
||||
</p>
|
||||
<div className="content-card-body">
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
Application deadline: {formatDeadline(position.applyTo)}
|
||||
</p>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
|
||||
You have already applied for {rankName}
|
||||
</button>
|
||||
) : (
|
||||
<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}
|
||||
{alreadyApplied ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
You have already applied for {rankName}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
+56
-56
@@ -1,8 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { applyTeam } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { applyTeam } from "@/actions/applications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -35,38 +36,28 @@ export default async function ApplyTeamPage() {
|
||||
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for the {hotelName} team</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We open team applications periodically. If you see a team you fit, do not hesitate to
|
||||
apply!
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title={`Apply for the ${hotelName} team`}
|
||||
subtitle="We open team applications periodically. If you see a team you fit, do not hesitate to apply!"
|
||||
/>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>No team positions open</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<ContentCard icon="🤝" title="No team positions open">
|
||||
<EmptyState icon="🤝">
|
||||
There are currently no open team positions. Please come back later to check for new
|
||||
openings. Thank you!
|
||||
</p>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
<div className="card-grid sm-2">
|
||||
{teams.map((team) => {
|
||||
const teamId = Number(team.id);
|
||||
const alreadyApplied = appliedTeamIds.has(teamId);
|
||||
return (
|
||||
<article key={String(team.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<article key={String(team.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@@ -75,45 +66,54 @@ export default async function ApplyTeamPage() {
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: team.staffColor || undefined }}>
|
||||
) : (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
🤝
|
||||
</span>
|
||||
)}
|
||||
<div className="content-card-head-text">
|
||||
<p className="content-card-title" style={{ color: team.staffColor || undefined }}>
|
||||
{team.rankName}
|
||||
</h3>
|
||||
</p>
|
||||
{team.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
<p className="content-card-subtitle">{team.jobDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
|
||||
Your application is pending
|
||||
</button>
|
||||
) : (
|
||||
<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}
|
||||
<div className="content-card-body">
|
||||
{alreadyApplied ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
Your application is pending
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
+38
-36
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,44 +14,45 @@ export default async function BadgesPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Badges</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The badges you can earn and show off around the hotel.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏅"
|
||||
title="Badges"
|
||||
subtitle="The badges you can earn and show off around the hotel"
|
||||
/>
|
||||
|
||||
{badges.length === 0 ? (
|
||||
<p className="muted">No badges available yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={String(badge.id)}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "0.9rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
|
||||
alt={badge.badgeName}
|
||||
title={badge.badgeKey}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>{badge.badgeName}</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}>
|
||||
{badge.badgeKey}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: "0.9rem" }}>{badge.badgeDescription}</p>
|
||||
<ContentCard padded={badges.length === 0}>
|
||||
{badges.length === 0 ? (
|
||||
<EmptyState icon="🏅">No badges available yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={String(badge.id)}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "0.9rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
|
||||
alt={badge.badgeName}
|
||||
title={badge.badgeKey}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>{badge.badgeName}</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}>
|
||||
{badge.badgeKey}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: "0.9rem" }}>{badge.badgeDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
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", " ")}.`;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
|
||||
<h1 style={{ marginTop: 0, color: "var(--color-danger)" }}>You are banned</h1>
|
||||
<p>Your account has been suspended from the hotel.</p>
|
||||
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||
<ContentCard icon="🚫" title="You are banned" subtitle="Account suspended">
|
||||
<p style={{ marginTop: 0 }}>Your account has been suspended from the hotel.</p>
|
||||
{reason ? (
|
||||
<p>
|
||||
<strong>Reason:</strong> {reason}
|
||||
</p>
|
||||
) : null}
|
||||
{expiryText ? <p className="muted">{expiryText}</p> : null}
|
||||
<p className="muted">If you believe this is a mistake, contact the staff team.</p>
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
If you believe this is a mistake, contact the staff team.
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+21
-11
@@ -1,5 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -30,19 +31,28 @@ export default async function ClientPage() {
|
||||
: "";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Launching {hotelName ?? "Atom"}…</h1>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏨"
|
||||
title={`Launching ${hotelName ?? "Atom"}…`}
|
||||
subtitle="Your single sign-on ticket is ready"
|
||||
/>
|
||||
|
||||
{src ? (
|
||||
<iframe
|
||||
title="Hotel client"
|
||||
src={src}
|
||||
style={{ width: "100%", height: "80vh", border: 0, borderRadius: 10 }}
|
||||
/>
|
||||
<ContentCard padded={false}>
|
||||
<iframe
|
||||
title="Hotel client"
|
||||
src={src}
|
||||
style={{ width: "100%", height: "80vh", border: 0, borderRadius: 10, display: "block" }}
|
||||
/>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<p className="muted">
|
||||
No client URL configured (set <code>nitro_client_url</code> in settings). SSO ticket
|
||||
issued for this session: <code>{ticket}</code>
|
||||
</p>
|
||||
<ContentCard icon="🔌" title="No client configured" subtitle="Set nitro_client_url in settings">
|
||||
<EmptyState icon="🔌">
|
||||
No client URL configured (set <code>nitro_client_url</code> in settings). SSO ticket
|
||||
issued for this session: <code>{ticket}</code>
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
+49
-14
@@ -1,43 +1,78 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
href: "/rankings",
|
||||
icon: "🏆",
|
||||
title: "Rankings",
|
||||
text: "See the wealthiest players in the hotel, ranked by credits.",
|
||||
},
|
||||
{
|
||||
href: "/staff",
|
||||
icon: "🛡️",
|
||||
title: "Staff",
|
||||
text: "Meet the team that keeps the hotel running every day.",
|
||||
},
|
||||
{
|
||||
href: "/news",
|
||||
icon: "📰",
|
||||
title: "News",
|
||||
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() {
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Community</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Everything happening around the hotel in one place.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🌍"
|
||||
title="Community"
|
||||
subtitle="Everything happening around the hotel in one place"
|
||||
/>
|
||||
|
||||
<div className="grid cols-3">
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className="card hover" style={{ color: "inherit" }}>
|
||||
<h3 style={{ margin: "0 0 0.35rem" }}>{l.title}</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{l.text}
|
||||
</p>
|
||||
<span className="btn btn-outline">Open →</span>
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="content-card hover-lift"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<div className="content-card-head">
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
{l.icon}
|
||||
</span>
|
||||
<div className="content-card-head-text">
|
||||
<p className="content-card-title">{l.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{l.text}
|
||||
</p>
|
||||
<span className="btn btn-outline">Open →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+10
-6
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { requestReset } from "@/actions/password-reset";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default async function ForgotPage({
|
||||
searchParams,
|
||||
@@ -9,15 +10,18 @@ export default async function ForgotPage({
|
||||
const { sent } = await searchParams;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem" }}>
|
||||
<h1 style={{ marginTop: 0, textAlign: "center" }}>Reset password</h1>
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon="🔑"
|
||||
title="Reset password"
|
||||
subtitle="We'll email you a secure link to set a new password."
|
||||
>
|
||||
{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.
|
||||
</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 />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Send reset link
|
||||
@@ -27,7 +31,7 @@ export default async function ForgotPage({
|
||||
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+57
-67
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -50,78 +51,67 @@ export default async function FriendsPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Friends</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.25rem" }}>
|
||||
{friends.length === 0
|
||||
? "You have no friends added yet."
|
||||
: `You have ${friends.length} friend${friends.length === 1 ? "" : "s"}.`}
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title="Friends"
|
||||
subtitle={
|
||||
friends.length === 0
|
||||
? "You have no friends added yet"
|
||||
: `You have ${friends.length} friend${friends.length === 1 ? "" : "s"}`
|
||||
}
|
||||
/>
|
||||
|
||||
{friends.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<ContentCard padded={friends.length === 0}>
|
||||
{friends.length === 0 ? (
|
||||
<EmptyState icon="🤝">
|
||||
Add friends in the hotel and they will show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{friends.map((friend) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
const isOnline = friend.online === "1";
|
||||
return (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${friend.username} avatar`}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||
<span
|
||||
aria-hidden
|
||||
title={isOnline ? "Online" : "Offline"}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{friends.map((friend) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
const isOnline = friend.online === "1";
|
||||
return (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${friend.username} avatar`}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
||||
<Link href={`/u/${friend.username}`}>{friend.username}</Link>
|
||||
</h3>
|
||||
<p
|
||||
className="muted"
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "999px",
|
||||
flexShrink: 0,
|
||||
background: isOnline
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-text-muted)",
|
||||
margin: "0 0 0.4rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
/>
|
||||
<Link href={`/u/${friend.username}`}>
|
||||
<strong>{friend.username}</strong>
|
||||
</Link>
|
||||
</p>
|
||||
<p
|
||||
className="muted"
|
||||
style={{
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{friend.motto || "No motto"}
|
||||
</p>
|
||||
>
|
||||
{friend.motto || "No motto"}
|
||||
</p>
|
||||
<OnlineBadge online={isOnline} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -679,3 +679,164 @@ td {
|
||||
margin: 0 0 0.7rem;
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { postThread } from "@/actions/social";
|
||||
@@ -33,54 +34,55 @@ export default async function NewThreadPage({
|
||||
if (!guild) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}/forum`}>← Back to forum</Link>
|
||||
</p>
|
||||
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>New thread</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
in {guild.name || "Unnamed guild"} forum
|
||||
</p>
|
||||
<ContentCard
|
||||
icon="✍️"
|
||||
title="New thread"
|
||||
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" }}>
|
||||
{/* 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)} />
|
||||
<label htmlFor="thread-subject" className="muted">
|
||||
Subject
|
||||
</label>
|
||||
<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">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="thread-subject"
|
||||
name="subject"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Thread subject"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }}
|
||||
/>
|
||||
<label htmlFor="thread-message" className="muted">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="thread-message"
|
||||
name="message"
|
||||
required
|
||||
rows={8}
|
||||
placeholder="Write your opening post…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }}
|
||||
/>
|
||||
|
||||
<label htmlFor="thread-message" className="muted">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="thread-message"
|
||||
name="message"
|
||||
required
|
||||
rows={8}
|
||||
placeholder="Write your opening post…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 1rem", resize: "vertical" }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}`}>← Back to guild</Link>
|
||||
</p>
|
||||
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>
|
||||
{guild.name || "Unnamed guild"} — Forum
|
||||
</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{threads.length} {threads.length === 1 ? "thread" : "threads"}
|
||||
</p>
|
||||
<ContentCard
|
||||
icon="🗣️"
|
||||
title={`${guild.name || "Unnamed guild"} — Forum`}
|
||||
subtitle={`${threads.length} ${threads.length === 1 ? "thread" : "threads"}`}
|
||||
action={
|
||||
<Link href={`/guilds/${guild.id}/forum/new`} className="btn btn-primary">
|
||||
+ New thread
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<p className="muted">No threads in this forum yet.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<ContentCard padded={threads.length === 0}>
|
||||
{threads.length === 0 ? (
|
||||
<EmptyState icon="🗣️">No threads in this forum yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -125,13 +130,13 @@ export default async function GuildForumPage({
|
||||
{t.subject?.trim() || "(no subject)"}
|
||||
</span>
|
||||
{t.pinned ? (
|
||||
<span className="muted" style={{ marginLeft: "0.5rem" }}>
|
||||
· Pinned
|
||||
<span className="online-badge online" style={{ marginLeft: "0.5rem" }}>
|
||||
📌 Pinned
|
||||
</span>
|
||||
) : null}
|
||||
{t.locked ? (
|
||||
<span className="muted" style={{ marginLeft: "0.5rem" }}>
|
||||
· Locked
|
||||
<span className="online-badge" style={{ marginLeft: "0.5rem" }}>
|
||||
🔒 Locked
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
@@ -155,8 +160,8 @@ export default async function GuildForumPage({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -118,74 +119,96 @@ export default async function GuildPage({
|
||||
const created = formatTimestamp(guild.dateCreated);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href="/guilds">← All guilds</Link>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{guild.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||
alt={`${guild.name} badge`}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>{guild.name || "Unnamed guild"}</h1>
|
||||
<p style={{ margin: "0 0 0.5rem" }}>
|
||||
{guild.description || <span className="muted">No description</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
{created ? ` · Founded ${created}` : ""}
|
||||
</p>
|
||||
<ContentCard>
|
||||
<div style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}>
|
||||
{guild.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||
alt={`${guild.name} badge`}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>{guild.name || "Unnamed guild"}</h1>
|
||||
<p style={{ margin: 0 }}>
|
||||
{guild.description || <span className="muted">No description</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginTop: "1.5rem" }}>Members</h2>
|
||||
{members.length === 0 ? (
|
||||
<p className="muted">No members.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{members.map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="card"
|
||||
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.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 className="stat-grid" style={{ marginTop: "1.1rem" }}>
|
||||
<StatBlock
|
||||
icon="👥"
|
||||
value={members.length}
|
||||
label={members.length === 1 ? "Member" : "Members"}
|
||||
/>
|
||||
{created ? <StatBlock icon="📅" value={created} label="Founded" /> : null}
|
||||
<StatBlock
|
||||
icon="🚪"
|
||||
value={
|
||||
<Link href={`/guilds/${guild.id}/forum`} style={{ textDecoration: "none" }}>
|
||||
Forum →
|
||||
</Link>
|
||||
}
|
||||
label="Discussion"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title="Members"
|
||||
subtitle={`${members.length} ${members.length === 1 ? "member" : "members"}`}
|
||||
padded={members.length === 0}
|
||||
>
|
||||
{members.length === 0 ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+49
-39
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -28,45 +29,54 @@ export default async function GuildsPage() {
|
||||
const guilds = await getGuilds();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Guilds</h1>
|
||||
{guilds.length === 0 ? (
|
||||
<p className="muted">No guilds yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{guilds.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/guilds/${g.id}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.85rem",
|
||||
alignItems: "flex-start",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt={`${g.name} badge`}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>{g.name || "Unnamed guild"}</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{g.description ? excerpt(g.description, 120) : "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🚪"
|
||||
title="Guilds"
|
||||
subtitle="Browse the groups players have created and joined"
|
||||
/>
|
||||
|
||||
<ContentCard padded={guilds.length === 0}>
|
||||
{guilds.length === 0 ? (
|
||||
<EmptyState icon="🚪">No guilds yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{guilds.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/guilds/${g.id}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.85rem",
|
||||
alignItems: "flex-start",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt={`${g.name} badge`}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -93,16 +94,12 @@ export default async function HelpCategoryPage({
|
||||
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p style={{ margin: "0 0 0.75rem" }}>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href="/help">← Help Center</Link>
|
||||
</p>
|
||||
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>{title}</h1>
|
||||
</div>
|
||||
|
||||
<article className="card">
|
||||
<ContentCard icon="❓" title={title} subtitle="Help Center topic">
|
||||
{imageSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
@@ -120,6 +117,7 @@ export default async function HelpCategoryPage({
|
||||
{/* content is author-supplied HTML in AtomCMS (rendered raw with {!! !!}). */}
|
||||
<div
|
||||
style={{ lineHeight: 1.7 }}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: author-supplied help content, matches AtomCMS Blade
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
|
||||
@@ -137,12 +135,15 @@ export default async function HelpCategoryPage({
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</ContentCard>
|
||||
|
||||
{siblings.length > 0 ? (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>More help topics</h2>
|
||||
<div className="grid cols-2">
|
||||
<ContentCard
|
||||
icon="🧭"
|
||||
title="More help topics"
|
||||
subtitle="Jump to another support topic"
|
||||
>
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{siblings.map((s) => (
|
||||
<Link
|
||||
key={String(s.id)}
|
||||
@@ -154,7 +155,7 @@ export default async function HelpCategoryPage({
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
|
||||
+129
-112
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -87,122 +88,138 @@ export default async function HelpCenterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Help Center</h1>
|
||||
<p className="muted" style={{ fontSize: 16, margin: 0 }}>
|
||||
Find guides, support topics, and the hotel rules — everything you need to get help.
|
||||
</p>
|
||||
</section>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="❓"
|
||||
title="Help Center"
|
||||
subtitle="Find guides, support topics, and the hotel rules — everything you need to get help."
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/help/tickets">
|
||||
My tickets →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<h2>Support topics</h2>
|
||||
{categories.length === 0 ? (
|
||||
<p className="muted">No help topics yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{categories.map((c) => (
|
||||
<article key={String(c.id)} className="card hover">
|
||||
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
|
||||
{c.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flexShrink: 0, objectFit: "contain" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.35rem" }}>{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>
|
||||
<ContentCard
|
||||
icon="🧭"
|
||||
title="Support topics"
|
||||
subtitle="Browse guides and answers to common questions"
|
||||
padded={categories.length === 0}
|
||||
>
|
||||
{categories.length === 0 ? (
|
||||
<EmptyState icon="🧭">No help topics yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{categories.map((c) => (
|
||||
<article key={String(c.id)} className="card hover">
|
||||
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
|
||||
{c.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flexShrink: 0, objectFit: "contain" }}
|
||||
/>
|
||||
) : 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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createTicket } from "@/actions/help-tickets";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -26,51 +27,79 @@ export default async function HelpTicketsPage() {
|
||||
tickets = [];
|
||||
}
|
||||
|
||||
const openCount = tickets.filter((t) => t.open).length;
|
||||
const closedCount = tickets.length - openCount;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Help Tickets</h1>
|
||||
<p className="muted">Submit a ticket to staff and track your open requests.</p>
|
||||
|
||||
<form action={createTicket} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>New ticket</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
|
||||
<input name="title" placeholder="Subject" maxLength={255} required />
|
||||
<textarea
|
||||
name="content"
|
||||
placeholder="Describe your issue…"
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit ticket
|
||||
</button>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎫"
|
||||
title="Help Tickets"
|
||||
subtitle="Submit a ticket to staff and track your open requests."
|
||||
>
|
||||
<div className="stat-grid">
|
||||
<StatBlock icon="🎫" value={tickets.length} label="Total tickets" />
|
||||
<StatBlock icon="📬" value={openCount} label="Open" />
|
||||
<StatBlock icon="✅" value={closedCount} label="Closed" />
|
||||
</div>
|
||||
</form>
|
||||
</ContentCard>
|
||||
|
||||
<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>{t.open ? "Open" : "Closed"}</td>
|
||||
<td className="muted">{formatDate(t.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tickets.length === 0 ? <p className="muted">You have no tickets yet.</p> : null}
|
||||
<ContentCard icon="✏️" title="New ticket" subtitle="Describe your issue and staff will follow up">
|
||||
<form action={createTicket}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
|
||||
<input name="title" placeholder="Subject" maxLength={255} required />
|
||||
<textarea
|
||||
name="content"
|
||||
placeholder="Describe your issue…"
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit ticket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="📋"
|
||||
title="Your tickets"
|
||||
subtitle="Every request you have sent to staff"
|
||||
padded={tickets.length === 0}
|
||||
>
|
||||
{tickets.length === 0 ? (
|
||||
<EmptyState icon="🎫">You have no tickets yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -80,13 +81,14 @@ export default async function LeaderboardPage({
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Leaderboard</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Top 20 players by {activeTab.label.toLowerCase()}.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📊"
|
||||
title="Leaderboard"
|
||||
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) => (
|
||||
<Link
|
||||
key={t.key}
|
||||
@@ -98,11 +100,9 @@ export default async function LeaderboardPage({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<ContentCard padded={rows.length === 0}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No rankings to show yet.
|
||||
</p>
|
||||
<EmptyState icon="📊">No rankings to show yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
@@ -121,7 +121,9 @@ export default async function LeaderboardPage({
|
||||
});
|
||||
return (
|
||||
<tr key={`${row.username}-${i}`}>
|
||||
<td style={{ fontWeight: 800 }}>{i + 1}</td>
|
||||
<td>
|
||||
<RankBadge position={i + 1} />
|
||||
</td>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -149,7 +151,7 @@ export default async function LeaderboardPage({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+18
-10
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -46,13 +47,17 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem" }}>
|
||||
<h1 style={{ marginTop: 0, textAlign: "center" }}>Sign in</h1>
|
||||
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>
|
||||
{needs2fa ? "Enter the 6-digit code from your authenticator." : "Welcome back — log in to enter the hotel."}
|
||||
</p>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon={needs2fa ? "🔒" : "🔑"}
|
||||
title="Sign in"
|
||||
subtitle={
|
||||
needs2fa
|
||||
? "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
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -111,12 +116,15 @@ export default function LoginPage() {
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
<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>
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,12 +13,17 @@ export default async function MaintenancePage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
|
||||
<h1 style={{ marginTop: 0 }}>{hotel} is under maintenance 🛠️</h1>
|
||||
<p>{message}</p>
|
||||
<p className="muted">Staff can still log in.</p>
|
||||
</div>
|
||||
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon="🛠️"
|
||||
title={`${hotel} is under maintenance`}
|
||||
subtitle="We'll be back shortly"
|
||||
>
|
||||
<p style={{ marginTop: 0 }}>{message}</p>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Staff can still log in.
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -58,76 +59,62 @@ export default async function MarketplacePage() {
|
||||
const total = offers.reduce((sum, o) => sum + o.price, 0);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Marketplace</h1>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Browse furniture and rares listed by players. Listings are placed in-game
|
||||
and update live — head into the hotel to make an offer.
|
||||
</p>
|
||||
</section>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🛍️"
|
||||
title="Marketplace"
|
||||
subtitle="Browse furniture and rares listed by players. Listings are placed in-game and update live — head into the hotel to make an offer."
|
||||
>
|
||||
<div className="stat-grid">
|
||||
<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" }}>
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Active offers
|
||||
</p>
|
||||
<p style={{ fontSize: "1.6rem", fontWeight: 800, margin: "0.15rem 0 0" }}>
|
||||
{offers.length}
|
||||
</p>
|
||||
</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 }}>
|
||||
<ContentCard
|
||||
icon="📋"
|
||||
title="Active listings"
|
||||
subtitle={offers.length > 0 ? "Most recently listed first" : undefined}
|
||||
padded={offers.length === 0}
|
||||
>
|
||||
{offers.length === 0 ? (
|
||||
<EmptyState icon="🛍️">
|
||||
There are no active marketplace offers right now. Check back later.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: "0.5rem 1.25rem 0.75rem" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Offer</th>
|
||||
<th>Item</th>
|
||||
<th>Seller</th>
|
||||
<th>Price</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>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Offer</th>
|
||||
<th>Item</th>
|
||||
<th>Seller</th>
|
||||
<th>Price</th>
|
||||
<th>Listed</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+33
-23
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -79,23 +80,28 @@ export default async function MessagesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Messages</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.25rem" }}>
|
||||
Friend requests and offline messages waiting for you.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="✉️"
|
||||
title="Messages"
|
||||
subtitle="Friend requests and offline messages waiting for you"
|
||||
/>
|
||||
|
||||
{/* ── Pending friend requests ───────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Friend requests</h2>
|
||||
<ContentCard
|
||||
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 ? (
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No pending friend requests.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon="🙋">No pending friend requests.</EmptyState>
|
||||
) : (
|
||||
<div className="grid">
|
||||
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
||||
{requests.map((req) => {
|
||||
const from = userById.get(req.userFromId);
|
||||
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
|
||||
@@ -141,19 +147,23 @@ export default async function MessagesPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Offline messages ──────────────────────────────────── */}
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Offline messages</h2>
|
||||
<ContentCard
|
||||
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 ? (
|
||||
<div className="card">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
You have no offline messages.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon="💬">You have no offline messages.</EmptyState>
|
||||
) : (
|
||||
<div className="grid">
|
||||
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
||||
{messages.map((msg) => {
|
||||
const from = userById.get(msg.userFromId);
|
||||
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
|
||||
@@ -194,7 +204,7 @@ export default async function MessagesPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { postComment } from "@/actions/article-comments";
|
||||
@@ -86,28 +87,30 @@ export default async function ArticlePage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/news">← News</Link>
|
||||
</p>
|
||||
<h1>{article.title}</h1>
|
||||
{article.createdAt ? (
|
||||
<p className="muted">{article.createdAt.toISOString().slice(0, 10)}</p>
|
||||
) : null}
|
||||
{article.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.image}
|
||||
alt=""
|
||||
style={{ width: "100%", borderRadius: 10, margin: "1rem 0" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title={article.title}
|
||||
subtitle={article.createdAt ? article.createdAt.toISOString().slice(0, 10) : undefined}
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
← News
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{article.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.image}
|
||||
alt=""
|
||||
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Reactions ─────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Reactions</h2>
|
||||
|
||||
<ContentCard icon="💬" title="Reactions" subtitle="Tell us what you think">
|
||||
{loggedIn ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{REACTIONS.map((r) => {
|
||||
@@ -136,9 +139,9 @@ export default async function ArticlePage({
|
||||
})}
|
||||
</div>
|
||||
) : reactionCounts.size === 0 ? (
|
||||
<p className="muted">
|
||||
<EmptyState icon="💬">
|
||||
No reactions yet. <Link href="/login">Log in</Link> to react.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
@@ -156,13 +159,16 @@ export default async function ArticlePage({
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Comments ──────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Comments</h2>
|
||||
<ContentCard
|
||||
icon="🗨️"
|
||||
title="Comments"
|
||||
subtitle={comments.length > 0 ? `${comments.length} so far` : "Join the conversation"}
|
||||
>
|
||||
{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" }}>
|
||||
{comments.map((c) => {
|
||||
@@ -213,7 +219,7 @@ export default async function ArticlePage({
|
||||
<Link href="/login">Log in</Link> to leave a comment.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+31
-19
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -21,25 +22,36 @@ export default async function NewsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>News</h1>
|
||||
{articles.length === 0 ? (
|
||||
<p className="muted">No articles yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{articles.map((a) => (
|
||||
<article key={a.slug} className="card">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/news/${a.slug}`}>{a.title}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{excerpt(a.shortStory, 200)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title="News"
|
||||
subtitle="The latest announcements, events and updates from the hotel"
|
||||
/>
|
||||
|
||||
<ContentCard padded={articles.length === 0}>
|
||||
{articles.length === 0 ? (
|
||||
<EmptyState icon="📰">No articles yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{articles.map((a) => (
|
||||
<article
|
||||
key={a.slug}
|
||||
className="card hover"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
||||
<Link href={`/news/${a.slug}`}>{a.title}</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+50
-27
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -20,13 +21,16 @@ export default async function HomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="hero">
|
||||
<h1 style={{ fontSize: 36, marginTop: 0 }}>Welcome to {hotelName}</h1>
|
||||
<p className="muted" style={{ fontSize: 16 }}>
|
||||
Your retro hotel, now powered by Next.js. Hang out, collect furni, and meet friends.
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏨"
|
||||
title={`Welcome to ${hotelName}`}
|
||||
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>
|
||||
<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">
|
||||
Enter hotel
|
||||
</Link>
|
||||
@@ -34,28 +38,47 @@ export default async function HomePage() {
|
||||
Latest news
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
<h2>Latest news</h2>
|
||||
{articles.length === 0 ? (
|
||||
<p className="muted">No articles yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{articles.map((a) => (
|
||||
<article key={a.slug} className="card hover article">
|
||||
{a.image ? <img className="article-img" src={a.image} alt="" /> : <div className="article-img" />}
|
||||
<div className="body">
|
||||
<h3 style={{ margin: "0 0 0.3rem" }}>
|
||||
<Link href={`/news/${a.slug}`}>{a.title}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{excerpt(a.shortStory, 120)}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title="Latest news"
|
||||
subtitle="Fresh from the hotel"
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
All news →
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{articles.length === 0 ? (
|
||||
<EmptyState icon="📰">No articles yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-4">
|
||||
{articles.map((a) => (
|
||||
<article
|
||||
key={a.slug}
|
||||
className="card hover"
|
||||
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>
|
||||
);
|
||||
}
|
||||
+37
-34
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,41 +30,43 @@ export default async function PhotosPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1>Photos</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
The latest snapshots taken in the hotel.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📸"
|
||||
title="Photos"
|
||||
subtitle="The latest snapshots taken in the hotel"
|
||||
/>
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<p className="muted">No photos yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{photos.map((p) => (
|
||||
<article key={String(p.id)} className="card">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`Photo by user #${p.userId}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
borderRadius: 4,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
{formatDate(p.timestamp)}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
by user #{p.userId}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ContentCard padded={photos.length === 0}>
|
||||
{photos.length === 0 ? (
|
||||
<EmptyState icon="📸">No photos yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
|
||||
{photos.map((p) => (
|
||||
<article key={String(p.id)} className="card hover" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`Photo by user #${p.userId}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: "0.7rem 0.85rem" }}>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{formatDate(p.timestamp)}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
by user #{p.userId}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { applyDj } from "@/actions/radio-apply";
|
||||
@@ -26,18 +27,17 @@ export default async function RadioApplyPage() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>Apply to be a Radio DJ</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Tell us about yourself and why you'd be a great fit for the airwaves.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎤"
|
||||
title="Apply to be a Radio DJ"
|
||||
subtitle="Tell us about yourself and why you'd be a great fit for the airwaves"
|
||||
/>
|
||||
|
||||
{existing ? (
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<ContentCard icon="📋" title="Your latest application">
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Your latest application:</strong> {existing.status}
|
||||
<strong>Status:</strong> {existing.status}
|
||||
{submittedDate ? (
|
||||
<span className="muted"> · submitted {submittedDate}</span>
|
||||
) : null}
|
||||
@@ -45,101 +45,103 @@ export default async function RadioApplyPage() {
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
You can submit again below if your details have changed.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
<form action={applyDj} className="card">
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="realName" className="muted">
|
||||
Real name
|
||||
<ContentCard icon="📝" title="Application form" subtitle="All fields marked required must be filled in">
|
||||
<form action={applyDj}>
|
||||
<div className="card-grid sm-2">
|
||||
<div>
|
||||
<label htmlFor="realName" className="muted">
|
||||
Real name
|
||||
</label>
|
||||
<input
|
||||
id="realName"
|
||||
name="realName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Your full name"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="muted">
|
||||
Age
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
name="age"
|
||||
type="number"
|
||||
required
|
||||
min={13}
|
||||
max={120}
|
||||
placeholder="18"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="availability" className="muted">
|
||||
Availability
|
||||
</label>
|
||||
<textarea
|
||||
id="availability"
|
||||
name="availability"
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Which days and times can you broadcast?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="musicStyle" className="muted">
|
||||
Music style
|
||||
</label>
|
||||
<input
|
||||
id="realName"
|
||||
name="realName"
|
||||
id="musicStyle"
|
||||
name="musicStyle"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Your full name"
|
||||
placeholder="e.g. House, Pop, Hip-Hop"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="muted">
|
||||
Age
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="experience" className="muted">
|
||||
Experience <span className="muted">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
name="age"
|
||||
type="number"
|
||||
required
|
||||
min={13}
|
||||
max={120}
|
||||
placeholder="18"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
<textarea
|
||||
id="experience"
|
||||
name="experience"
|
||||
rows={3}
|
||||
placeholder="Any prior DJ or hosting experience?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="availability" className="muted">
|
||||
Availability
|
||||
</label>
|
||||
<textarea
|
||||
id="availability"
|
||||
name="availability"
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Which days and times can you broadcast?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="motivation" className="muted">
|
||||
Motivation
|
||||
</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Why do you want to join the radio team?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="musicStyle" className="muted">
|
||||
Music style
|
||||
</label>
|
||||
<input
|
||||
id="musicStyle"
|
||||
name="musicStyle"
|
||||
type="text"
|
||||
maxLength={255}
|
||||
placeholder="e.g. House, Pop, Hip-Hop"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="experience" className="muted">
|
||||
Experience <span className="muted">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="experience"
|
||||
name="experience"
|
||||
rows={3}
|
||||
placeholder="Any prior DJ or hosting experience?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="motivation" className="muted">
|
||||
Motivation
|
||||
</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Why do you want to join the radio team?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
|
||||
Submit application
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
|
||||
Submit application
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -17,33 +18,39 @@ export default async function RadioContestsPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Contests</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Tune in and take part in our latest on-air contests.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎉"
|
||||
title="Radio Contests"
|
||||
subtitle="Tune in and take part in our latest on-air contests"
|
||||
/>
|
||||
|
||||
{contests.length === 0 ? (
|
||||
<p className="muted">No contests yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{contests.map((c) => {
|
||||
const id = c.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ContentCard padded={contests.length === 0}>
|
||||
{contests.length === 0 ? (
|
||||
<EmptyState icon="🎉">No contests yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{contests.map((c) => {
|
||||
const id = c.id.toString();
|
||||
return (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/radio/contests/${id}`}
|
||||
className="card hover"
|
||||
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: "1rem" }}>
|
||||
<span aria-hidden>🎉</span> Contest #{id}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -17,33 +18,39 @@ export default async function RadioGiveawaysPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Giveaways</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Listen in for your chance to win in our radio giveaways.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎁"
|
||||
title="Radio Giveaways"
|
||||
subtitle="Listen in for your chance to win in our radio giveaways"
|
||||
/>
|
||||
|
||||
{giveaways.length === 0 ? (
|
||||
<p className="muted">No giveaways yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ContentCard padded={giveaways.length === 0}>
|
||||
{giveaways.length === 0 ? (
|
||||
<EmptyState icon="🎁">No giveaways yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
return (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/radio/giveaways/${id}`}
|
||||
className="card hover"
|
||||
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: "1rem" }}>
|
||||
<span aria-hidden>🎁</span> Giveaway #{id}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -43,22 +44,21 @@ export default async function RadioLeaderboardPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Listener Leaderboard</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Top 50 listeners by points earned tuning in to the radio.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏆"
|
||||
title="Radio Listener Leaderboard"
|
||||
subtitle="Top 50 listeners by points earned tuning in to the radio"
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<ContentCard padded={rows.length === 0}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No listener points to show yet.
|
||||
</p>
|
||||
<EmptyState icon="🏆">No listener points to show yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "3rem" }}>#</th>
|
||||
<th style={{ width: "3.5rem" }}>Rank</th>
|
||||
<th style={{ width: "3.5rem" }}>Avatar</th>
|
||||
<th>Listener</th>
|
||||
<th style={{ textAlign: "right" }}>Points</th>
|
||||
@@ -72,7 +72,9 @@ export default async function RadioLeaderboardPage() {
|
||||
});
|
||||
return (
|
||||
<tr key={`${row.username}-${i}`}>
|
||||
<td style={{ fontWeight: 800 }}>{i + 1}</td>
|
||||
<td>
|
||||
<RankBadge position={i + 1} />
|
||||
</td>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -100,7 +102,7 @@ export default async function RadioLeaderboardPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+88
-64
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -6,12 +7,12 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Sub-pages linked from the radio home hub.
|
||||
const RADIO_LINKS: { label: string; href: string }[] = [
|
||||
{ label: "Schedule", href: "/radio/schedule" },
|
||||
{ label: "Shouts", href: "/radio/shouts" },
|
||||
{ label: "Requests", href: "/radio/requests" },
|
||||
{ label: "Apply as DJ", href: "/radio/apply" },
|
||||
{ label: "Leaderboard", href: "/radio/leaderboard" },
|
||||
const RADIO_LINKS: { label: string; href: string; icon: string; text: string }[] = [
|
||||
{ label: "Schedule", href: "/radio/schedule", icon: "🗓️", text: "Our weekly line-up of DJs and shows." },
|
||||
{ label: "Shouts", href: "/radio/shouts", icon: "📣", text: "Shout-outs and requests from the community." },
|
||||
{ label: "Requests", href: "/radio/requests", icon: "🎵", text: "Ask the DJ to spin your favourite track." },
|
||||
{ label: "Apply as DJ", href: "/radio/apply", icon: "🎤", text: "Join the team and take to the airwaves." },
|
||||
{ label: "Leaderboard", href: "/radio/leaderboard", icon: "🏆", text: "Top listeners ranked by points earned." },
|
||||
];
|
||||
|
||||
// Canonical weekday order so today's slots come out chronologically and we can
|
||||
@@ -69,28 +70,30 @@ export default async function RadioPage() {
|
||||
: [];
|
||||
const djById = new Map(djs.map((d) => [d.id, d.username]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* ── Hero: live stream + now playing ─────────────────────── */}
|
||||
<div className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Radio</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Tune in to the hotel radio — live DJ sets, requests and shoutouts.
|
||||
</p>
|
||||
const isLive = Boolean(streamUrl);
|
||||
|
||||
<div className="grid cols-2" style={{ marginTop: "1rem" }}>
|
||||
<div>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>Now playing</strong>
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{/* ── Header: live stream + now playing ─────────────────────── */}
|
||||
<ContentCard
|
||||
icon="📻"
|
||||
title="Radio"
|
||||
subtitle="Tune in to the hotel radio — live DJ sets, requests and shoutouts."
|
||||
action={<OnlineBadge online={isLive} />}
|
||||
>
|
||||
<div className="card-grid sm-2" style={{ alignItems: "stretch" }}>
|
||||
<div className="card" style={{ display: "grid", gap: "0.35rem", alignContent: "center" }}>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.8rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Now playing
|
||||
</p>
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}>
|
||||
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>}
|
||||
<p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}>
|
||||
{currentSong ? currentSong : <span className="muted" style={{ fontWeight: 400 }}>Nothing playing right now.</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{currentDj ? `On air: ${currentDj}` : "No DJ on air."}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<div className="card" style={{ display: "flex", alignItems: "center" }}>
|
||||
{streamUrl ? (
|
||||
<audio
|
||||
controls
|
||||
@@ -107,60 +110,81 @@ export default async function RadioPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Sub-page links ──────────────────────────────────────── */}
|
||||
<section style={{ marginBottom: "1.5rem" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
|
||||
<ContentCard
|
||||
icon="🎚️"
|
||||
title="Explore the radio"
|
||||
subtitle="Schedule, shouts, requests and more"
|
||||
>
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{RADIO_LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className="btn btn-outline">
|
||||
{l.label}
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="card hover"
|
||||
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.4rem" }}
|
||||
>
|
||||
<p style={{ margin: 0, fontWeight: 700 }}>
|
||||
<span aria-hidden>{l.icon}</span> {l.label}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{l.text}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Today's schedule ────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2>Today’s schedule{today ? ` · ${today}` : ""}</h2>
|
||||
<ContentCard
|
||||
icon="🗓️"
|
||||
title={`Today's schedule${today ? ` · ${today}` : ""}`}
|
||||
subtitle="What's on the airwaves today"
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/radio/schedule">
|
||||
Full week →
|
||||
</Link>
|
||||
}
|
||||
padded={todaysSlots.length === 0}
|
||||
>
|
||||
{todaysSlots.length === 0 ? (
|
||||
<p className="muted">No shows scheduled for today.</p>
|
||||
<EmptyState icon="📻">No shows scheduled for today.</EmptyState>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Show</th>
|
||||
<th>DJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{todaysSlots.map((s) => {
|
||||
const djName = djById.get(Number(s.userId));
|
||||
return (
|
||||
<tr key={String(s.id)}>
|
||||
<td>
|
||||
{formatTime(s.startTime)} – {formatTime(s.endTime)}
|
||||
</td>
|
||||
<td>
|
||||
{s.showName || <span className="muted">Untitled show</span>}
|
||||
</td>
|
||||
<td>
|
||||
{djName ? (
|
||||
<Link href={`/u/${djName}`}>{djName}</Link>
|
||||
) : (
|
||||
<span className="muted">{`User #${s.userId}`}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Show</th>
|
||||
<th>DJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{todaysSlots.map((s) => {
|
||||
const djName = djById.get(Number(s.userId));
|
||||
return (
|
||||
<tr key={String(s.id)}>
|
||||
<td>
|
||||
{formatTime(s.startTime)} – {formatTime(s.endTime)}
|
||||
</td>
|
||||
<td>
|
||||
{s.showName || <span className="muted">Untitled show</span>}
|
||||
</td>
|
||||
<td>
|
||||
{djName ? (
|
||||
<Link href={`/u/${djName}`}>{djName}</Link>
|
||||
) : (
|
||||
<span className="muted">{`User #${s.userId}`}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -72,16 +73,17 @@ export default async function RadioSchedulePage() {
|
||||
const hasAny = schedules.length > 0;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Schedule</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Our weekly line-up of DJs and shows.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🗓️"
|
||||
title="Radio Schedule"
|
||||
subtitle="Our weekly line-up of DJs and shows"
|
||||
/>
|
||||
|
||||
{!hasAny ? (
|
||||
<p className="muted">No shows scheduled yet.</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<ContentCard padded={!hasAny}>
|
||||
{!hasAny ? (
|
||||
<EmptyState icon="🗓️">No shows scheduled yet.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -125,8 +127,8 @@ export default async function RadioSchedulePage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -40,76 +41,88 @@ export default async function RadioShoutsPage() {
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Shouts</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Shout-outs and requests from the community.
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📣"
|
||||
title="Radio Shouts"
|
||||
subtitle="Shout-outs and requests from the community"
|
||||
/>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<form action={postShout} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<ContentCard icon="✍️" title="Post a shout" subtitle="Send a message to the DJ on air">
|
||||
{/* The author is taken from the session inside the action, never from
|
||||
this form — no user id is submitted. */}
|
||||
<label htmlFor="shout-message" className="muted">
|
||||
Post a shout
|
||||
</label>
|
||||
<textarea
|
||||
id="shout-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder="Send a shout to the DJ…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Shout
|
||||
</button>
|
||||
</form>
|
||||
<form action={postShout}>
|
||||
<label htmlFor="shout-message" className="muted">
|
||||
Your message
|
||||
</label>
|
||||
<textarea
|
||||
id="shout-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder="Send a shout to the DJ…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Shout
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<p className="muted">Log in to post a shout.</p>
|
||||
<ContentCard padded>
|
||||
<EmptyState icon="🔒">Log in to post a shout.</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{shouts.length === 0 ? (
|
||||
<p className="muted">No shouts yet. Be the first!</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
const authorAvatar = author
|
||||
? avatarImageUrl(imagerBase ?? "", author.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={String(s.id)}
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{authorAvatar ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
className="avatar"
|
||||
src={authorAvatar}
|
||||
alt={author?.username ?? "author"}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
|
||||
<span className="muted">{formatDate(s.createdAt)}</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{s.message}</p>
|
||||
<ContentCard
|
||||
icon="💬"
|
||||
title="Latest shouts"
|
||||
subtitle="The most recent messages from listeners"
|
||||
padded={shouts.length === 0}
|
||||
>
|
||||
{shouts.length === 0 ? (
|
||||
<EmptyState icon="📣">No shouts yet. Be the first!</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
const authorAvatar = author
|
||||
? avatarImageUrl(imagerBase ?? "", author.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={String(s.id)}
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{authorAvatar ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
className="avatar"
|
||||
src={authorAvatar}
|
||||
alt={author?.username ?? "author"}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
|
||||
<span className="muted">{formatDate(s.createdAt)}</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{s.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+42
-44
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -10,13 +11,14 @@ type TopUser = {
|
||||
look: string;
|
||||
credits: number;
|
||||
rank: number;
|
||||
online: string;
|
||||
};
|
||||
|
||||
export default async function RankingsPage() {
|
||||
let users: TopUser[] = [];
|
||||
try {
|
||||
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" },
|
||||
take: 12,
|
||||
});
|
||||
@@ -30,51 +32,47 @@ export default async function RankingsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Rankings</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The top 12 players ranked by credits.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="🏆" title="Rankings" subtitle="The top 12 players ranked by credits" />
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="muted">No players to rank yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{users.map((u, i) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" });
|
||||
return (
|
||||
<div
|
||||
key={u.username}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "1rem", alignItems: "center" }}
|
||||
>
|
||||
<span style={{ fontSize: "1.5rem", fontWeight: 800, minWidth: "2rem" }}>
|
||||
#{i + 1}
|
||||
</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${u.username} avatar`}
|
||||
width={64}
|
||||
height={110}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.2rem" }}>
|
||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||
</h3>
|
||||
<p className="currency" style={{ margin: 0 }}>
|
||||
<span className="coin credits">c</span>
|
||||
{u.credits.toLocaleString()}
|
||||
</p>
|
||||
<ContentCard padded={users.length === 0}>
|
||||
{users.length === 0 ? (
|
||||
<EmptyState icon="🏆">No players to rank yet.</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{users.map((u, i) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" });
|
||||
return (
|
||||
<div
|
||||
key={u.username}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "0.9rem", alignItems: "center" }}
|
||||
>
|
||||
<RankBadge position={i + 1} />
|
||||
{/* biome-ignore lint/performance/noImgElement: external avatar imager */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${u.username} avatar`}
|
||||
width={50}
|
||||
height={90}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||
</h3>
|
||||
<p className="currency" style={{ margin: "0 0 0.3rem" }}>
|
||||
<span className="coin credits">c</span>
|
||||
{u.credits.toLocaleString()}
|
||||
</p>
|
||||
<OnlineBadge online={u.online === "1"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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` : "";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p style={{ margin: "0 0 0.75rem" }}>
|
||||
<Link href="/rares">← All rares</Link>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="hero"
|
||||
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon={badgeSrc ? undefined : "💎"}
|
||||
title={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6rem" }}>
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={badgeSrc} alt={`${cat.name} badge`} width={48} height={48} />
|
||||
) : null}
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>{cat.name}</h1>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
{rares.length} {rares.length === 1 ? "rare" : "rares"} in this category
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rares.length === 0 ? (
|
||||
<p className="muted">No rares listed in this category yet.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}></th>
|
||||
<th>Rare</th>
|
||||
<th>Credits</th>
|
||||
<th>Currency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rares.map((r) => {
|
||||
const iconSrc =
|
||||
iconBase && r.furnitureIcon ? `${iconBase}/${r.furnitureIcon}` : "";
|
||||
return (
|
||||
<tr key={String(r.id)}>
|
||||
<td>
|
||||
{iconSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="avatar"
|
||||
style={{ display: "inline-block", width: 40, height: 40 }}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{r.name}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{r.creditValue && r.creditValue !== "" ? (
|
||||
`${r.creditValue} credits`
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.currencyValue && r.currencyValue !== "" ? (
|
||||
`${r.currencyValue} ${prettyCurrency(r.currencyType)}`
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{rares.length === 0 ? (
|
||||
<EmptyState icon="💎">No rares listed in this category yet.</EmptyState>
|
||||
) : (
|
||||
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}></th>
|
||||
<th>Rare</th>
|
||||
<th>Credits</th>
|
||||
<th>Currency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rares.map((r) => {
|
||||
const iconSrc =
|
||||
iconBase && r.furnitureIcon ? `${iconBase}/${r.furnitureIcon}` : "";
|
||||
return (
|
||||
<tr key={String(r.id)}>
|
||||
<td>
|
||||
{iconSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="avatar"
|
||||
style={{ display: "inline-block", width: 40, height: 40 }}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{r.name}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{r.creditValue && r.creditValue !== "" ? (
|
||||
`${r.creditValue} credits`
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.currencyValue && r.currencyValue !== "" ? (
|
||||
`${r.currencyValue} ${prettyCurrency(r.currencyType)}`
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+38
-35
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -39,18 +40,19 @@ export default async function RareValuesPage() {
|
||||
const badgesBase = (await siteSettings.get("badges_path", "")) ?? "";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Rare values</h1>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Get an overview of all of the rares on the hotel, grouped by category.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="💎"
|
||||
title="Rare values"
|
||||
subtitle="Get an overview of all of the rares on the hotel, grouped by category."
|
||||
/>
|
||||
|
||||
{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) => {
|
||||
const id = String(c.id);
|
||||
const count = counts.get(id) ?? 0;
|
||||
@@ -59,33 +61,34 @@ export default async function RareValuesPage() {
|
||||
<Link
|
||||
key={id}
|
||||
href={`/rares/${id}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.85rem",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}
|
||||
className="content-card hover-lift"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={badgeSrc}
|
||||
alt={`${c.name} badge`}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<span className="avatar" style={{ width: 40, height: 40, flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ display: "flex", flexDirection: "column" }}>
|
||||
<strong>{c.name}</strong>
|
||||
<span className="muted">
|
||||
{count} {count === 1 ? "rare" : "rares"}
|
||||
</span>
|
||||
</span>
|
||||
<div className="content-card-head">
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={badgeSrc}
|
||||
alt={`${c.name} badge`}
|
||||
width={36}
|
||||
height={36}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
💎
|
||||
</span>
|
||||
)}
|
||||
<div className="content-card-head-text">
|
||||
<p className="content-card-title">{c.name}</p>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
+16
-22
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import RedeemForm from "./RedeemForm";
|
||||
import { ContentCard, StatBlock } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -25,36 +26,29 @@ export default async function RedeemPage() {
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>Redeem a voucher</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Got a voucher code? Enter it below to add the reward to your account.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎟️"
|
||||
title="Redeem a voucher"
|
||||
subtitle="Got a voucher code? Enter it below to add the reward to your account."
|
||||
/>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Enter your code</h3>
|
||||
<div className="card-grid sm-2">
|
||||
<ContentCard icon="🔑" title="Enter your code">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Your balance</h3>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
Signed in as <strong>{user.username}</strong>.
|
||||
</p>
|
||||
<span className="currency">
|
||||
<span className="coin credits">cr</span>
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
<ContentCard icon="💰" title="Your balance" subtitle={`Signed in as ${user.username}`}>
|
||||
<div className="stat-grid">
|
||||
<StatBlock value={user.credits.toLocaleString()} label="Credits" icon="🪙" />
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
|
||||
Redeemed rewards are credited instantly and appear in-game the next time you log in.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { register } from "@/actions/register";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default async function RegisterPage({
|
||||
searchParams,
|
||||
@@ -9,13 +10,13 @@ export default async function RegisterPage({
|
||||
const { error } = await searchParams;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem" }}>
|
||||
<h1 style={{ marginTop: 0, textAlign: "center" }}>Create account</h1>
|
||||
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>
|
||||
Join the hotel — it only takes a moment.
|
||||
</p>
|
||||
<form action={register} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon="🎉"
|
||||
title="Create account"
|
||||
subtitle="Join the hotel — it only takes a moment."
|
||||
>
|
||||
<form action={register} style={{ display: "grid", gap: "0.7rem" }}>
|
||||
<input name="username" placeholder="Username" autoComplete="username" required />
|
||||
<input name="mail" type="email" placeholder="Email" autoComplete="email" required />
|
||||
<input
|
||||
@@ -37,7 +38,7 @@ export default async function RegisterPage({
|
||||
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
|
||||
Already have an account? <Link href="/login">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+12
-6
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { resetPassword } from "@/actions/password-reset";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default async function ResetPage({
|
||||
searchParams,
|
||||
@@ -9,10 +10,13 @@ export default async function ResetPage({
|
||||
const { email = "", token = "", error } = await searchParams;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem" }}>
|
||||
<h1 style={{ marginTop: 0, textAlign: "center" }}>Set a new password</h1>
|
||||
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon="🔐"
|
||||
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="token" value={token} />
|
||||
<input
|
||||
@@ -27,12 +31,14 @@ export default async function ResetPage({
|
||||
</button>
|
||||
</form>
|
||||
{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}
|
||||
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { totpKeyUri } from "@/lib/auth/totp";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -47,67 +48,81 @@ export default async function TwoFactorPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 520 }}>
|
||||
<p className="muted">
|
||||
<main style={{ display: "grid", gap: "1.5rem", maxWidth: 560, margin: "0 auto" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/settings">← Settings</Link>
|
||||
</p>
|
||||
<h1>Two-factor authentication</h1>
|
||||
|
||||
{sp.enabled ? <p style={{ color: "var(--color-accent)" }}>2FA is now enabled. 🔒</p> : null}
|
||||
{sp.disabled ? <p className="muted">2FA has been disabled.</p> : null}
|
||||
{sp.error === "badcode" ? (
|
||||
<p style={{ color: "var(--color-danger)" }}>That code wasn't valid — try again.</p>
|
||||
) : null}
|
||||
<ContentCard
|
||||
icon="🔒"
|
||||
title="Two-factor authentication"
|
||||
subtitle="Add a second layer of security with an authenticator app"
|
||||
>
|
||||
{sp.enabled ? (
|
||||
<p style={{ color: "var(--color-accent)", marginTop: 0 }}>2FA is now enabled. 🔒</p>
|
||||
) : null}
|
||||
{sp.disabled ? (
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
2FA has been disabled.
|
||||
</p>
|
||||
) : null}
|
||||
{sp.error === "badcode" ? (
|
||||
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
|
||||
That code wasn't valid — try again.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!hasAppKey ? (
|
||||
<div className="card">
|
||||
{!hasAppKey ? (
|
||||
<p style={{ margin: 0 }}>
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
) : enabled ? (
|
||||
<div className="card">
|
||||
<p>
|
||||
<strong>2FA is enabled</strong> on your account.
|
||||
</p>
|
||||
<form action={disableTwoFactor}>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Disable 2FA
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : pending ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Scan or enter this key</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm.
|
||||
</p>
|
||||
<p>
|
||||
Manual key: <code style={{ userSelect: "all" }}>{secret}</code>
|
||||
</p>
|
||||
<p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}>
|
||||
{uri}
|
||||
</p>
|
||||
<form action={confirmTwoFactor} style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<input name="code" placeholder="6-digit code" inputMode="numeric" required />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Confirm
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Add a second layer of security with an authenticator app.
|
||||
</p>
|
||||
<form action={beginTwoFactor}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Enable 2FA
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
) : enabled ? (
|
||||
<>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<strong>2FA is enabled</strong> on your account.
|
||||
</p>
|
||||
<form action={disableTwoFactor}>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Disable 2FA
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : pending ? (
|
||||
<>
|
||||
<h3 style={{ marginTop: 0 }}>Scan or enter this key</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Add this to Google Authenticator / Authy, then enter the 6-digit code to confirm.
|
||||
</p>
|
||||
<p>
|
||||
Manual key: <code style={{ userSelect: "all" }}>{secret}</code>
|
||||
</p>
|
||||
<p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}>
|
||||
{uri}
|
||||
</p>
|
||||
<form
|
||||
action={confirmTwoFactor}
|
||||
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
|
||||
>
|
||||
<input name="code" placeholder="6-digit code" inputMode="numeric" required />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Confirm
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Add a second layer of security with an authenticator app.
|
||||
</p>
|
||||
<form action={beginTwoFactor}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Enable 2FA
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/env";
|
||||
@@ -14,11 +15,13 @@ const PROVIDERS = [
|
||||
{
|
||||
key: "discord",
|
||||
label: "Discord",
|
||||
icon: "🎮",
|
||||
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
label: "Google",
|
||||
icon: "🔵",
|
||||
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
||||
},
|
||||
] as const;
|
||||
@@ -48,85 +51,82 @@ export default async function ConnectionsPage() {
|
||||
const callbackUrl = "/settings/connections";
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 560 }}>
|
||||
<p className="muted">
|
||||
<main style={{ display: "grid", gap: "1.5rem", maxWidth: 620, margin: "0 auto" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/settings">← Settings</Link>
|
||||
</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 ? (
|
||||
<div className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We couldn't read your linked accounts right now. You can still connect
|
||||
below — your status will show once it's available.
|
||||
<ContentCard
|
||||
icon="🔗"
|
||||
title="Connected accounts"
|
||||
subtitle="Link a social account so you can sign in with one click"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</ContentCard>
|
||||
|
||||
<div className="grid" style={{ gap: "1rem" }}>
|
||||
<div className="card-grid sm-2">
|
||||
{PROVIDERS.map((p) => {
|
||||
const isLinked = linked.has(p.key);
|
||||
return (
|
||||
<div
|
||||
<ContentCard
|
||||
key={p.key}
|
||||
className="card"
|
||||
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
|
||||
icon={p.icon}
|
||||
title={p.label}
|
||||
subtitle={
|
||||
!p.available
|
||||
? "Not available on this hotel."
|
||||
: isLinked
|
||||
? "Connected — sign in with this account."
|
||||
: "Not connected."
|
||||
}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{!p.available
|
||||
? "Not available on this hotel."
|
||||
: isLinked
|
||||
? "Connected — you can sign in with this account."
|
||||
: "Not connected."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{isLinked ? (
|
||||
<span
|
||||
className="btn btn-outline"
|
||||
style={{ cursor: "default", pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
) : p.available ? (
|
||||
// Plain link into NextAuth's built-in sign-in handler. Re-using
|
||||
// the same OAuth flow as /login binds the provider to the
|
||||
// matching hotel account (see auth.ts signIn callback).
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
|
||||
callbackUrl,
|
||||
)}`}
|
||||
>
|
||||
Connect {p.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="btn"
|
||||
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLinked ? (
|
||||
<span
|
||||
className="btn btn-outline"
|
||||
style={{ cursor: "default", pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
) : p.available ? (
|
||||
// Plain link into NextAuth's built-in sign-in handler. Re-using
|
||||
// the same OAuth flow as /login binds the provider to the
|
||||
// matching hotel account (see auth.ts signIn callback).
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
|
||||
callbackUrl,
|
||||
)}`}
|
||||
>
|
||||
Connect {p.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="btn"
|
||||
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</ContentCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ marginTop: "1.5rem", marginBottom: 0 }}>
|
||||
Connecting takes you to the provider to authorise, then back here. If your
|
||||
social account's e-mail doesn't match a hotel account, you'll be returned
|
||||
to the login screen with a note.
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Connecting takes you to the provider to authorise, then back here. If your social account's
|
||||
e-mail doesn't match a hotel account, you'll be returned to the login screen with a note.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
||||
+49
-41
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { updateMotto } from "@/actions/user-settings";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -42,29 +43,39 @@ export default async function SettingsPage() {
|
||||
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Account settings</h1>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="⚙️"
|
||||
title="Account settings"
|
||||
subtitle="Manage your profile, security and connected accounts"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "1.5rem", alignItems: "center", marginBottom: "1.5rem" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="avatar" src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
|
||||
<div>
|
||||
<h2 style={{ margin: "0 0 0.25rem" }}>{user.username}</h2>
|
||||
<p style={{ margin: "0 0 0.5rem" }}>
|
||||
{user.motto || <span className="muted">No motto</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{user.mail || "No e-mail on file"}
|
||||
</p>
|
||||
<ContentCard icon="🙂" title={user.username} subtitle="Your public profile">
|
||||
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${user.username} avatar`}
|
||||
width={100}
|
||||
height={150}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
|
||||
{user.motto || <span className="muted">No motto</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{user.mail || "No e-mail on file"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Change motto</h3>
|
||||
<form action={updateMotto} style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<ContentCard icon="✏️" title="Change motto" subtitle="Updates instantly in-game if you are online">
|
||||
<form
|
||||
action={updateMotto}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.75rem", maxWidth: 520 }}
|
||||
>
|
||||
<input
|
||||
name="motto"
|
||||
defaultValue={user.motto}
|
||||
@@ -78,29 +89,26 @@ export default async function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Updates instantly in-game if you are online.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Security</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Protect your account with two-factor authentication.
|
||||
</p>
|
||||
<Link href="/settings/2fa" className="btn btn-outline">
|
||||
Two-factor authentication
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-grid sm-2">
|
||||
<ContentCard icon="🔒" title="Security" subtitle="Two-factor authentication">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Add a second layer of protection to your account.
|
||||
</p>
|
||||
<Link href="/settings/2fa" className="btn btn-outline">
|
||||
Two-factor authentication
|
||||
</Link>
|
||||
</ContentCard>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Connected accounts</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Link Discord or Google to sign in with one click.
|
||||
</p>
|
||||
<Link href="/settings/connections" className="btn btn-outline">
|
||||
Manage connections
|
||||
</Link>
|
||||
<ContentCard icon="🔗" title="Connected accounts" subtitle="Sign in with one click">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Link Discord or Google to your hotel account.
|
||||
</p>
|
||||
<Link href="/settings/connections" className="btn btn-outline">
|
||||
Manage connections
|
||||
</Link>
|
||||
</ContentCard>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
+128
-119
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -104,136 +105,144 @@ export default async function ShopPage({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>Shop</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{activeCategory
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🛒"
|
||||
title="Shop"
|
||||
subtitle={
|
||||
activeCategory
|
||||
? `Browsing the ${activeCategory.name} packages.`
|
||||
: "Top up your account and grab exclusive packages, currency and badges."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/shop"
|
||||
className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}
|
||||
: "Top up your account and grab exclusive packages, currency and badges."
|
||||
}
|
||||
>
|
||||
{categories.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
{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 href="/shop" className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}>
|
||||
All
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<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",
|
||||
}}
|
||||
{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>
|
||||
))}
|
||||
</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
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#f8fafc",
|
||||
border: `2px solid ${a.color || "var(--color-golden)"}`,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "0.6rem",
|
||||
}}
|
||||
>
|
||||
{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" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#f8fafc",
|
||||
border: `2px solid ${a.color || "var(--border-color)"}`,
|
||||
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}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0 }}>{a.name}</h3>
|
||||
<span className="muted">{priceLabel(a.costs)}</span>
|
||||
|
||||
<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>
|
||||
</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}
|
||||
</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>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+17
-22
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import TopUpForm from "./TopUpForm";
|
||||
import { ContentCard, StatBlock } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
|
||||
@@ -33,23 +34,21 @@ export default async function TopUpPage({
|
||||
const rate = creditsPerUnit();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>Top up with PayPal</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Pay securely with PayPal and your credits are added the moment the payment clears.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="💳"
|
||||
title="Top up with PayPal"
|
||||
subtitle="Pay securely with PayPal and your credits are added the moment the payment clears."
|
||||
/>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Choose an amount</h3>
|
||||
<div className="card-grid sm-2">
|
||||
<ContentCard icon="🪙" title="Choose an amount">
|
||||
{configured ? (
|
||||
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
|
||||
) : (
|
||||
@@ -59,22 +58,18 @@ export default async function TopUpPage({
|
||||
<code>PAYPAL_API</code> for sandbox vs. live).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Your balance</h3>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
Signed in as <strong>{user.username}</strong>.
|
||||
</p>
|
||||
<span className="currency">
|
||||
<span className="coin credits">cr</span>
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
<ContentCard icon="💰" title="Your balance" subtitle={`Signed in as ${user.username}`}>
|
||||
<div className="stat-grid">
|
||||
<StatBlock value={user.credits.toLocaleString()} label="Credits" icon="🪙" />
|
||||
<StatBlock value={rate.toLocaleString()} label={`Credits per 1.00 ${PAYPAL_CURRENCY}`} />
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
|
||||
You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}.
|
||||
Credits appear in-game the next time you log in.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
+51
-42
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -53,53 +54,61 @@ export default async function StaffPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Staff</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The team behind the hotel.
|
||||
</p>
|
||||
</div>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="🛡️" title="Staff" subtitle="The team behind the hotel" />
|
||||
|
||||
{teams.length > 0 ? (
|
||||
<div className="grid cols-3" style={{ marginBottom: "1.5rem" }}>
|
||||
{teams.map((t) => (
|
||||
<div key={t.id} className="card">
|
||||
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3>
|
||||
{t.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ContentCard icon="⭐" title="Ranks" subtitle="What each part of the team takes care of">
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{teams.map((t) => (
|
||||
<div key={t.id} className="card">
|
||||
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3>
|
||||
{t.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
{staff.length === 0 ? (
|
||||
<p className="muted">No staff members to show.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Rank</th>
|
||||
<th>Motto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title="Team members"
|
||||
subtitle={
|
||||
staff.length === 0
|
||||
? "Nobody to show right now"
|
||||
: `${staff.length} member${staff.length === 1 ? "" : "s"} keeping things running`
|
||||
}
|
||||
padded={staff.length === 0}
|
||||
>
|
||||
{staff.length === 0 ? (
|
||||
<EmptyState icon="🛡️">No staff members to show.</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Rank</th>
|
||||
<th>Motto</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -88,31 +89,43 @@ export default async function ProfilePage({
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="card" style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
|
||||
<p style={{ margin: "0 0 0.5rem" }}>
|
||||
{user.motto || <span className="muted">No motto</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Rank {user.rank} · {user.credits} credits · {user.online === "1" ? "Online" : "Offline"} ·
|
||||
Registered {registered}
|
||||
</p>
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{/* ── Profile header ───────────────────────────────────── */}
|
||||
<ContentCard padded={false}>
|
||||
<div
|
||||
className="content-card-body"
|
||||
style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
{user.motto || <span className="muted">No motto</span>}
|
||||
</p>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
</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 ───────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Badges</h2>
|
||||
<ContentCard icon="🏅" title="Badges" padded={badges.length === 0}>
|
||||
{badges.length === 0 ? (
|
||||
<p className="muted">No badges yet.</p>
|
||||
<EmptyState icon="🏅">No badges yet.</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", flexWrap: "wrap", gap: "0.75rem", alignItems: "center" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
{badges.map((b) => (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
@@ -127,15 +140,14 @@ export default async function ProfilePage({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Photos ───────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Photos</h2>
|
||||
<ContentCard icon="📸" title="Photos" padded={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) => (
|
||||
<div key={p.id} className="card" style={{ padding: "0.5rem" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -151,12 +163,10 @@ export default async function ProfilePage({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Guestbook ────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Guestbook</h2>
|
||||
|
||||
<ContentCard icon="📝" title="Guestbook" subtitle={`Messages on ${user.username}'s wall`}>
|
||||
{isLoggedIn ? (
|
||||
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>
|
||||
{/* Profile owner id drives the write target; the author is taken from
|
||||
@@ -184,9 +194,9 @@ export default async function ProfilePage({
|
||||
)}
|
||||
|
||||
{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) => {
|
||||
const author = authorById.get(g.userId);
|
||||
const authorAvatar = author
|
||||
@@ -220,7 +230,7 @@ export default async function ProfilePage({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+39
-48
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { isValidVerificationToken } from "@/actions/email-verify";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Reads + writes the live users table — must never be statically rendered.
|
||||
@@ -50,54 +51,44 @@ export default async function VerifyPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Email verification</h1>
|
||||
<div className="card" style={{ marginTop: "1rem" }}>
|
||||
{status === "verified" ? (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0 }}>You're all set</h2>
|
||||
<p className="muted">
|
||||
Your email address has been verified. You can now log in and play.
|
||||
</p>
|
||||
<p style={{ marginTop: "1rem" }}>
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
Go to login
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : status === "already" ? (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0 }}>Already verified</h2>
|
||||
<p className="muted">This email address has already been confirmed.</p>
|
||||
<p style={{ marginTop: "1rem" }}>
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
Go to login
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : status === "unavailable" ? (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0 }}>Try again shortly</h2>
|
||||
<p className="muted">
|
||||
We couldn't reach the server to verify your email. Please retry the link in a
|
||||
moment.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0 }}>Invalid or expired link</h2>
|
||||
<p className="muted">
|
||||
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 style={{ maxWidth: 480, margin: "2rem auto" }}>
|
||||
{status === "verified" ? (
|
||||
<ContentCard icon="✅" title="You're all set" subtitle="Email verified">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Your email address has been verified. You can now log in and play.
|
||||
</p>
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
Go to login
|
||||
</Link>
|
||||
</ContentCard>
|
||||
) : status === "already" ? (
|
||||
<ContentCard icon="✅" title="Already verified" subtitle="Nothing left to do">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
This email address has already been confirmed.
|
||||
</p>
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
Go to login
|
||||
</Link>
|
||||
</ContentCard>
|
||||
) : status === "unavailable" ? (
|
||||
<ContentCard icon="⏳" title="Try again shortly" subtitle="Server unreachable">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We couldn't reach the server to verify your email. Please retry the link in a moment.
|
||||
</p>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard icon="📧" title="Email verification" subtitle="Invalid or expired link">
|
||||
<EmptyState icon="🔗">
|
||||
This verification link is not valid. Make sure you opened the full link from your email,
|
||||
or request a new one.
|
||||
</EmptyState>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Link href="/login" className="btn btn-outline">
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</ContentCard>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user