Files
Epicnabbo-Catalogus-Updated…/src/app/u/[username]/page.tsx
T
simoleo e9ea19795a 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.
2026-06-28 19:28:36 +02:00

237 lines
8.7 KiB
TypeScript

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";
import { siteSettings } from "@/lib/services/site-settings";
import { postGuestbook } from "@/actions/guestbook";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN. Badge codes (e.g. "ADM") map to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
const [user, imagerBase, session] = await Promise.all([
prisma.user.findUnique({
where: { username },
select: {
id: true,
username: true,
motto: true,
look: true,
rank: true,
credits: true,
online: true,
accountCreated: true,
},
}),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
auth(),
]);
if (!user) notFound();
const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
const registered = new Date(user.accountCreated * 1000).toISOString().slice(0, 10);
const isLoggedIn = Boolean(session?.user?.id);
// Profile sub-sections. Each query is isolated so a DB hiccup degrades that
// single section to empty rather than 500-ing the whole profile.
const badges = await prisma.usersBadges
.findMany({
where: { userId: user.id },
orderBy: { slotId: "asc" },
})
.catch(() => []);
const photos = await prisma.cameraWeb
.findMany({
where: { userId: user.id },
orderBy: { timestamp: "desc" },
take: 6,
})
.catch(() => []);
const guestbook = await prisma.websiteUserGuestbooks
.findMany({
where: { profileId: user.id },
orderBy: { createdAt: "desc" },
take: 30,
})
.catch(() => []);
// Resolve author usernames/looks for the guestbook entries in one query.
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
const authors = authorIds.length
? await prisma.user
.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true, look: true },
})
.catch(() => [])
: [];
const authorById = new Map(authors.map((a) => [a.id, a]));
return (
<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 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 ───────────────────────────────────────────── */}
<ContentCard icon="🏅" title="Badges" padded={badges.length === 0}>
{badges.length === 0 ? (
<EmptyState icon="🏅">No badges yet.</EmptyState>
) : (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
alignItems: "center",
padding: "1rem",
}}
>
{badges.map((b) => (
/* eslint-disable-next-line @next/next/no-img-element */
<img
key={b.id}
src={`${BADGE_IMG_BASE}/${b.badgeCode}.gif`}
alt={b.badgeCode}
title={b.badgeCode}
width={40}
height={40}
/>
))}
</div>
)}
</ContentCard>
{/* ── Photos ───────────────────────────────────────────── */}
<ContentCard icon="📸" title="Photos" padded={photos.length === 0}>
{photos.length === 0 ? (
<EmptyState icon="📸">No photos yet.</EmptyState>
) : (
<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 */}
<img
src={p.url}
alt={`Photo by ${user.username}`}
style={{ width: "100%", height: "auto", display: "block", borderRadius: "8px" }}
/>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{formatTimestamp(p.timestamp)}
</p>
</div>
))}
</div>
)}
</ContentCard>
{/* ── Guestbook ────────────────────────────────────────── */}
<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
the session inside the action, never from this form. */}
<input type="hidden" name="profileId" value={String(user.id)} />
<input type="hidden" name="username" value={user.username} />
<label htmlFor="gb-message" className="muted">
Leave a message
</label>
<textarea
id="gb-message"
name="message"
required
maxLength={255}
rows={3}
placeholder={`Write something on ${user.username}'s guestbook…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary">
Post
</button>
</form>
) : (
<p className="muted">Log in to leave a guestbook message.</p>
)}
{guestbook.length === 0 ? (
<EmptyState icon="📝">No guestbook entries yet.</EmptyState>
) : (
<div className="grid" style={{ gap: "0.75rem" }}>
{guestbook.map((g) => {
const author = authorById.get(g.userId);
const authorAvatar = author
? avatarImageUrl(imagerBase ?? "", author.look, { size: "s", headOnly: true })
: null;
return (
<div
key={String(g.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 #${g.userId}`}</strong>{" "}
<span className="muted">{formatDate(g.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{g.message}</p>
</div>
</div>
);
})}
</div>
)}
</ContentCard>
</main>
);
}