import { and, asc, desc, eq, inArray, or } from "drizzle-orm"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; import { postGuestbook } from "@/actions/guestbook"; import { sendFriendRequest } from "@/actions/social"; import Link from "@/components/link"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { CurrencyIcon } from "@/components/shared/currency-icon"; import { ProfileImage } from "@/components/shared/profile-image"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { auth } from "@/lib/auth"; import { CameraWeb, db, MessengerFriendrequests, MessengerFriendships, Rooms, User, UsersBadges, UsersCurrency, WebsiteBadges, WebsiteUserGuestbooks, } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; import { withPublicPagePerformance } from "@/lib/public-page-performance"; import { loadProfilePrivacy } from "@/lib/services/profile-privacy"; import { ProfilePhotoGallery } from "./ProfilePhotoGallery"; import styles from "./profile.module.css"; type SearchParams = Promise<{ friend?: string; guestbook?: string; error?: string; }>; function feedbackStyle(tone: "success" | "error"): CSSProperties { const accent = tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; return { margin: 0, padding: "0.85rem 1rem", borderRadius: "var(--radius-md)", border: `1px solid ${accent}`, color: "var(--color-text-readable, var(--color-text))", fontSize: "0.9rem", fontWeight: 600, background: "var(--color-surface)", borderLeft: `4px solid ${accent}`, }; } export async function generateMetadata({ params, }: { params: Promise<{ username: string }>; }): Promise { const { username } = await params; const [user] = await db .select({ username: User.username, motto: User.motto }) .from(User) .where(eq(User.username, username)) .limit(1) .catch(() => []); 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; async function ProfilePage({ params, searchParams, }: { params: Promise<{ username: string }>; searchParams: SearchParams; }) { const t = await getTranslations("pages.profile"); const { username } = await params; const { friend, guestbook: guestbookStatus, error } = await searchParams; const [userRows, session] = await Promise.all([ db .select({ id: User.id, username: User.username, motto: User.motto, look: User.look, credits: User.credits, online: User.online, accountCreated: User.accountCreated, }) .from(User) .where(eq(User.username, username)) .limit(1), auth(), ]); const user = userRows[0] ?? null; if (!user) notFound(); const avatar = avatarImageUrl(user.look); const registered = formatDate(new Date(user.accountCreated * 1000), "date"); const meId = Number(session?.user?.id); const isLoggedIn = Number.isInteger(meId) && meId > 0; const isSelf = isLoggedIn && meId === user.id; const privacy = await loadProfilePrivacy(user.id); const visible = (key: keyof typeof privacy.values) => isSelf || privacy.values[key]; // Friend / request state relative to the signed-in viewer (if any). let isFriend = false; let outgoingPending = false; let incomingPending = false; if (isLoggedIn && !isSelf) { const [friendshipRows, outgoingRows, incomingRows] = await Promise.all([ db .select({ id: MessengerFriendships.id }) .from(MessengerFriendships) .where( or( and( eq(MessengerFriendships.userOneId, meId), eq(MessengerFriendships.userTwoId, user.id), ), and( eq(MessengerFriendships.userOneId, user.id), eq(MessengerFriendships.userTwoId, meId), ), ), ) .limit(1) .catch(() => []), db .select({ id: MessengerFriendrequests.id }) .from(MessengerFriendrequests) .where( and( eq(MessengerFriendrequests.userFromId, meId), eq(MessengerFriendrequests.userToId, user.id), ), ) .limit(1) .catch(() => []), db .select({ id: MessengerFriendrequests.id }) .from(MessengerFriendrequests) .where( and( eq(MessengerFriendrequests.userFromId, user.id), eq(MessengerFriendrequests.userToId, meId), ), ) .limit(1) .catch(() => []), ]); isFriend = Boolean(friendshipRows[0]); outgoingPending = Boolean(outgoingRows[0]); incomingPending = Boolean(incomingRows[0]); } const feedbackErrorMessage = error === "self" ? t("errors.self") : error === "already_friends" ? t("errors.alreadyFriends") : error === "already_pending" ? t("errors.alreadyPending") : error === "incoming_pending" ? t("errors.incomingPending") : error === "empty" ? t("errors.empty") : error === "moderated" ? t("errors.moderated") : error === "ratelimit" ? t("errors.ratelimit") : error === "invalid" ? t("errors.invalid") : error ? t("errors.error") : null; // 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. // Currency ledger rows (duckets/diamonds). Credits live on the users row // itself; the alt-currencies sit in the users_currency ledger keyed by type. // All sub-sections run in parallel (each query keeps its own .catch so a DB // hiccup still degrades only that section instead of 500-ing the profile). const [currencyRows, friendships, rooms, badges, photoRows, guestbook] = await Promise.all([ visible("wallet") ? db .select({ type: UsersCurrency.type, amount: UsersCurrency.amount, }) .from(UsersCurrency) .where( and( eq(UsersCurrency.userId, user.id), inArray(UsersCurrency.type, [ CURRENCY_DUCKETS, CURRENCY_DIAMONDS, ]), ), ) .catch(() => []) : Promise.resolve([]), // Friends. messenger_friendships stores one directed row per pair // keyed on user_one_id; resolve the user_two_id side below. visible("friends") ? db .select({ userTwoId: MessengerFriendships.userTwoId }) .from(MessengerFriendships) .where(eq(MessengerFriendships.userOneId, user.id)) .orderBy(desc(MessengerFriendships.friendsSince)) .limit(18) .catch(() => []) : Promise.resolve([]), // Rooms owned by this user. db .select({ id: Rooms.id, name: Rooms.name, users: Rooms.users, usersMax: Rooms.usersMax, state: Rooms.state, }) .from(Rooms) .where(eq(Rooms.ownerId, user.id)) .orderBy(desc(Rooms.users)) .limit(8) .catch(() => []), db .select() .from(UsersBadges) .where(eq(UsersBadges.userId, user.id)) .orderBy(asc(UsersBadges.slotId)) .catch(() => []), visible("photos") ? db .select() .from(CameraWeb) .where(eq(CameraWeb.userId, user.id)) .orderBy(desc(CameraWeb.timestamp)) .limit(24) .catch(() => []) : Promise.resolve([]), db .select() .from(WebsiteUserGuestbooks) .where(eq(WebsiteUserGuestbooks.profileId, user.id)) .orderBy(desc(WebsiteUserGuestbooks.createdAt)) .limit(30) .catch(() => []), ]); const photos = photoRows ?? []; 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", label: t("statCredits"), value: user.credits, }, { key: "duckets", label: t("statDuckets"), value: ducketsAmount, }, { key: "diamonds", label: t("statDiamonds"), value: diamondsAmount, }, ] as const; // Resolve friend usernames/looks and the guestbook author usernames/looks in // parallel queries (the ids come from the queries above). const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId))); const authorIds = Array.from(new Set(guestbook.map((g) => g.userId))); const [friendUsers, authors, badgeDetails] = await Promise.all([ friendIds.length ? db .select({ id: User.id, username: User.username, look: User.look, }) .from(User) .where(inArray(User.id, friendIds)) .catch(() => []) : [], authorIds.length ? db .select({ id: User.id, username: User.username, look: User.look, }) .from(User) .where(inArray(User.id, authorIds)) .catch(() => []) : [], badges.length ? db .select() .from(WebsiteBadges) .where( inArray( WebsiteBadges.badgeKey, badges.map((b) => b.badgeCode), ), ) .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 => Boolean(f)); const authorById = new Map(authors.map((a) => [a.id, a])); const badgeByCode = new Map(badgeDetails.map((b) => [b.badgeKey, b])); return (
{friend === "sent" ? (
{t("success.sent")}
) : null} {guestbookStatus === "posted" ? (
{t("success.guestbook")}
) : null} {feedbackErrorMessage ? (
{feedbackErrorMessage}
) : null} {/* ── Profile header ──────────────────── */}
{/* eslint-disable-next-line @next/next/no-img-element */}

{user.username}

{user.motto || {t("noMotto")}}

{visible("online") && ( )} {visible("registered") && ( {t("statRegistered")}: {registered} )}
{isLoggedIn && !isSelf ? (
{isFriend ? ( {t("alreadyFriends")} ) : outgoingPending ? ( {t("requestPending")} ) : incomingPending ? ( {t("respondInMessages")} ) : (
)}
) : null}
{/* Rooms */} {rooms.length === 0 ? ( {t("roomsEmpty")} ) : (
{rooms.map((r) => { const photo = photos.find((photo) => photo.roomId === r.id); return (
{r.name || t("roomUntitled")} {!photo && ( {t("noRoomPreview")} )}
{r.users}/{r.usersMax} {t.has(`roomStates.${r.state}`) ? t(`roomStates.${r.state}`) : r.state}
{t("viewRoom")} →
); })}
)}
{visible("photos") && ( {photoRows === null ? (

{t("photosUnavailable")}

) : photos.length === 0 ? ( {t("photosEmpty")} ) : ( )}
)} {/* ── Guestbook ───────────────────── */} {isLoggedIn ? (
{/* Profile owner id drives the write target; the author is taken from the session inside the action, never from this form. */}