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:
@@ -441,6 +441,7 @@ function Account() {
|
|||||||
<Body muted style={styles.small}>
|
<Body muted style={styles.small}>
|
||||||
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
|
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
|
||||||
</Body>
|
</Body>
|
||||||
|
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
|
||||||
<Row>
|
<Row>
|
||||||
<Button small title="Log out" onPress={signOut} />
|
<Button small title="Log out" onPress={signOut} />
|
||||||
<Button small variant="danger" title="Delete account" onPress={confirmDelete} />
|
<Button small variant="danger" title="Delete account" onPress={confirmDelete} />
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ function AppNavigator() {
|
|||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={signedIn}>
|
<Stack.Protected guard={signedIn}>
|
||||||
{/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
|
{/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
|
||||||
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
<Stack.Screen name="u/[username]/index" options={{ title: '' }} />
|
||||||
|
<Stack.Screen name="u/[username]/followers" options={{ title: 'Followers' }} />
|
||||||
|
<Stack.Screen name="u/[username]/following" options={{ title: 'Following' }} />
|
||||||
|
<Stack.Screen name="report/[username]" options={{ title: 'Report', presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="blocked" options={{ title: 'Blocked accounts' }} />
|
||||||
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={!signedIn}>
|
<Stack.Protected guard={!signedIn}>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
|
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Body, Button } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { colors, space } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function BlockedScreen() {
|
||||||
|
const { data: blocked = [], mutate, isLoading } = useSWR<UserSummaryDTO[]>('/me/blocks');
|
||||||
|
const unblock = async (username: string) => {
|
||||||
|
try {
|
||||||
|
await api.unblock(username);
|
||||||
|
await mutate();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={blocked}
|
||||||
|
keyExtractor={(u) => u.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListEmptyComponent={isLoading ? null : <Body muted>You haven't blocked anyone. Block someone from the ⋯ menu on their profile.</Body>}
|
||||||
|
renderItem={({ item: u }) => (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Link href={`/u/${u.username}`} style={{ flex: 1 }}>
|
||||||
|
<View style={styles.person}>
|
||||||
|
<Avatar user={u} size={40} />
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{u.displayName ?? u.username}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
<Button small title="Unblock" onPress={() => unblock(u.username)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
list: { padding: space.lg, gap: space.md },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
|
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
|
name: { color: colors.text, fontSize: 15, fontWeight: '600', flexShrink: 1 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
|
import { REPORT_REASONS, REPORT_REASON_LABELS, type ReportReason } from '@musicshare/shared';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { Body, Button, Chip, Field, Row, Title } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export default function ReportScreen() {
|
||||||
|
const { username } = useLocalSearchParams<{ username: string }>();
|
||||||
|
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!reason) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.report(username, { reason, note: note.trim() || null });
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't send the report");
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Title>Thanks for letting us know</Title>
|
||||||
|
<Body>We'll take a look. If they're bothering you, you can also block them from the ⋯ menu on their profile.</Body>
|
||||||
|
<Button variant="primary" title="Done" onPress={() => router.back()} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<Screen>
|
||||||
|
<Body muted>What's wrong with @{username}'s account?</Body>
|
||||||
|
<Row>
|
||||||
|
{REPORT_REASONS.map((r) => (
|
||||||
|
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Field label="Anything else? (optional)" value={note} onChangeText={setNote} maxLength={500} multiline style={{ minHeight: 80 }} />
|
||||||
|
<Button variant="primary" title="Send report" disabled={!reason} loading={busy} onPress={submit} />
|
||||||
|
</Screen>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { FollowList } from '@/components/FollowList';
|
||||||
|
|
||||||
|
export default function FollowersScreen() {
|
||||||
|
const { username } = useLocalSearchParams<{ username: string }>();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Followers' }} />
|
||||||
|
<FollowList username={username} direction="followers" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { FollowList } from '@/components/FollowList';
|
||||||
|
|
||||||
|
export default function FollowingScreen() {
|
||||||
|
const { username } = useLocalSearchParams<{ username: string }>();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Following' }} />
|
||||||
|
<FollowList username={username} direction="following" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import type { FollowListDTO } from '@musicshare/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, space } from '@/lib/theme';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { Body, Button } from './ui';
|
||||||
|
|
||||||
|
/** Followers or following, 50 at a time. On your own followers you can remove people. */
|
||||||
|
export function FollowList({ username, direction }: { username: string; direction: 'followers' | 'following' }) {
|
||||||
|
const { me } = useSession();
|
||||||
|
const { data, size, setSize, mutate, isLoading } = useSWRInfinite<FollowListDTO>(
|
||||||
|
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : [username, direction, prev?.nextCursor ?? null]),
|
||||||
|
([u, d, cursor]: [string, typeof direction, string | null]) => (d === 'followers' ? api.followers(u, cursor) : api.following(u, cursor)),
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const users = data?.flatMap((p) => p.users) ?? [];
|
||||||
|
const more = Boolean(data?.at(-1)?.nextCursor);
|
||||||
|
const own = me?.username === username;
|
||||||
|
|
||||||
|
const act = async (target: string, fn: () => Promise<unknown>) => {
|
||||||
|
setBusy(target);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await mutate();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
setBusy(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={users}
|
||||||
|
keyExtractor={(u) => u.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
onEndReached={() => more && setSize(size + 1)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
isLoading ? null : <Body muted>{direction === 'followers' ? 'No followers yet.' : 'Not following anyone yet.'}</Body>
|
||||||
|
}
|
||||||
|
renderItem={({ item: u }) => (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Link href={`/u/${u.username}`} style={{ flex: 1 }}>
|
||||||
|
<View style={styles.person}>
|
||||||
|
<Avatar user={u} size={40} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{u.displayName ?? u.username}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.handle}>@{u.username}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
{me && u.username !== me.username && (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{own && direction === 'followers' && (
|
||||||
|
<Button small title="Remove" disabled={busy === u.username} onPress={() => act(u.username, () => api.removeFollower(u.username))} />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
variant={u.isFollowing ? 'secondary' : 'primary'}
|
||||||
|
title={u.isFollowing ? 'Following' : 'Follow'}
|
||||||
|
disabled={busy === u.username}
|
||||||
|
onPress={() => act(u.username, () => (u.isFollowing ? api.unfollow(u.username) : api.follow(u.username)))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
list: { padding: space.lg, gap: space.md },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
|
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
|
name: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
|
handle: { color: colors.muted, fontSize: 13 },
|
||||||
|
actions: { flexDirection: 'row', gap: space.sm },
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { ProfileDTO } from '@musicshare/shared';
|
import type { ProfileDTO } from '@musicshare/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -49,6 +49,28 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
}
|
}
|
||||||
if (!profile) return <Screen edges={edges} />;
|
if (!profile) return <Screen edges={edges} />;
|
||||||
const { user } = profile;
|
const { user } = profile;
|
||||||
|
const name = user.displayName ?? user.username;
|
||||||
|
|
||||||
|
const openMenu = () =>
|
||||||
|
Alert.alert(name, undefined, [
|
||||||
|
profile.isBlocked
|
||||||
|
? { text: `Unblock ${name}`, onPress: () => act(() => api.unblock(username)) }
|
||||||
|
: {
|
||||||
|
text: `Block ${name}`,
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () =>
|
||||||
|
Alert.alert(
|
||||||
|
`Block ${name}?`,
|
||||||
|
"They won't be able to find you, see your profile or what you're playing, and you'll stop following each other. They aren't told.",
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Block', style: 'destructive', onPress: () => act(() => api.block(username)) },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ text: `Report ${name}`, style: 'destructive', onPress: () => router.push(`/report/${username}`) },
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen
|
<Screen
|
||||||
@@ -64,8 +86,21 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
<View style={{ flex: 1, gap: 2 }}>
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
<Title numberOfLines={1}>{user.displayName ?? user.username}</Title>
|
<Title numberOfLines={1}>{user.displayName ?? user.username}</Title>
|
||||||
<Text style={styles.meta}>
|
<Text style={styles.meta}>
|
||||||
@{user.username} · {profile.followerCount} followers · {profile.followingCount} following
|
@{user.username}
|
||||||
|
{profile.followsYou && !profile.isSelf ? ' · Follows you' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={styles.counts}>
|
||||||
|
<Pressable onPress={() => router.push(`/u/${user.username}/followers`)} hitSlop={6}>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
<Text style={styles.count}>{profile.followerCount}</Text> {profile.followerCount === 1 ? 'follower' : 'followers'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => router.push(`/u/${user.username}/following`)} hitSlop={6}>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
<Text style={styles.count}>{profile.followingCount}</Text> following
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{user.bio && <Body>{user.bio}</Body>}
|
{user.bio && <Body>{user.bio}</Body>}
|
||||||
@@ -75,13 +110,15 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
<Button small title="Edit profile" onPress={() => router.push('/settings')} />
|
<Button small title="Edit profile" onPress={() => router.push('/settings')} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{!profile.isBlocked && (
|
||||||
<Button
|
<Button
|
||||||
small
|
small
|
||||||
variant={profile.isFollowing ? 'secondary' : 'primary'}
|
variant={profile.isFollowing ? 'secondary' : 'primary'}
|
||||||
title={profile.isFollowing ? 'Following' : 'Follow'}
|
title={profile.isFollowing ? 'Following' : profile.followsYou ? 'Follow back' : 'Follow'}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
|
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{profile.isFollowing && (
|
{profile.isFollowing && (
|
||||||
<Button
|
<Button
|
||||||
small
|
small
|
||||||
@@ -90,10 +127,20 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
onPress={() => act(() => (profile.isPinned ? api.unpin(username) : api.pin(username)))}
|
onPress={() => act(() => (profile.isPinned ? api.unpin(username) : api.pin(username)))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Button small title="⋯" accessibilityLabel="More options" onPress={openMenu} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{profile.isBlocked && (
|
||||||
|
<Card>
|
||||||
|
<Body muted>
|
||||||
|
You blocked @{user.username}. They can't see your profile or what you play, and you won't see theirs. Unblock
|
||||||
|
them from the ⋯ menu.
|
||||||
|
</Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{profile.featured && (
|
{profile.featured && (
|
||||||
<Card>
|
<Card>
|
||||||
<Heading>Featured song</Heading>
|
<Heading>Featured song</Heading>
|
||||||
@@ -116,6 +163,8 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||||||
meta: { color: colors.muted, fontSize: 13 },
|
meta: { color: colors.muted, fontSize: 13 },
|
||||||
|
counts: { flexDirection: 'row', gap: space.lg, marginTop: 2 },
|
||||||
|
count: { color: colors.text, fontWeight: '700' },
|
||||||
quote: {
|
quote: {
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,6 +68,28 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
.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-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; }
|
.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 { display: flex; gap: 1rem; align-items: center; }
|
||||||
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.grow { flex: 1; min-width: 0; }
|
.grow { flex: 1; min-width: 0; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useRef, useState } from 'react';
|
import { Suspense, useRef, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
@@ -12,12 +13,13 @@ import {
|
|||||||
type MeDTO,
|
type MeDTO,
|
||||||
type NowPlayingProvider,
|
type NowPlayingProvider,
|
||||||
type TrackSearchResultDTO,
|
type TrackSearchResultDTO,
|
||||||
|
type UserSummaryDTO,
|
||||||
} from '@musicshare/shared';
|
} from '@musicshare/shared';
|
||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { ServiceName } from '@/components/ServiceName';
|
import { ServiceName } from '@/components/ServiceName';
|
||||||
import { SharingControls } from '@/components/SharingControls';
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
import { SupportCard } from '@/components/SupportLink';
|
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 { timeAgo } from '@musicshare/shared';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ function Settings() {
|
|||||||
<SharingControls />
|
<SharingControls />
|
||||||
<Connections />
|
<Connections />
|
||||||
<FeaturedPicker />
|
<FeaturedPicker />
|
||||||
|
<BlockedAccounts />
|
||||||
<SupportCard />
|
<SupportCard />
|
||||||
<DangerZone />
|
<DangerZone />
|
||||||
</div>
|
</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'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() {
|
function Connections() {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import useSWR from 'swr';
|
|||||||
import { profileUrl, type ProfileDTO } from '@musicshare/shared';
|
import { profileUrl, type ProfileDTO } from '@musicshare/shared';
|
||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
|
import { ProfileMenu } from '@/components/ProfileMenu';
|
||||||
import { QrCode } from '@/components/QrCode';
|
import { QrCode } from '@/components/QrCode';
|
||||||
import { RecentPlays } from '@/components/RecentPlays';
|
import { RecentPlays } from '@/components/RecentPlays';
|
||||||
import { TrackCard } from '@/components/TrackCard';
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
@@ -65,15 +66,18 @@ export default function ProfilePage() {
|
|||||||
<Avatar user={user} size={96} live={Boolean(profile.nowPlaying?.isPlaying)} />
|
<Avatar user={user} size={96} live={Boolean(profile.nowPlaying?.isPlaying)} />
|
||||||
<div>
|
<div>
|
||||||
<h1>{user.displayName ?? user.username}</h1>
|
<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>
|
||||||
<div className="stats">
|
<div className="stats">
|
||||||
<span>
|
<Link href={`/u/${user.username}/followers`}>
|
||||||
<strong>{profile.followerCount}</strong> {profile.followerCount === 1 ? 'follower' : 'followers'}
|
<strong>{profile.followerCount}</strong> {profile.followerCount === 1 ? 'follower' : 'followers'}
|
||||||
</span>
|
</Link>
|
||||||
<span>
|
<Link href={`/u/${user.username}/following`}>
|
||||||
<strong>{profile.followingCount}</strong> following
|
<strong>{profile.followingCount}</strong> following
|
||||||
</span>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{user.bio && <p className="bio">{user.bio}</p>}
|
{user.bio && <p className="bio">{user.bio}</p>}
|
||||||
{profile.isSelf ? (
|
{profile.isSelf ? (
|
||||||
@@ -92,9 +96,12 @@ export default function ProfilePage() {
|
|||||||
{profile.isPinned ? '★ Pinned' : '☆ Pin'}
|
{profile.isPinned ? '★ Pinned' : '☆ Pin'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!profile.isBlocked && (
|
||||||
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
|
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
|
||||||
{profile.isFollowing ? 'Following' : 'Follow'}
|
{profile.isFollowing ? 'Following' : profile.followsYou ? 'Follow back' : 'Follow'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
<ProfileMenu profile={profile} onChanged={() => mutate()} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/register" className="btn btn-primary">
|
<Link href="/register" className="btn btn-primary">
|
||||||
@@ -103,6 +110,13 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</motion.section>
|
</motion.section>
|
||||||
|
|
||||||
|
{profile.isBlocked && (
|
||||||
|
<p className="card muted">
|
||||||
|
You blocked @{user.username}. They can't see your profile or what you play, and you won't see theirs. Unblock
|
||||||
|
them from the ⋯ menu.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{profile.isSelf && showQr && (
|
{profile.isSelf && showQr && (
|
||||||
<section className="card qr-card">
|
<section className="card qr-card">
|
||||||
<QrCode value={profileUrl(window.location.origin, user.username)} size={220} />
|
<QrCode value={profileUrl(window.location.origin, user.username)} size={220} />
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import type { FollowListDTO } from '@musicshare/shared';
|
||||||
|
import { client, useMe } from '@/lib/api';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
/** Followers or following, 50 at a time. On your own followers you can remove people. */
|
||||||
|
export function FollowList({ username, direction }: { username: string; direction: 'followers' | 'following' }) {
|
||||||
|
const { me } = useMe();
|
||||||
|
const { data, size, setSize, mutate, isLoading, error } = useSWRInfinite<FollowListDTO>(
|
||||||
|
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : [username, direction, prev?.nextCursor ?? null]),
|
||||||
|
([u, d, cursor]: [string, typeof direction, string | null]) => (d === 'followers' ? client.followers(u, cursor) : client.following(u, cursor)),
|
||||||
|
);
|
||||||
|
const users = data?.flatMap((p) => p.users) ?? [];
|
||||||
|
const more = Boolean(data?.at(-1)?.nextCursor);
|
||||||
|
const own = me?.username === username;
|
||||||
|
|
||||||
|
const toggleFollow = async (target: string, following: boolean) => {
|
||||||
|
await (following ? client.unfollow(target) : client.follow(target));
|
||||||
|
await mutate();
|
||||||
|
};
|
||||||
|
const remove = async (target: string) => {
|
||||||
|
await client.removeFollower(target);
|
||||||
|
await mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) return <p className="muted">Couldn't load this list.</p>;
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
{users.length === 0 && !isLoading && (
|
||||||
|
<p className="muted">{direction === 'followers' ? 'No followers yet.' : 'Not following anyone yet.'}</p>
|
||||||
|
)}
|
||||||
|
<ul className="people">
|
||||||
|
{users.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<Link href={`/u/${u.username}`} className="user-chip">
|
||||||
|
<Avatar user={u} size={40} />
|
||||||
|
<span>
|
||||||
|
<span className="user-name">{u.displayName ?? u.username}</span>
|
||||||
|
<span className="muted"> @{u.username}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{me && u.username !== me.username && (
|
||||||
|
<span className="row">
|
||||||
|
{own && direction === 'followers' && (
|
||||||
|
<button className="btn btn-small" onClick={() => remove(u.username)} title="They won't be told">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`btn btn-small ${u.isFollowing ? '' : 'btn-primary'}`}
|
||||||
|
onClick={() => toggleFollow(u.username, u.isFollowing)}
|
||||||
|
>
|
||||||
|
{u.isFollowing ? 'Following' : 'Follow'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{more && (
|
||||||
|
<button className="btn" onClick={() => setSize(size + 1)}>
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export function Nav() {
|
|||||||
<Link href="/">Feed</Link>
|
<Link href="/">Feed</Link>
|
||||||
<Link href={`/u/${me.username}`}>Profile</Link>
|
<Link href={`/u/${me.username}`}>Profile</Link>
|
||||||
<Link href="/settings">Settings</Link>
|
<Link href="/settings">Settings</Link>
|
||||||
|
{me.role === 'admin' && <Link href="/admin">Admin</Link>}
|
||||||
<button className="link-button" onClick={logout}>
|
<button className="link-button" onClick={logout}>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { REPORT_REASONS, REPORT_REASON_LABELS, type ProfileDTO, type ReportReason } from '@musicshare/shared';
|
||||||
|
import { client } from '@/lib/api';
|
||||||
|
|
||||||
|
/** "⋯" on someone else's profile: block/unblock and report. */
|
||||||
|
export function ProfileMenu({ profile, onChanged }: { profile: ProfileDTO; onChanged: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
const [confirmBlock, setConfirmBlock] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const name = profile.user.displayName ?? profile.user.username;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
|
||||||
|
document.addEventListener('mousedown', close);
|
||||||
|
return () => document.removeEventListener('mousedown', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggleBlock = async () => {
|
||||||
|
setOpen(false);
|
||||||
|
setConfirmBlock(false);
|
||||||
|
await (profile.isBlocked ? client.unblock(profile.user.username) : client.block(profile.user.username));
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu" ref={ref}>
|
||||||
|
<button className="btn" aria-haspopup="menu" aria-expanded={open} aria-label="More options" onClick={() => setOpen((v) => !v)}>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="menu-list" role="menu">
|
||||||
|
<button role="menuitem" onClick={() => (profile.isBlocked ? toggleBlock() : (setOpen(false), setConfirmBlock(true)))}>
|
||||||
|
{profile.isBlocked ? `Unblock ${name}` : `Block ${name}`}
|
||||||
|
</button>
|
||||||
|
<button role="menuitem" className="danger" onClick={() => (setOpen(false), setReporting(true))}>
|
||||||
|
Report {name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confirmBlock && (
|
||||||
|
<Modal onClose={() => setConfirmBlock(false)} title={`Block ${name}?`}>
|
||||||
|
<p>
|
||||||
|
They won't be able to find you, see your profile or what you're playing, and you'll stop following each
|
||||||
|
other. They aren't told.
|
||||||
|
</p>
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-danger" onClick={toggleBlock}>
|
||||||
|
Block
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => setConfirmBlock(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{reporting && <ReportForm username={profile.user.username} name={name} onClose={() => setReporting(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportForm({ username, name, onClose }: { username: string; name: string; onClose: () => void }) {
|
||||||
|
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!reason) return;
|
||||||
|
try {
|
||||||
|
await client.report(username, { reason, note: note.trim() || null });
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} title="Thanks for letting us know">
|
||||||
|
<p>We'll take a look. If they're bothering you, you can also block them from this menu.</p>
|
||||||
|
<button className="btn btn-primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} title={`Report ${name}`}>
|
||||||
|
<form className="form" onSubmit={submit}>
|
||||||
|
<fieldset className="choices">
|
||||||
|
<legend className="muted">What's wrong?</legend>
|
||||||
|
{REPORT_REASONS.map((r) => (
|
||||||
|
<label key={r} className="checkbox">
|
||||||
|
<input type="radio" name="reason" checked={reason === r} onChange={() => setReason(r)} /> {REPORT_REASON_LABELS[r]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<label>
|
||||||
|
Anything else? (optional)
|
||||||
|
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={3} />
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-danger" disabled={!reason}>
|
||||||
|
Send report
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.showModal();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<dialog ref={ref} className="modal card" onClose={onClose} onClick={(e) => e.target === ref.current && onClose()}>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { MeDTO } from '@musicshare/shared';
|
import { createApiClient, type MeDTO } from '@musicshare/shared';
|
||||||
|
|
||||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
||||||
|
|
||||||
@@ -45,3 +45,6 @@ export function useMe() {
|
|||||||
});
|
});
|
||||||
return { me: data, isLoading, loggedOut: error?.status === 401, mutate };
|
return { me: data, isLoading, loggedOut: error?.status === 401, mutate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The shared typed client, for calls not covered by `api` above. */
|
||||||
|
export const client = createApiClient({ baseUrl: API_URL, credentials: 'include' });
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdminReportDTO,
|
||||||
|
AdminStatsDTO,
|
||||||
ConnectionDTO,
|
ConnectionDTO,
|
||||||
|
FollowListDTO,
|
||||||
FeedItemDTO,
|
FeedItemDTO,
|
||||||
MeDTO,
|
MeDTO,
|
||||||
ProfileDTO,
|
ProfileDTO,
|
||||||
@@ -7,7 +10,7 @@ import type {
|
|||||||
UserSummaryDTO,
|
UserSummaryDTO,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import type { DeviceDTO } from './devices';
|
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 {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -89,6 +92,24 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
|||||||
pin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'PUT', body: {} }),
|
pin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'PUT', body: {} }),
|
||||||
unpin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'DELETE' }),
|
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)}`),
|
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
||||||
|
|
||||||
connections: () => request<ConnectionDTO[]>('/connections'),
|
connections: () => request<ConnectionDTO[]>('/connections'),
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable
|
|||||||
|
|
||||||
export const REPORT_REASONS = ['spam', 'harassment', 'inappropriate', 'impersonation', 'other'] as const;
|
export const REPORT_REASONS = ['spam', 'harassment', 'inappropriate', 'impersonation', 'other'] as const;
|
||||||
export type ReportReason = (typeof REPORT_REASONS)[number];
|
export type ReportReason = (typeof REPORT_REASONS)[number];
|
||||||
|
export const REPORT_REASON_LABELS: Record<ReportReason, string> = {
|
||||||
|
spam: 'Spam or fake account',
|
||||||
|
harassment: 'Harassment or bullying',
|
||||||
|
inappropriate: 'Inappropriate profile or picture',
|
||||||
|
impersonation: 'Pretending to be someone else',
|
||||||
|
other: 'Something else',
|
||||||
|
};
|
||||||
export const reportSchema = z.object({
|
export const reportSchema = z.object({
|
||||||
reason: z.enum(REPORT_REASONS),
|
reason: z.enum(REPORT_REASONS),
|
||||||
note: z.string().trim().max(500).nullish(),
|
note: z.string().trim().max(500).nullish(),
|
||||||
|
|||||||
Reference in New Issue
Block a user