Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
715 lines
19 KiB
TypeScript
715 lines
19 KiB
TypeScript
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,
|
|
StatBlock,
|
|
} from "@/components/public/ui";
|
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
import { auth } from "@/lib/auth";
|
|
import {
|
|
CameraWeb,
|
|
db,
|
|
MessengerFriendrequests,
|
|
MessengerFriendships,
|
|
Rooms,
|
|
User,
|
|
UsersBadges,
|
|
UsersCurrency,
|
|
WebsiteUserGuestbooks,
|
|
} from "@/lib/db";
|
|
import { avatarImageUrl } from "@/lib/format";
|
|
import { formatDate } from "@/lib/format-date";
|
|
|
|
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<Metadata> {
|
|
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;
|
|
|
|
export default 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,
|
|
rank: User.rank,
|
|
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;
|
|
|
|
// 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, photos, guestbook] =
|
|
await Promise.all([
|
|
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(() => []),
|
|
// Friends. messenger_friendships stores one directed row per pair
|
|
// keyed on user_one_id; resolve the user_two_id side below.
|
|
db
|
|
.select({ userTwoId: MessengerFriendships.userTwoId })
|
|
.from(MessengerFriendships)
|
|
.where(eq(MessengerFriendships.userOneId, user.id))
|
|
.orderBy(desc(MessengerFriendships.friendsSince))
|
|
.limit(18)
|
|
.catch(() => []),
|
|
// 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(() => []),
|
|
db
|
|
.select()
|
|
.from(CameraWeb)
|
|
.where(eq(CameraWeb.userId, user.id))
|
|
.orderBy(desc(CameraWeb.timestamp))
|
|
.limit(6)
|
|
.catch(() => []),
|
|
db
|
|
.select()
|
|
.from(WebsiteUserGuestbooks)
|
|
.where(eq(WebsiteUserGuestbooks.profileId, user.id))
|
|
.orderBy(desc(WebsiteUserGuestbooks.createdAt))
|
|
.limit(30)
|
|
.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))",
|
|
},
|
|
];
|
|
|
|
// Resolve friend usernames/looks and the guestbook author usernames/looks in
|
|
// two 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] = 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(() => [])
|
|
: [],
|
|
]);
|
|
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));
|
|
const authorById = new Map(authors.map((a) => [a.id, a]));
|
|
|
|
return (
|
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
|
{friend === "sent" ? (
|
|
<div role="status" style={feedbackStyle("success")}>
|
|
{t("success.sent")}
|
|
</div>
|
|
) : null}
|
|
{guestbookStatus === "posted" ? (
|
|
<div role="status" style={feedbackStyle("success")}>
|
|
{t("success.guestbook")}
|
|
</div>
|
|
) : null}
|
|
{feedbackErrorMessage ? (
|
|
<div role="alert" style={feedbackStyle("error")}>
|
|
{feedbackErrorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ── 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>
|
|
{isLoggedIn && !isSelf ? (
|
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
{isFriend ? (
|
|
<span className="muted" style={{ fontWeight: 600 }}>
|
|
{t("alreadyFriends")}
|
|
</span>
|
|
) : outgoingPending ? (
|
|
<span className="muted" style={{ fontWeight: 600 }}>
|
|
{t("requestPending")}
|
|
</span>
|
|
) : incomingPending ? (
|
|
<Link href="/messages" className="btn btn-secondary">
|
|
{t("respondInMessages")}
|
|
</Link>
|
|
) : (
|
|
<form action={sendFriendRequest}>
|
|
<input type="hidden" name="userId" value={String(user.id)} />
|
|
<input type="hidden" name="username" value={user.username} />
|
|
<button type="submit" className="btn btn-primary">
|
|
{t("addFriend")}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</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",
|
|
}}
|
|
>
|
|
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
|
<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={`by ${user.username}`}
|
|
style={{
|
|
width: "100%",
|
|
height: "auto",
|
|
display: "block",
|
|
borderRadius: "8px",
|
|
}}
|
|
/>
|
|
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
|
{formatDate(new Date(p.timestamp * 1000), "date")}
|
|
</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);
|
|
return (
|
|
<div
|
|
key={String(g.id)}
|
|
className="card"
|
|
style={{
|
|
display: "flex",
|
|
gap: "0.75rem",
|
|
alignItems: "flex-start",
|
|
}}
|
|
>
|
|
{author ? (
|
|
<UserAvatarThumbnail
|
|
figure={author.look}
|
|
alt={author?.username ?? "author"}
|
|
/>
|
|
) : 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, "date", "")}
|
|
</span>
|
|
</p>
|
|
<p style={{ margin: 0 }}>{g.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ContentCard>
|
|
</main>
|
|
);
|
|
}
|