feat(profile): organize profile sections and use native currency icons

This commit is contained in:
Simo committed 2026-09-06 10:56:20 +02:00
1 parent 6146995758
commit 31691c1c7d
5 files changed
+557 -340

No files matched your search

+314 -340
View File
@@ -6,12 +6,8 @@ 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 { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { auth } from "@/lib/auth";
import {
@@ -23,10 +19,13 @@ import {
User,
UsersBadges,
UsersCurrency,
WebsiteBadges,
WebsiteUserGuestbooks,
} from "@/lib/db";
import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import styles from "./profile.module.css";
import { ProfileImage } from "./profile-image";
type SearchParams = Promise<{
friend?: string;
@@ -260,32 +259,26 @@ export default async function ProfilePage({
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))",
},
];
] as const;
// Resolve friend usernames/looks and the guestbook author usernames/looks in
// two parallel queries (the ids come from the queries above).
// 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([
const [friendUsers, authors, badgeDetails] = await Promise.all([
friendIds.length
? db
.select({
@@ -308,15 +301,28 @@ export default async function ProfilePage({
.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<typeof f> => Boolean(f));
const authorById = new Map(authors.map((a) => [a.id, a]));
const badgeByCode = new Map(badgeDetails.map((b) => [b.badgeKey, b]));
return (
<main className="page-grid">
<main className={`page-grid ${styles.profile}`}>
{friend === "sent" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.sent")}
@@ -334,7 +340,7 @@ export default async function ProfilePage({
) : null}
{/* ── Profile header ──────────────────── */}
<ContentCard padded={false}>
<ContentCard padded={false} className={styles.hero}>
<div
className="content-card-body"
style={{
@@ -345,18 +351,24 @@ export default async function ProfilePage({
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<ProfileImage
src={avatar}
alt={`${user.username} avatar`}
alt={user.username}
width={100}
height={150}
className={styles.avatar}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div className={styles.identity}>
<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 className={styles.meta}>
<OnlineBadge online={user.online === "1"} />
<span>
{t("statRegistered")}: {registered}
</span>
</div>
</div>
{isLoggedIn && !isSelf ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
@@ -384,329 +396,291 @@ export default async function ProfilePage({
</div>
) : null}
</div>
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
<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 className={styles.columns}>
<aside className={styles.sidebar}>
{/* Wallet */}
<ContentCard title={t("walletTitle")}>
<div className={styles.wallet}>
{wallet.map((w) => (
<div key={w.key} className={styles.currency}>
<CurrencyIcon kind={w.key} size={28} alt="" />
<div>
<strong>{w.value.toLocaleString()}</strong>
<span className="muted">{w.label}</span>
</div>
</div>
);
})}
</div>
)}
</ContentCard>
))}
</div>
</ContentCard>
{/* ── Friends ──────────────────────── */}
<ContentCard
icon="👥"
title={t("friendsTitle")}
subtitle={t("friendsSubtitle", { count: friends.length })}
padded={true}
>
{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>
{/* Badges */}
<ContentCard icon="🏅" title={t("badgesTitle")}>
{badges.length === 0 ? (
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
) : (
<div className={styles.badges}>
{badges.map((b) => {
const details = badgeByCode.get(b.badgeCode);
const name = details?.badgeName || b.badgeCode;
return (
<details key={b.id} className={styles.badge}>
<summary title={name}>
<img
src={
BADGE_IMG_BASE +
"/" +
encodeURIComponent(b.badgeCode) +
".gif"
}
alt={name}
width={40}
height={40}
loading="lazy"
/>
</summary>
<div className={styles.badgeInfo}>
<strong>{name}</strong>
{details?.badgeDescription && (
<p>{details.badgeDescription}</p>
)}
<small>{b.badgeCode}</small>
</div>
</details>
);
})}
</div>
)}
</ContentCard>
</aside>
<div className={styles.content}>
{/* Rooms */}
<ContentCard
icon="🏠"
title={t("roomsTitle")}
subtitle={t("roomsSubtitle", { username: user.username })}
>
{rooms.length === 0 ? (
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
) : (
<div className={styles.rooms}>
{rooms.map((r) => {
const photo = photos.find((photo) => photo.roomId === r.id);
return (
<Link
href={`/room/${r.id}`}
key={r.id}
className={styles.room}
>
<ProfileImage
src={
photo?.url ||
"/assets/images/profile/room_placeholder.png"
}
fallback="/assets/images/profile/room_placeholder.png"
alt=""
width={320}
height={140}
className={styles.roomImage}
/>
<div className={styles.roomBody}>
<strong>{r.name || t("roomUntitled")}</strong>
{!photo && (
<span className="muted">{t("noRoomPreview")}</span>
)}
<div className={styles.roomMeta}>
<span>
{r.users}/{r.usersMax}
</span>
<span>
{t.has(`roomStates.${r.state}`)
? t(`roomStates.${r.state}`)
: r.state}
</span>
</div>
<span className={styles.roomLink}>
{t("viewRoom")} →
</span>
</div>
</Link>
);
})}
</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>
</div>
</div>
</main>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
export function ProfileImage({
src,
alt,
width,
height,
fallback,
className,
}: {
src: string;
alt: string;
width: number;
height: number;
fallback?: string;
className?: string;
}) {
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const failed = failedSrc === src;
if (failed && !fallback)
return (
<span
role="img"
aria-label={alt}
className={className}
style={{
width,
height,
display: "inline-grid",
placeItems: "center",
fontSize: "2rem",
fontWeight: 700,
}}
>
{alt.slice(0, 1).toUpperCase()}
</span>
);
return (
<img
src={failed ? fallback : src}
alt={alt}
width={width}
height={height}
className={className}
loading="lazy"
decoding="async"
onError={() => setFailedSrc(src)}
/>
);
}
@@ -0,0 +1,176 @@
.profile {
min-width: 0;
}
.hero {
overflow: hidden;
border-top: 4px solid var(--color-primary);
background: linear-gradient(
120deg,
color-mix(in srgb, var(--color-primary) 9%, var(--color-surface)),
var(--color-surface)
);
}
.avatar {
object-fit: contain;
image-rendering: pixelated;
border-radius: 20px;
background: color-mix(
in srgb,
var(--color-primary) 12%,
var(--color-surface)
);
flex-shrink: 0;
}
.identity {
min-width: 0;
flex: 1;
overflow-wrap: anywhere;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.columns {
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.sidebar,
.content {
display: grid;
gap: 24px;
min-width: 0;
}
.wallet {
display: grid;
gap: 12px;
}
.currency {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: 12px;
}
.currency > div {
min-width: 0;
display: grid;
gap: 2px;
}
.currency strong {
font-size: 1.25rem;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
.currency span {
font-size: 0.8rem;
}
.profile [class~="empty-state"] {
padding: 18px 12px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
text-align: left;
}
.profile [class~="empty-state-icon"] {
margin: 0;
font-size: 1.5rem;
}
.rooms {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
gap: 16px;
}
.room {
display: block;
border: 1px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: inherit;
}
.room:hover,
.room:focus-visible {
border-color: var(--color-primary);
}
.roomImage {
width: 100%;
height: 140px;
object-fit: contain;
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
}
.roomBody {
padding: 14px;
display: grid;
gap: 8px;
overflow-wrap: anywhere;
}
.roomBody > [class~="muted"],
.roomMeta {
font-size: 0.8rem;
}
.roomMeta {
display: flex;
justify-content: space-between;
gap: 8px;
}
.roomLink {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-primary-readable, var(--color-primary));
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: start;
}
.badge {
border: 1px solid var(--color-border);
border-radius: 8px;
}
.badge summary {
padding: 6px;
cursor: pointer;
list-style: none;
}
.badge summary::-webkit-details-marker {
display: none;
}
.badge img {
display: block;
object-fit: contain;
image-rendering: pixelated;
}
.badge[open] {
width: 100%;
}
.badgeInfo {
padding: 0 12px 12px;
font-size: 0.85rem;
overflow-wrap: anywhere;
}
.badgeInfo p {
margin: 6px 0;
}
@media (max-width: 760px) {
.columns {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.sidebar,
.content {
gap: 16px;
}
.avatar {
width: 80px;
height: 120px;
}
}
+8
View File
@@ -205,6 +205,14 @@
"ratelimit": "You're posting too fast. Please wait a moment and try again.",
"invalid": "That request was invalid.",
"error": "Something went wrong. Please try again."
},
"noRoomPreview": "Preview unavailable",
"viewRoom": "View room",
"roomStates": {
"open": "Open",
"locked": "Doorbell",
"password": "Password",
"invisible": "Hidden"
}
},
"rankings": {
+8
View File
@@ -205,6 +205,14 @@
"ratelimit": "Stai pubblicando troppo in fretta. Attendi un momento e riprova.",
"invalid": "Richiesta non valida.",
"error": "Qualcosa è andato storto. Riprova."
},
"noRoomPreview": "Anteprima non disponibile",
"viewRoom": "Vedi stanza",
"roomStates": {
"open": "Aperta",
"locked": "Campanello",
"password": "Password",
"invisible": "Nascosta"
}
},
"rankings": {