feat(profile): organize profile sections and use native currency icons
This commit is contained in:
1 parent
6146995758
commit
31691c1c7d
5 files changed
+557
-340
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user