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
+121
View File
@@ -0,0 +1,121 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import useSWR from 'swr';
import { REPORT_REASON_LABELS, timeAgo, type AdminReportDTO, type AdminStatsDTO, type ReportReason } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Modal } from '@/components/ProfileMenu';
import { client, fetcher, useMe } from '@/lib/api';
/** Moderation for the server's admin (the first account): reports and suspensions. */
export default function AdminPage() {
const { me, loggedOut } = useMe();
const router = useRouter();
const [status, setStatus] = useState<'open' | 'resolved'>('open');
const isAdmin = me?.role === 'admin';
const { data: stats, mutate: refreshStats } = useSWR<AdminStatsDTO>(isAdmin ? '/admin/stats' : null, fetcher);
const { data: reports, mutate } = useSWR<AdminReportDTO[]>(isAdmin ? `/admin/reports?status=${status}` : null, fetcher);
const [confirm, setConfirm] = useState<AdminReportDTO | null>(null);
if (loggedOut) router.replace('/login');
if (!me) return null;
if (!isAdmin) return <p className="muted">Admins only.</p>;
const refresh = () => Promise.all([mutate(), refreshStats()]);
const resolve = async (r: AdminReportDTO) => (await client.resolveReport(r.id), refresh());
const toggleSuspend = async (r: AdminReportDTO) => {
setConfirm(null);
await (r.reported.suspended ? client.unsuspend(r.reported.username) : client.suspend(r.reported.username));
await refresh();
};
return (
<div className="stack">
<h1>Admin</h1>
{stats && (
<div className="admin-stats">
<Stat label="Accounts" value={stats.users} />
<Stat label="Listening now" value={stats.listeningNow} />
<Stat label="Open reports" value={stats.openReports} />
<Stat label="Suspended" value={stats.suspended} />
</div>
)}
<section className="card">
<div className="row" style={{ justifyContent: 'space-between' }}>
<h2>Reports</h2>
<div className="row">
{(['open', 'resolved'] as const).map((s) => (
<button key={s} className={`btn btn-small ${status === s ? 'btn-primary' : ''}`} onClick={() => setStatus(s)}>
{s === 'open' ? 'Open' : 'Resolved'}
</button>
))}
</div>
</div>
{reports?.length === 0 && <p className="muted">{status === 'open' ? 'Nothing to review. 🎉' : 'No resolved reports yet.'}</p>}
<ul className="reports">
{reports?.map((r) => (
<li key={r.id} className="report">
<div className="row" style={{ justifyContent: 'space-between' }}>
<Link href={`/u/${r.reported.username}`} className="user-chip">
<Avatar user={r.reported} size={36} />
<span className="user-name">{r.reported.displayName ?? r.reported.username}</span>
<span className="muted">@{r.reported.username}</span>
{r.reported.suspended && <span className="chip-small danger">Suspended</span>}
</Link>
<span className="muted">{timeAgo(r.createdAt)}</span>
</div>
<p>
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
</p>
{r.note && <blockquote>{r.note}</blockquote>}
<div className="row">
{r.status === 'open' && (
<button className="btn btn-small" onClick={() => resolve(r)}>
Mark resolved
</button>
)}
<button className={`btn btn-small ${r.reported.suspended ? '' : 'btn-danger'}`} onClick={() => setConfirm(r)}>
{r.reported.suspended ? 'Unsuspend' : 'Suspend account'}
</button>
</div>
</li>
))}
</ul>
</section>
{confirm && (
<Modal
title={`${confirm.reported.suspended ? 'Unsuspend' : 'Suspend'} @${confirm.reported.username}?`}
onClose={() => setConfirm(null)}
>
<p>
{confirm.reported.suspended
? 'They can sign in again and their profile is visible again. Sharing stays paused until they turn it back on.'
: "They're signed out everywhere, can't sign in, their profile is hidden and nothing they play is shared."}
</p>
<div className="row">
<button className={`btn ${confirm.reported.suspended ? 'btn-primary' : 'btn-danger'}`} onClick={() => toggleSuspend(confirm)}>
{confirm.reported.suspended ? 'Unsuspend' : 'Suspend'}
</button>
<button className="btn" onClick={() => setConfirm(null)}>
Cancel
</button>
</div>
</Modal>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="card admin-stat">
<div className="admin-stat-value">{value}</div>
<div className="muted">{label}</div>
</div>
);
}