- Custom not-found (404) + error / global-error boundaries, styled with
the public design system; raw errors logged, never shown to users.
- In-process rate limiter (src/lib/rate-limit.ts) wired into the abuse-
prone flows: login (10/5min/IP), register (5/10min/IP), password-reset
request (3/15min/IP), keyed by the proxy-forwarded client IP.
- SEO/metadata: root generateMetadata sets a `%s · {hotel}` title
template from the live hotel_name; dynamic generateMetadata on
news/[slug] (article title + excerpt) and u/[username] (name + motto);
static titles on 12 primary public pages.
- env.ts: added the vars introduced since (PASSWORD_HASH, OPENAI_API_KEY,
DISCORD_WEBHOOK_URL, ALERT_EMAIL, PAYPAL_*) so env stays authoritative.
Verified on the prod server: /missing → 404 card, news title renders
"News · Habbo". tsc 0, vitest 49/49, next build 0.
251 lines
9.2 KiB
TypeScript
251 lines
9.2 KiB
TypeScript
import type { Metadata } from "next";
|
|
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";
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ username: string }>;
|
|
}): Promise<Metadata> {
|
|
const { username } = await params;
|
|
const user = await prisma.user
|
|
.findUnique({ where: { username }, select: { username: true, motto: true } })
|
|
.catch(() => null);
|
|
if (!user) return { title: "Profile" };
|
|
return { title: user.username, description: user.motto || `${user.username}'s profile` };
|
|
}
|
|
|
|
// 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>
|
|
);
|
|
}
|