feat(profile): add privacy controls and progressive photo gallery
CI / check (push) Failing after 53s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:31:29 +02:00
1 parent ba9c61d808
commit 74223984dc
13 files changed
+764 -167

No files matched your search

+181 -166
View File
@@ -25,6 +25,8 @@ import {
} from "@/lib/db";
import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { loadProfilePrivacy } from "@/lib/services/profile-privacy";
import { ProfilePhotoGallery } from "./ProfilePhotoGallery";
import styles from "./profile.module.css";
type SearchParams = Promise<{
@@ -111,6 +113,9 @@ export default async function ProfilePage({
const meId = Number(session?.user?.id);
const isLoggedIn = Number.isInteger(meId) && meId > 0;
const isSelf = isLoggedIn && meId === user.id;
const privacy = await loadProfilePrivacy(user.id);
const visible = (key: keyof typeof privacy.values) =>
isSelf || privacy.values[key];
// Friend / request state relative to the signed-in viewer (if any).
let isFriend = false;
@@ -193,30 +198,37 @@ export default async function ProfilePage({
// 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] =
const [currencyRows, friendships, rooms, badges, photoRows, 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(() => []),
visible("wallet")
? db
.select({
type: UsersCurrency.type,
amount: UsersCurrency.amount,
})
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, user.id),
inArray(UsersCurrency.type, [
CURRENCY_DUCKETS,
CURRENCY_DIAMONDS,
]),
),
)
.catch(() => [])
: Promise.resolve([]),
// Friends. messenger_friendships stores one directed row per pair
// keyed on user_one_id; resolve the user_two_id side below.
db
.select({ userTwoId: MessengerFriendships.userTwoId })
.from(MessengerFriendships)
.where(eq(MessengerFriendships.userOneId, user.id))
.orderBy(desc(MessengerFriendships.friendsSince))
.limit(18)
.catch(() => []),
visible("friends")
? db
.select({ userTwoId: MessengerFriendships.userTwoId })
.from(MessengerFriendships)
.where(eq(MessengerFriendships.userOneId, user.id))
.orderBy(desc(MessengerFriendships.friendsSince))
.limit(18)
.catch(() => [])
: Promise.resolve([]),
// Rooms owned by this user.
db
.select({
@@ -237,13 +249,15 @@ export default async function ProfilePage({
.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(() => []),
visible("photos")
? db
.select()
.from(CameraWeb)
.where(eq(CameraWeb.userId, user.id))
.orderBy(desc(CameraWeb.timestamp))
.limit(24)
.catch(() => [])
: Promise.resolve([]),
db
.select()
.from(WebsiteUserGuestbooks)
@@ -252,6 +266,7 @@ export default async function ProfilePage({
.limit(30)
.catch(() => []),
]);
const photos = photoRows ?? [];
const ducketsAmount =
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
const diamondsAmount =
@@ -364,10 +379,14 @@ export default async function ProfilePage({
{user.motto || <span className="muted">{t("noMotto")}</span>}
</p>
<div className={styles.meta}>
<OnlineBadge online={user.online === "1"} />
<span>
{t("statRegistered")}: {registered}
</span>
{visible("online") && (
<OnlineBadge online={user.online === "1"} />
)}
{visible("registered") && (
<span>
{t("statRegistered")}: {registered}
</span>
)}
</div>
</div>
{isLoggedIn && !isSelf ? (
@@ -400,110 +419,126 @@ export default async function ProfilePage({
<div className={styles.columns}>
<aside className={styles.sidebar}>
{isSelf && (
<p className="muted">
{t("privacy.ownerHint")}{" "}
<Link href="/settings#profile-privacy">{t("privacy.title")}</Link>
</p>
)}
{/* 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>
{visible("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>
))}
</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>
</ContentCard>
)}
{/* ── Friends ──────────────────────── */}
{visible("friends") && (
<details className={styles.more}>
<summary>{t("friendsTitle")}</summary>
<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>
</details>
)}
{/* 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>
<details className={styles.more}>
<summary>{t("badgesTitle")}</summary>
<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>
</details>
</aside>
<div className={styles.content}>
{/* Rooms */}
@@ -561,42 +596,22 @@ export default async function ProfilePage({
)}
</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>
{visible("photos") && (
<ContentCard icon="📸" title={t("photosTitle")}>
{photoRows === null ? (
<p role="status">{t("photosUnavailable")}</p>
) : photos.length === 0 ? (
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
) : (
<ProfilePhotoGallery
photos={photos}
username={user.username}
moreLabel={t("morePhotos")}
openLabel={t("openPhoto")}
/>
)}
</ContentCard>
)}
{/* ── Guestbook ───────────────────── */}
<ContentCard
icon="📝"