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
+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);