- Anyone who can see a play can read its comments; the player and the people they follow can write. The author, the player and admins can delete; comments can be reported (admins see the text). - Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its own setting. Reaction and comment notifications now open the play. - A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on the app (/play/[id]) and the website. Threads refresh every few seconds while open. - Blocking removes comments between the two people. Plays with comments survive history trimming. - Privacy page updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
127 lines
5.4 KiB
TypeScript
127 lines
5.4 KiB
TypeScript
'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.comment && (
|
|
<p className="muted small">
|
|
Their comment{r.comment.id ? '' : ' (since deleted)'}: <q>{r.comment.body}</q>
|
|
</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>
|
|
);
|
|
}
|