feat(profile): add privacy controls and progressive photo gallery

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

@@ -0,0 +1,60 @@
import { getTranslations } from "next-intl/server";
import { saveProfilePrivacy } from "@/actions/profile-privacy";
import { SurfaceCard } from "@/components/surface-card";
import {
loadProfilePrivacy,
profilePrivacyKeys,
} from "@/lib/services/profile-privacy";
export default async function ProfilePrivacySettings({
userId,
status,
}: {
userId: number;
status?: string;
}) {
const [privacy, t] = await Promise.all([
loadProfilePrivacy(userId),
getTranslations("pages.profile.privacy"),
]);
return (
<section id="profile-privacy">
<SurfaceCard title={t("title")} bodyClassName="p-5">
<p className="muted">{t("hint")}</p>
{status === "saved" && <p role="status">{t("saved")}</p>}
{(privacy.unavailable || status === "error") && (
<p role="alert">{t("error")}</p>
)}
<form action={saveProfilePrivacy}>
<fieldset
disabled={privacy.unavailable}
style={{
display: "grid",
gap: 12,
margin: "16px 0",
border: 0,
padding: 0,
}}
>
<legend className="sr-only">{t("title")}</legend>
{profilePrivacyKeys.map((key) => (
<label
key={key}
style={{ display: "flex", gap: 10, alignItems: "center" }}
>
<input
type="checkbox"
name={key}
defaultChecked={privacy.values[key]}
/>
{t(key)}
</label>
))}
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
</fieldset>
</form>
</SurfaceCard>
</section>
);
}
+8 -1
View File
@@ -10,7 +10,13 @@ import { auth } from "@/lib/auth";
import { db, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format";
export default async function SettingsPage() {
import ProfilePrivacySettings from "./ProfilePrivacySettings";
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ privacy?: string }>;
}) {
const { privacy } = await searchParams;
const t = await getTranslations("pages.settings");
const session = await auth();
if (!session?.user?.id) redirect("/login");
@@ -45,6 +51,7 @@ export default async function SettingsPage() {
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-14">
<ProfilePrivacySettings userId={id} status={privacy} />
{/* Header */}
<Reveal>
<SurfaceCard
@@ -0,0 +1,71 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ProfilePhotoGallery } from "./ProfilePhotoGallery";
import styles from "./profile.module.css";
const photos = Array.from({ length: 10 }, (_, id) => ({
id,
url: `/photo-${id}.png`,
}));
describe("profile photo gallery", () => {
it("shows six photos initially and discloses the remaining photos accessibly", () => {
const html = renderToStaticMarkup(
<ProfilePhotoGallery
photos={photos}
username="Alice"
moreLabel="Mostra altre foto"
openLabel="Apri foto completa"
/>,
);
expect(html.split("<details")[0].match(/<a /g)).toHaveLength(6);
expect(html.split("<details")[1].match(/<a /g)).toHaveLength(4);
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain("<summary>Mostra altre foto</summary>");
if (process.env.ACCOUNT_VISUAL_FIXTURE) {
let css = readFileSync(
"src/app/(site)/u/[username]/profile.module.css",
"utf8",
);
for (const name of ["photos", "photo", "more"]) {
const scoped = styles[name];
if (scoped)
css = css.replaceAll(
new RegExp(`\\.${name}(?=[\\s.{:#>])`, "g"),
`.${scoped}`,
);
}
mkdirSync("storage/account-fixtures", { recursive: true });
writeFileSync(
"storage/account-fixtures/photos.html",
`<!doctype html><html lang="it"><base href="http://fixtures.local/"><meta name="viewport" content="width=device-width, initial-scale=1"><style>*{box-sizing:border-box}body{font:16px Arial;margin:0;padding:20px;background:#f7f5f2}main{max-width:1000px;margin:auto}a{color:#943712}img{background:#dec5ab}${css}</style><main><h1>Foto di Alice</h1>${html}</main></html>`,
);
}
});
it("excludes executable URL schemes from full-photo links", () => {
const html = renderToStaticMarkup(
<ProfilePhotoGallery
photos={[
{ id: 1, url: "javascript:alert(1)" },
{ id: 2, url: "data:text/html,test" },
]}
username="Alice"
moreLabel="More"
openLabel="Open"
/>,
);
expect(html).not.toContain("<a ");
});
it("does not show an empty expansion when there are only six photos", () => {
expect(
renderToStaticMarkup(
<ProfilePhotoGallery
photos={photos.slice(0, 6)}
username="Alice"
moreLabel="More"
openLabel="Open"
/>,
),
).not.toContain("<details");
});
});
@@ -0,0 +1,55 @@
import { ProfileImage } from "@/components/shared/profile-image";
import styles from "./profile.module.css";
export function ProfilePhotoGallery({
photos,
username,
moreLabel,
openLabel,
}: {
photos: { id: number; url: string }[];
username: string;
moreLabel: string;
openLabel: string;
}) {
const safePhotos = photos.filter((photo) => {
try {
const url = new URL(photo.url, "https://profile.invalid");
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
});
const gallery = (rows: typeof photos) => (
<div className={styles.photos}>
{rows.map((photo) => (
<a
key={photo.id}
href={photo.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${openLabel} · ${username}`}
>
<ProfileImage
src={photo.url}
alt={username}
width={320}
height={320}
className={styles.photo}
/>
<span>{openLabel} ↗</span>
</a>
))}
</div>
);
return (
<>
{gallery(safePhotos.slice(0, 6))}
{safePhotos.length > 6 && (
<details className={styles.more}>
<summary>{moreLabel}</summary>
{gallery(safePhotos.slice(6))}
</details>
)}
</>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -174,3 +174,33 @@
height: 120px;
}
}
.photos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 12px;
}
.photos a {
min-width: 0;
overflow-wrap: anywhere;
}
.photo {
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: 10px;
}
.more {
min-width: 0;
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 12px;
}
.more summary {
cursor: pointer;
font-weight: 700;
padding: 8px;
}
.more[open] > summary {
margin-bottom: 12px;
}