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 { postGuestbook } from "@/actions/guestbook";
|
||||||
import { sendFriendRequest } from "@/actions/social";
|
import { sendFriendRequest } from "@/actions/social";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import {
|
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||||
ContentCard,
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
EmptyState,
|
|
||||||
OnlineBadge,
|
|
||||||
StatBlock,
|
|
||||||
} from "@/components/public/ui";
|
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
@@ -23,10 +19,13 @@ import {
|
|||||||
User,
|
User,
|
||||||
UsersBadges,
|
UsersBadges,
|
||||||
UsersCurrency,
|
UsersCurrency,
|
||||||
|
WebsiteBadges,
|
||||||
WebsiteUserGuestbooks,
|
WebsiteUserGuestbooks,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import styles from "./profile.module.css";
|
||||||
|
import { ProfileImage } from "./profile-image";
|
||||||
|
|
||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
friend?: string;
|
friend?: string;
|
||||||
@@ -260,32 +259,26 @@ export default async function ProfilePage({
|
|||||||
const wallet = [
|
const wallet = [
|
||||||
{
|
{
|
||||||
key: "credits",
|
key: "credits",
|
||||||
icon: "💰",
|
|
||||||
label: t("statCredits"),
|
label: t("statCredits"),
|
||||||
value: user.credits,
|
value: user.credits,
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "duckets",
|
key: "duckets",
|
||||||
icon: "🪙",
|
|
||||||
label: t("statDuckets"),
|
label: t("statDuckets"),
|
||||||
value: ducketsAmount,
|
value: ducketsAmount,
|
||||||
color: "var(--color-accent-readable, var(--color-accent))",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "diamonds",
|
key: "diamonds",
|
||||||
icon: "💎",
|
|
||||||
label: t("statDiamonds"),
|
label: t("statDiamonds"),
|
||||||
value: diamondsAmount,
|
value: diamondsAmount,
|
||||||
color: "var(--color-text-readable, var(--color-text))",
|
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
// Resolve friend usernames/looks and the guestbook author usernames/looks in
|
// 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 friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
|
||||||
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
|
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
|
friendIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -308,15 +301,28 @@ export default async function ProfilePage({
|
|||||||
.where(inArray(User.id, authorIds))
|
.where(inArray(User.id, authorIds))
|
||||||
.catch(() => [])
|
.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 friendById = new Map(friendUsers.map((f) => [f.id, f]));
|
||||||
const friends = friendships
|
const friends = friendships
|
||||||
.map((f) => friendById.get(f.userTwoId))
|
.map((f) => friendById.get(f.userTwoId))
|
||||||
.filter((f): f is NonNullable<typeof f> => Boolean(f));
|
.filter((f): f is NonNullable<typeof f> => Boolean(f));
|
||||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||||
|
const badgeByCode = new Map(badgeDetails.map((b) => [b.badgeKey, b]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-grid">
|
<main className={`page-grid ${styles.profile}`}>
|
||||||
{friend === "sent" ? (
|
{friend === "sent" ? (
|
||||||
<div role="status" style={feedbackStyle("success")}>
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
{t("success.sent")}
|
{t("success.sent")}
|
||||||
@@ -334,7 +340,7 @@ export default async function ProfilePage({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ── Profile header ──────────────────── */}
|
{/* ── Profile header ──────────────────── */}
|
||||||
<ContentCard padded={false}>
|
<ContentCard padded={false} className={styles.hero}>
|
||||||
<div
|
<div
|
||||||
className="content-card-body"
|
className="content-card-body"
|
||||||
style={{
|
style={{
|
||||||
@@ -345,18 +351,24 @@ export default async function ProfilePage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<ProfileImage
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={`${user.username} avatar`}
|
alt={user.username}
|
||||||
width={100}
|
width={100}
|
||||||
height={150}
|
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>
|
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
|
||||||
<p style={{ margin: "0 0 0.6rem" }}>
|
<p style={{ margin: "0 0 0.6rem" }}>
|
||||||
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
||||||
</p>
|
</p>
|
||||||
<OnlineBadge online={user.online === "1"} />
|
<div className={styles.meta}>
|
||||||
|
<OnlineBadge online={user.online === "1"} />
|
||||||
|
<span>
|
||||||
|
{t("statRegistered")}: {registered}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLoggedIn && !isSelf ? (
|
{isLoggedIn && !isSelf ? (
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
@@ -384,329 +396,291 @@ export default async function ProfilePage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Wallet / currencies ─────────────── */}
|
<div className={styles.columns}>
|
||||||
<ContentCard icon="💰" title={t("walletTitle")}>
|
<aside className={styles.sidebar}>
|
||||||
<div
|
{/* Wallet */}
|
||||||
style={{
|
<ContentCard title={t("walletTitle")}>
|
||||||
display: "grid",
|
<div className={styles.wallet}>
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
|
{wallet.map((w) => (
|
||||||
gap: "0.75rem",
|
<div key={w.key} className={styles.currency}>
|
||||||
}}
|
<CurrencyIcon kind={w.key} size={28} alt="" />
|
||||||
>
|
<div>
|
||||||
{wallet.map((w) => (
|
<strong>{w.value.toLocaleString()}</strong>
|
||||||
<div
|
<span className="muted">{w.label}</span>
|
||||||
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>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</ContentCard>
|
||||||
)}
|
|
||||||
</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>
|
</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.",
|
"ratelimit": "You're posting too fast. Please wait a moment and try again.",
|
||||||
"invalid": "That request was invalid.",
|
"invalid": "That request was invalid.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
|
},
|
||||||
|
"noRoomPreview": "Preview unavailable",
|
||||||
|
"viewRoom": "View room",
|
||||||
|
"roomStates": {
|
||||||
|
"open": "Open",
|
||||||
|
"locked": "Doorbell",
|
||||||
|
"password": "Password",
|
||||||
|
"invisible": "Hidden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rankings": {
|
"rankings": {
|
||||||
|
|||||||
@@ -205,6 +205,14 @@
|
|||||||
"ratelimit": "Stai pubblicando troppo in fretta. Attendi un momento e riprova.",
|
"ratelimit": "Stai pubblicando troppo in fretta. Attendi un momento e riprova.",
|
||||||
"invalid": "Richiesta non valida.",
|
"invalid": "Richiesta non valida.",
|
||||||
"error": "Qualcosa è andato storto. Riprova."
|
"error": "Qualcosa è andato storto. Riprova."
|
||||||
|
},
|
||||||
|
"noRoomPreview": "Anteprima non disponibile",
|
||||||
|
"viewRoom": "Vedi stanza",
|
||||||
|
"roomStates": {
|
||||||
|
"open": "Aperta",
|
||||||
|
"locked": "Campanello",
|
||||||
|
"password": "Password",
|
||||||
|
"invisible": "Nascosta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rankings": {
|
"rankings": {
|
||||||
|
|||||||
Reference in new issue
Block a user