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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user