Add niche admin + public expansions + self-host Nunito font (batches 3-4)
Built via two more parallel agent workflows (read schema -> return files), integrated + verified. Admin: /admin/emulator (emulator_settings + emulator_texts key/value editor), /admin/badges (give-badge via RCON), /admin/rare-values (CRUD), /admin/housekeeping (CRUD), /admin/email-templates (CRUD), /admin/photos (moderation). Public: /rares (+[category]), /leaderboard (credits/diamonds/duckets), /guilds (+[id] members), /redeem (voucher -> sendCurrency). Expanded: /u/[username] now shows badges + photos + guestbook (post form); /news/[slug] now shows reactions + comments (comment form). Reactions tallied in JS (Prisma groupBy typing avoided). Self-hosted Nunito via next/font/google wired to --font-nunito (the atom theme font, no runtime external fetch). Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build exit 0.
This commit is contained in:
1 parent
e96b606e1e
commit
e8be0461d8
28 files changed
+2347
-5
No files matched your search
@@ -1,10 +1,24 @@
|
||||
import { notFound } from "next/navigation";
|
||||
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,
|
||||
}: {
|
||||
@@ -12,10 +26,11 @@ export default async function ProfilePage({
|
||||
}) {
|
||||
const { username } = await params;
|
||||
|
||||
const [user, imagerBase] = await Promise.all([
|
||||
const [user, imagerBase, session] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
@@ -26,12 +41,51 @@ export default async function ProfilePage({
|
||||
},
|
||||
}),
|
||||
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>
|
||||
@@ -40,13 +94,133 @@ export default async function ProfilePage({
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Badges ───────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Badges</h2>
|
||||
{badges.length === 0 ? (
|
||||
<p className="muted">No badges yet.</p>
|
||||
) : (
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", flexWrap: "wrap", gap: "0.75rem", alignItems: "center" }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Photos ───────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Photos</h2>
|
||||
{photos.length === 0 ? (
|
||||
<p className="muted">No photos yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Guestbook ────────────────────────────────────────── */}
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Guestbook</h2>
|
||||
|
||||
{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 ? (
|
||||
<p className="muted">No guestbook entries yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user