feat(profile): add privacy controls and progressive photo gallery
This commit is contained in:
1 parent
ba9c61d808
commit
74223984dc
13 files changed
+764
-167
No files matched your search
@@ -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="📝"
|
||||
|
||||
Reference in new issue
Block a user