Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s
Local Build and Deploy / deploy (push) Successful in 54s
Public chrome and width live only under (site). /admin and /client inherit the root layout alone, so housekeeping width is no longer tied to the public template. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
a1dc7ff0a7
commit
367f69a439
60 files changed
+29
-30
No files matched your search
@@ -0,0 +1,580 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { postGuestbook } from "@/actions/guestbook";
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
// Emulator currency type ids (see UsersCurrency model): Duckets=0, Diamonds=5.
|
||||
const CURRENCY_DUCKETS = 0;
|
||||
const CURRENCY_DIAMONDS = 5;
|
||||
|
||||
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 t = await getTranslations("pages.profile");
|
||||
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.
|
||||
|
||||
// Currency ledger rows (duckets/diamonds). Credits live on the users row
|
||||
// itself; the alt-currencies sit in the users_currency ledger keyed by type.
|
||||
const currencyRows = await prisma.usersCurrency
|
||||
.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: [CURRENCY_DUCKETS, CURRENCY_DIAMONDS] },
|
||||
},
|
||||
select: { type: true, amount: true },
|
||||
})
|
||||
.catch(() => []);
|
||||
const ducketsAmount =
|
||||
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
|
||||
const diamondsAmount =
|
||||
currencyRows.find((c) => c.type === CURRENCY_DIAMONDS)?.amount ?? 0;
|
||||
const wallet = [
|
||||
{
|
||||
key: "credits",
|
||||
icon: "💰",
|
||||
label: t("statCredits"),
|
||||
value: user.credits,
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
},
|
||||
{
|
||||
key: "duckets",
|
||||
icon: "🪙",
|
||||
label: t("statDuckets"),
|
||||
value: ducketsAmount,
|
||||
color: "var(--color-accent-readable, var(--color-accent))",
|
||||
},
|
||||
{
|
||||
key: "diamonds",
|
||||
icon: "💎",
|
||||
label: t("statDiamonds"),
|
||||
value: diamondsAmount,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
},
|
||||
];
|
||||
|
||||
// Friends. messenger_friendships stores one directed row per pair keyed on
|
||||
// user_one_id; resolve the user_two_id side to usernames/looks for the grid.
|
||||
const friendships = await prisma.messengerFriendships
|
||||
.findMany({
|
||||
where: { userOneId: user.id },
|
||||
orderBy: { friendsSince: "desc" },
|
||||
take: 18,
|
||||
select: { userTwoId: true },
|
||||
})
|
||||
.catch(() => []);
|
||||
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
|
||||
const friendUsers = friendIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: friendIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const friendById = new Map(friendUsers.map((f) => [f.id, f]));
|
||||
const friends = friendships
|
||||
.map((f) => friendById.get(f.userTwoId))
|
||||
.filter((f): f is NonNullable<typeof f> => Boolean(f));
|
||||
|
||||
// Rooms owned by this user.
|
||||
const rooms = await prisma.rooms
|
||||
.findMany({
|
||||
where: { ownerId: user.id },
|
||||
orderBy: { users: "desc" },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
users: true,
|
||||
usersMax: true,
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
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">{t("noMotto")}</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={t("statRank")} />
|
||||
<StatBlock
|
||||
icon="💰"
|
||||
value={user.credits.toLocaleString()}
|
||||
label={t("statCredits")}
|
||||
/>
|
||||
<StatBlock icon="📅" value={registered} label={t("statRegistered")} />
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Wallet / currencies ─────────────── */}
|
||||
<ContentCard icon="💰" title={t("walletTitle")}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{wallet.map((w) => (
|
||||
<div
|
||||
key={w.key}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderLeft: `4px solid ${w.color}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
fontSize: "1.6rem",
|
||||
lineHeight: 1,
|
||||
display: "flex",
|
||||
width: "2.4rem",
|
||||
height: "2.4rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
background: `color-mix(in srgb, ${w.color} 16%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{w.icon}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.35rem",
|
||||
fontWeight: 700,
|
||||
color: w.color,
|
||||
}}
|
||||
>
|
||||
{w.value.toLocaleString()}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{w.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Friends ──────────────────────── */}
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("friendsTitle")}
|
||||
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||
padded={friends.length === 0}
|
||||
>
|
||||
{friends.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{friends.map((f) => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/u/${encodeURIComponent(f.username)}`}
|
||||
className="card"
|
||||
title={f.username}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
padding: "0.6rem 0.4rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={avatarImageUrl(imagerBase ?? "", f.look, {
|
||||
size: "m",
|
||||
headOnly: true,
|
||||
})}
|
||||
alt={f.username}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.username}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Rooms ────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🏠"
|
||||
title={t("roomsTitle")}
|
||||
subtitle={t("roomsSubtitle", { username: user.username })}
|
||||
padded={rooms.length === 0}
|
||||
>
|
||||
{rooms.length === 0 ? (
|
||||
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{rooms.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: "1.2rem" }}>
|
||||
{"🏠"}
|
||||
</span>
|
||||
<strong
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{r.name || t("roomUntitled")}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className="muted"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{"👤"} {r.users}/{r.usersMax}
|
||||
</span>
|
||||
<span style={{ textTransform: "capitalize" }}>{r.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Badges ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🏅"
|
||||
title={t("badgesTitle")}
|
||||
padded={badges.length === 0}
|
||||
>
|
||||
{badges.length === 0 ? (
|
||||
<EmptyState icon="🏅">{t("badgesEmpty")}</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={t("photosTitle")}
|
||||
padded={photos.length === 0}
|
||||
>
|
||||
{photos.length === 0 ? (
|
||||
<EmptyState icon="📸">{t("photosEmpty")}</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={t("guestbookTitle")}
|
||||
subtitle={t("guestbookSubtitle", { username: user.username })}
|
||||
>
|
||||
{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">
|
||||
{t("leaveMessage")}
|
||||
</label>
|
||||
<textarea
|
||||
id="gb-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("guestbookPlaceholder", {
|
||||
username: user.username,
|
||||
})}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("post")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">{t("loginToPost")}</p>
|
||||
)}
|
||||
|
||||
{guestbook.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("guestbookEmpty")}</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 ?? t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">{formatDate(g.createdAt)}</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user