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
@@ -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} />