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
+22 -1
View File
@@ -1,5 +1,8 @@
import type {
AdminReportDTO,
AdminStatsDTO,
ConnectionDTO,
FollowListDTO,
FeedItemDTO,
MeDTO,
ProfileDTO,
@@ -7,7 +10,7 @@ import type {
UserSummaryDTO,
} from './dto';
import type { DeviceDTO } from './devices';
import type { LoginInput, RegisterInput, ResetPasswordInput, SetFeaturedInput, UpdateMeInput } from './schemas';
import type { LoginInput, RegisterInput, ReportReason, ResetPasswordInput, SetFeaturedInput, UpdateMeInput } from './schemas';
export class ApiError extends Error {
constructor(
@@ -89,6 +92,24 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
pin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'PUT', body: {} }),
unpin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'DELETE' }),
followers: (username: string, cursor?: string | null) =>
request<FollowListDTO>(`/users/${q(username)}/followers${cursor ? `?cursor=${q(cursor)}` : ''}`),
following: (username: string, cursor?: string | null) =>
request<FollowListDTO>(`/users/${q(username)}/following${cursor ? `?cursor=${q(cursor)}` : ''}`),
/** Stop someone following you (they aren't told). */
removeFollower: (username: string) => request(`/me/followers/${q(username)}`, { method: 'DELETE' }),
block: (username: string) => request(`/users/${q(username)}/block`, { method: 'POST', body: {} }),
unblock: (username: string) => request(`/users/${q(username)}/block`, { method: 'DELETE' }),
blocks: () => request<UserSummaryDTO[]>('/me/blocks'),
report: (username: string, input: { reason: ReportReason; note?: string | null }) =>
request(`/users/${q(username)}/report`, { body: input }),
adminStats: () => request<AdminStatsDTO>('/admin/stats'),
adminReports: (status: 'open' | 'resolved' = 'open') => request<AdminReportDTO[]>(`/admin/reports?status=${status}`),
resolveReport: (id: string) => request(`/admin/reports/${q(id)}/resolve`, { method: 'POST', body: {} }),
suspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'POST', body: {} }),
unsuspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'DELETE' }),
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
connections: () => request<ConnectionDTO[]>('/connections'),