Safety screens: followers, blocking, reporting, admin
CI / check (push) Canceled after 0s

Web and mobile:
- Follower/following lists (tap the counts), with Follow buttons and
  "Remove" on your own followers.
- Profile ⋯ menu: block (with confirmation) / unblock, and report with a
  reason and optional note. "Follows you" label, "Follow back", and a
  banner on profiles you've blocked.
- Blocked accounts list in Settings, with unblock.
Web only: /admin for the admin account: stats, open/resolved reports,
resolve, suspend/unsuspend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-06 12:53:28 -04:00
co-authored by Claude Opus 5.5
parent 13df568348
commit 45a7bd190a
21 changed files with 741 additions and 21 deletions
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { REPORT_REASONS, REPORT_REASON_LABELS, type ProfileDTO, type ReportReason } from '@musicshare/shared';
import { client } from '@/lib/api';
/** "⋯" on someone else's profile: block/unblock and report. */
export function ProfileMenu({ profile, onChanged }: { profile: ProfileDTO; onChanged: () => void }) {
const [open, setOpen] = useState(false);
const [reporting, setReporting] = useState(false);
const [confirmBlock, setConfirmBlock] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const name = profile.user.displayName ?? profile.user.username;
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
const toggleBlock = async () => {
setOpen(false);
setConfirmBlock(false);
await (profile.isBlocked ? client.unblock(profile.user.username) : client.block(profile.user.username));
onChanged();
};
return (
<div className="menu" ref={ref}>
<button className="btn" aria-haspopup="menu" aria-expanded={open} aria-label="More options" onClick={() => setOpen((v) => !v)}>
⋯
</button>
{open && (
<div className="menu-list" role="menu">
<button role="menuitem" onClick={() => (profile.isBlocked ? toggleBlock() : (setOpen(false), setConfirmBlock(true)))}>
{profile.isBlocked ? `Unblock ${name}` : `Block ${name}`}
</button>
<button role="menuitem" className="danger" onClick={() => (setOpen(false), setReporting(true))}>
Report {name}
</button>
</div>
)}
{confirmBlock && (
<Modal onClose={() => setConfirmBlock(false)} title={`Block ${name}?`}>
<p>
They won&apos;t be able to find you, see your profile or what you&apos;re playing, and you&apos;ll stop following each
other. They aren&apos;t told.
</p>
<div className="row">
<button className="btn btn-danger" onClick={toggleBlock}>
Block
</button>
<button className="btn" onClick={() => setConfirmBlock(false)}>
Cancel
</button>
</div>
</Modal>
)}
{reporting && <ReportForm username={profile.user.username} name={name} onClose={() => setReporting(false)} />}
</div>
);
}
function ReportForm({ username, name, onClose }: { username: string; name: string; onClose: () => void }) {
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!reason) return;
try {
await client.report(username, { reason, note: note.trim() || null });
setSent(true);
} catch (err) {
setError((err as Error).message);
}
};
if (sent) {
return (
<Modal onClose={onClose} title="Thanks for letting us know">
<p>We&apos;ll take a look. If they&apos;re bothering you, you can also block them from this menu.</p>
<button className="btn btn-primary" onClick={onClose}>
Done
</button>
</Modal>
);
}
return (
<Modal onClose={onClose} title={`Report ${name}`}>
<form className="form" onSubmit={submit}>
<fieldset className="choices">
<legend className="muted">What&apos;s wrong?</legend>
{REPORT_REASONS.map((r) => (
<label key={r} className="checkbox">
<input type="radio" name="reason" checked={reason === r} onChange={() => setReason(r)} /> {REPORT_REASON_LABELS[r]}
</label>
))}
</fieldset>
<label>
Anything else? (optional)
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={3} />
</label>
{error && <p className="error">{error}</p>}
<div className="row">
<button className="btn btn-danger" disabled={!reason}>
Send report
</button>
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
</div>
</form>
</Modal>
);
}
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
ref.current?.showModal();
}, []);
return (
<dialog ref={ref} className="modal card" onClose={onClose} onClick={(e) => e.target === ref.current && onClose()}>
<h2>{title}</h2>
{children}
</dialog>
);
}