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>
);
}
+22
View File
@@ -68,6 +68,28 @@ input:focus, textarea:focus, select:focus {
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.service-name { display: flex; align-items: center; gap: 0.6rem; font-family: var(--font-display); font-size: 1.12rem; font-weight: 700; margin-bottom: 0.2rem; }
.service-badge { width: 1.7rem; height: 1.7rem; border-radius: 0.5rem; display: grid; place-items: center; color: #fff; font-size: 0.95rem; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; }
.chip-small { display: inline-block; margin-left: 0.5rem; padding: 0.05rem 0.5rem; border-radius: 999px; background: rgba(255, 255, 255, 0.1); font-size: 0.75rem; color: var(--text); }
.stats a { color: inherit; text-decoration: none; }
.stats a:hover strong { text-decoration: underline; }
.menu { position: relative; }
.menu-list { position: absolute; right: 0; top: calc(100% + 0.35rem); z-index: 20; min-width: 12rem; display: flex; flex-direction: column; padding: 0.35rem; border-radius: 0.8rem; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 18px 40px -12px rgba(0, 0, 0, 0.6); }
.menu-list button { all: unset; cursor: pointer; padding: 0.55rem 0.75rem; border-radius: 0.55rem; font-size: 0.92rem; }
.menu-list button:hover, .menu-list button:focus-visible { background: rgba(255, 255, 255, 0.08); }
.menu-list .danger { color: var(--danger); }
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: #15112c; }
.modal::backdrop { background: rgba(5, 3, 15, 0.7); backdrop-filter: blur(4px); }
.modal p { margin-bottom: 1rem; }
.choices { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.45rem; }
.people { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
.people li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.people .user-chip { min-width: 0; }
.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; }
.admin-stat { text-align: center; padding: 1rem; }
.admin-stat-value { font-family: var(--font-display); font-size: 1.8rem; font-weight: 700; }
.reports { list-style: none; padding: 0; margin: 0.75rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
.report { padding: 0.85rem; border-radius: 0.8rem; background: rgba(255, 255, 255, 0.04); display: flex; flex-direction: column; gap: 0.5rem; }
.report p { margin: 0; }
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; }
+30 -1
View File
@@ -1,5 +1,6 @@
'use client';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useRef, useState } from 'react';
import useSWR from 'swr';
@@ -12,12 +13,13 @@ import {
type MeDTO,
type NowPlayingProvider,
type TrackSearchResultDTO,
type UserSummaryDTO,
} from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls';
import { SupportCard } from '@/components/SupportLink';
import { api, fetcher, uploadFile, useMe } from '@/lib/api';
import { api, client, fetcher, uploadFile, useMe } from '@/lib/api';
import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced';
@@ -41,6 +43,7 @@ function Settings() {
<SharingControls />
<Connections />
<FeaturedPicker />
<BlockedAccounts />
<SupportCard />
<DangerZone />
</div>
@@ -145,6 +148,32 @@ function AvatarPicker({ me }: { me: MeDTO }) {
);
}
function BlockedAccounts() {
const { data: blocked = [], mutate } = useSWR<UserSummaryDTO[]>('/me/blocks', fetcher);
return (
<section className="card">
<h2>Blocked accounts</h2>
{blocked.length === 0 ? (
<p className="muted">You haven&apos;t blocked anyone. Block someone from the ⋯ menu on their profile.</p>
) : (
<ul className="people">
{blocked.map((u) => (
<li key={u.id}>
<Link href={`/u/${u.username}`} className="user-chip">
<Avatar user={u} size={36} />
<span className="user-name">{u.displayName ?? u.username}</span>
</Link>
<button className="btn btn-small" onClick={async () => (await client.unblock(u.username), mutate())}>
Unblock
</button>
</li>
))}
</ul>
)}
</section>
);
}
function Connections() {
const params = useSearchParams();
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
@@ -0,0 +1,18 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { FollowList } from '@/components/FollowList';
export default function FollowersPage() {
const { username } = useParams<{ username: string }>();
return (
<div className="stack">
<p className="muted">
<Link href={`/u/${username}`}>← @{username}</Link>
</p>
<h1>Followers</h1>
<FollowList username={username} direction="followers" />
</div>
);
}
@@ -0,0 +1,18 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { FollowList } from '@/components/FollowList';
export default function FollowingPage() {
const { username } = useParams<{ username: string }>();
return (
<div className="stack">
<p className="muted">
<Link href={`/u/${username}`}>← @{username}</Link>
</p>
<h1>Following</h1>
<FollowList username={username} direction="following" />
</div>
);
}
+22 -8
View File
@@ -8,6 +8,7 @@ import useSWR from 'swr';
import { profileUrl, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { ProfileMenu } from '@/components/ProfileMenu';
import { QrCode } from '@/components/QrCode';
import { RecentPlays } from '@/components/RecentPlays';
import { TrackCard } from '@/components/TrackCard';
@@ -65,15 +66,18 @@ export default function ProfilePage() {
<Avatar user={user} size={96} live={Boolean(profile.nowPlaying?.isPlaying)} />
<div>
<h1>{user.displayName ?? user.username}</h1>
<div className="muted">@{user.username}</div>
<div className="muted">
@{user.username}
{profile.followsYou && !profile.isSelf && <span className="chip-small">Follows you</span>}
</div>
</div>
<div className="stats">
<span>
<Link href={`/u/${user.username}/followers`}>
<strong>{profile.followerCount}</strong> {profile.followerCount === 1 ? 'follower' : 'followers'}
</span>
<span>
</Link>
<Link href={`/u/${user.username}/following`}>
<strong>{profile.followingCount}</strong> following
</span>
</Link>
</div>
{user.bio && <p className="bio">{user.bio}</p>}
{profile.isSelf ? (
@@ -92,9 +96,12 @@ export default function ProfilePage() {
{profile.isPinned ? '★ Pinned' : '☆ Pin'}
</button>
)}
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
{profile.isFollowing ? 'Following' : 'Follow'}
</button>
{!profile.isBlocked && (
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
{profile.isFollowing ? 'Following' : profile.followsYou ? 'Follow back' : 'Follow'}
</button>
)}
<ProfileMenu profile={profile} onChanged={() => mutate()} />
</div>
) : (
<Link href="/register" className="btn btn-primary">
@@ -103,6 +110,13 @@ export default function ProfilePage() {
)}
</motion.section>
{profile.isBlocked && (
<p className="card muted">
You blocked @{user.username}. They can&apos;t see your profile or what you play, and you won&apos;t see theirs. Unblock
them from the ⋯ menu.
</p>
)}
{profile.isSelf && showQr && (
<section className="card qr-card">
<QrCode value={profileUrl(window.location.origin, user.username)} size={220} />