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:
@@ -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't be able to find you, see your profile or what you're playing, and you'll stop following each
|
||||
other. They aren'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'll take a look. If they'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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user