diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 265c50f..b854a77 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -441,6 +441,7 @@ function Account() { Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'} + + ))} + + + {reports?.length === 0 &&

{status === 'open' ? 'Nothing to review. 🎉' : 'No resolved reports yet.'}

} + + + + {confirm && ( + setConfirm(null)} + > +

+ {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."} +

+
+ + +
+
+ )} + + ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 423d88f..580ba1c 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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; } diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 71712ce..b24965a 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -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() { + @@ -145,6 +148,32 @@ function AvatarPicker({ me }: { me: MeDTO }) { ); } +function BlockedAccounts() { + const { data: blocked = [], mutate } = useSWR('/me/blocks', fetcher); + return ( +
+

Blocked accounts

+ {blocked.length === 0 ? ( +

You haven't blocked anyone. Block someone from the ⋯ menu on their profile.

+ ) : ( +
    + {blocked.map((u) => ( +
  • + + + {u.displayName ?? u.username} + + +
  • + ))} +
+ )} +
+ ); +} + function Connections() { const params = useSearchParams(); const { data: connections = [], mutate } = useSWR('/connections', fetcher); diff --git a/apps/web/src/app/u/[username]/followers/page.tsx b/apps/web/src/app/u/[username]/followers/page.tsx new file mode 100644 index 0000000..4a90c19 --- /dev/null +++ b/apps/web/src/app/u/[username]/followers/page.tsx @@ -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 ( +
+

+ ← @{username} +

+

Followers

+ +
+ ); +} diff --git a/apps/web/src/app/u/[username]/following/page.tsx b/apps/web/src/app/u/[username]/following/page.tsx new file mode 100644 index 0000000..44a1429 --- /dev/null +++ b/apps/web/src/app/u/[username]/following/page.tsx @@ -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 ( +
+

+ ← @{username} +

+

Following

+ +
+ ); +} diff --git a/apps/web/src/app/u/[username]/page.tsx b/apps/web/src/app/u/[username]/page.tsx index 4fbbb1d..0c8f0ce 100644 --- a/apps/web/src/app/u/[username]/page.tsx +++ b/apps/web/src/app/u/[username]/page.tsx @@ -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() {

{user.displayName ?? user.username}

-
@{user.username}
+
+ @{user.username} + {profile.followsYou && !profile.isSelf && Follows you} +
- + {profile.followerCount} {profile.followerCount === 1 ? 'follower' : 'followers'} - - + + {profile.followingCount} following - +
{user.bio &&

{user.bio}

} {profile.isSelf ? ( @@ -92,9 +96,12 @@ export default function ProfilePage() { {profile.isPinned ? '★ Pinned' : '☆ Pin'} )} - + {!profile.isBlocked && ( + + )} + mutate()} /> ) : ( @@ -103,6 +110,13 @@ export default function ProfilePage() { )} + {profile.isBlocked && ( +

+ 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. +

+ )} + {profile.isSelf && showQr && (
diff --git a/apps/web/src/components/FollowList.tsx b/apps/web/src/components/FollowList.tsx new file mode 100644 index 0000000..292d8fe --- /dev/null +++ b/apps/web/src/components/FollowList.tsx @@ -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( + (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

Couldn't load this list.

; + return ( +
+ {users.length === 0 && !isLoading && ( +

{direction === 'followers' ? 'No followers yet.' : 'Not following anyone yet.'}

+ )} +
    + {users.map((u) => ( +
  • + + + + {u.displayName ?? u.username} + @{u.username} + + + {me && u.username !== me.username && ( + + {own && direction === 'followers' && ( + + )} + + + )} +
  • + ))} +
+ {more && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx index e6896d6..c5027e7 100644 --- a/apps/web/src/components/Nav.tsx +++ b/apps/web/src/components/Nav.tsx @@ -26,6 +26,7 @@ export function Nav() { Feed Profile Settings + {me.role === 'admin' && Admin} diff --git a/apps/web/src/components/ProfileMenu.tsx b/apps/web/src/components/ProfileMenu.tsx new file mode 100644 index 0000000..759083d --- /dev/null +++ b/apps/web/src/components/ProfileMenu.tsx @@ -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(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 ( +
+ + {open && ( +
+ + +
+ )} + {confirmBlock && ( + setConfirmBlock(false)} title={`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. +

+
+ + +
+
+ )} + {reporting && setReporting(false)} />} +
+ ); +} + +function ReportForm({ username, name, onClose }: { username: string; name: string; onClose: () => void }) { + const [reason, setReason] = useState(null); + const [note, setNote] = useState(''); + const [sent, setSent] = useState(false); + const [error, setError] = useState(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 ( + +

We'll take a look. If they're bothering you, you can also block them from this menu.

+ +
+ ); + } + return ( + +
+
+ What's wrong? + {REPORT_REASONS.map((r) => ( + + ))} +
+