diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 6c36f3b..fcb248b 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -17,7 +17,7 @@ import { FeaturedPicker } from '@/components/FeaturedPicker'; import { Screen } from '@/components/Screen'; import { ServiceName } from '@/components/ServiceName'; import { SharingControls } from '@/components/SharingControls'; -import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; +import { Body, Button, Card, Chip, Field, Heading, ListRow, Row, Title } from '@/components/ui'; import { api } from '@/lib/api'; import { WEB_URL } from '@/lib/config'; import { showError } from '@/lib/errors'; @@ -451,35 +451,42 @@ function Account() { }, }, ]); + const email = () => + Alert.alert( + me?.email ?? 'Email', + me?.pendingEmail + ? `Changing to ${me.pendingEmail}: open the link we sent there.` + : me?.emailVerified + ? 'Confirmed.' + : 'Not confirmed yet. Open the link we emailed you.', + [ + { text: 'Change email', onPress: () => router.push('/change-email') }, + ...(me?.pendingEmail + ? [{ text: 'Cancel the change', onPress: cancelChange }] + : !me?.emailVerified + ? [{ text: 'Send the link again', onPress: resend }] + : []), + { text: 'Close', style: 'cancel' as const }, + ], + ); + const confirmSignOut = () => + Alert.alert('Log out?', undefined, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Log out', onPress: signOut }, + ]); return ( - - Account - + + Account + + router.push('/change-password')} /> + router.push('/blocked')} /> + Linking.openURL(`${WEB_URL}/privacy`)} /> + Linking.openURL(`${WEB_URL}/terms`)} /> + + + Signed in as @{me?.username} - - {me?.email} · {me?.emailVerified ? 'confirmed' : 'not confirmed'} - - {me?.pendingEmail ? ( - - Changing to {me.pendingEmail}: open the link we sent there. - - ) : null} - - {me && !me.emailVerified && !me.pendingEmail && -

- )} - {!me.emailVerified && !me.pendingEmail && ( - - )} - - - {editing === 'email' ? ( -
{ - e.preventDefault(); - const f = new FormData(e.currentTarget); - const email = field(f, 'email'); - run(() => client.changeEmail({ email, password: field(f, 'password') }), `Check ${email} for a confirmation link.`); - }} - > - - - - setEditing(null)} /> - - ) : editing === 'password' ? ( -
{ - e.preventDefault(); - const f = new FormData(e.currentTarget); - run( - () => client.changePassword({ currentPassword: field(f, 'current'), newPassword: field(f, 'new') }), - 'Password changed. Your other devices were signed out.', - ); - }} - > - - - - setEditing(null)} /> - - ) : ( -
- - -
- )} - {message &&

{message}

} - {error &&

{error}

} - - ); -} - -function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) { - return ( - - ); -} - -function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) { - return ( -
- - -
- ); -} - -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); @@ -507,6 +356,9 @@ function WebhookHint({ connection }: { connection: ConnectionDTO | undefined }) function FeaturedPicker() { const { me } = useMe(); + const { data: profile, mutate: refreshProfile } = useSWR(me ? `/users/${me.username}` : null, fetcher); + const current = profile?.featured ?? null; + const [editing, setEditing] = useState(false); const [q, setQ] = useState(''); const [selected, setSelected] = useState(null); const [comment, setComment] = useState(''); @@ -521,12 +373,51 @@ function FeaturedPicker() { if (!selected) return; await api('/me/featured', { method: 'PUT', body: { deezerId: selected.deezerId, comment: comment || null } }); setSaved(true); + setEditing(false); + setSelected(null); + setComment(''); + await refreshProfile(); }; + const remove = async () => { + await client.clearFeatured(); + setSaved(false); + await refreshProfile(); + }; + + if (current && !editing) { + return ( +
+

Featured song

+
+ {current.track.artworkUrl && } +
+
{current.track.title}
+
{current.track.artist}
+
+
+ {current.comment &&
{current.comment}
} +
+ + + {saved && Featured ✓} +
+
+ ); + } return (

Featured song

Pin a song to your profile, with an optional note about why.

+ {current && ( + + )} {selected ? (
{selected.artworkUrl && } @@ -588,33 +479,238 @@ function FeaturedPicker() { ); } -function DangerZone() { - const [confirming, setConfirming] = useState(false); - const router = useRouter(); +type Panel = 'email' | 'password' | 'blocked' | 'delete' | null; + +/** Everything about the account itself, as one list of rows; each opens in place. */ +function AccountCard({ me }: { me: MeDTO }) { const { mutate } = useMe(); - const remove = async () => { - await api('/me', { method: 'DELETE' }); + const router = useRouter(); + const [open, setOpen] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [show, setShow] = useState(false); + const { data: blocked = [], mutate: refreshBlocked } = useSWR('/me/blocks', fetcher); + + const toggle = (panel: Panel) => { + setError(null); + setMessage(null); + setOpen((p) => (p === panel ? null : panel)); + }; + const run = async (fn: () => Promise, done: string) => { + setError(null); + try { + await fn(); + await mutate(); + setOpen(null); + setMessage(done); + } catch (err) { + setError((err as Error).message); + } + }; + const field = (f: FormData, k: string) => String(f.get(k) ?? ''); + const logout = async () => { + await client.logout(); await mutate(undefined, { revalidate: true }); router.push('/'); }; + const remove = async () => { + await client.deleteAccount(); + await mutate(undefined, { revalidate: true }); + router.push('/'); + }; + return (
-

Delete account

-

Permanently removes your profile, follows and connected services.

- {confirming ? ( -
- - -
- ) : ( - - )} +

Account

+
+ + {me.email} {me.emailVerified ? null : Not confirmed} + + } + open={open === 'email'} + onClick={() => toggle('email')} + /> + {open === 'email' && ( +
+ {me.pendingEmail && ( +

+ Changing to {me.pendingEmail}: open the link we sent there.{' '} + +

+ )} + {!me.emailVerified && !me.pendingEmail && ( +

+ Not confirmed yet.{' '} + +

+ )} +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + const email = field(f, 'email'); + run(() => client.changeEmail({ email, password: field(f, 'password') }), `Check ${email} for a confirmation link.`); + }} + > + + + + setOpen(null)} /> + +
+ )} + + toggle('password')} /> + {open === 'password' && ( +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + run( + () => client.changePassword({ currentPassword: field(f, 'current'), newPassword: field(f, 'new') }), + 'Password changed. Your other devices were signed out.', + ); + }} + > + + + + setOpen(null)} /> + + )} + + toggle('blocked')} /> + {open === 'blocked' && ( +
+ {blocked.length === 0 ? ( +

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

+ ) : ( +
    + {blocked.map((u) => ( +
  • + + + {u.displayName ?? u.username} + + +
  • + ))} +
+ )} +
+ )} + + {me.role === 'admin' && } + {DONATION_URL && } + + + + + toggle('delete')} /> + {open === 'delete' && ( +
+

This permanently removes your profile, picture, plays, follows and connected services.

+
+ + +
+
+ )} +
+ {message &&

{message}

} + {error &&

{error}

}
); } + +/** One settings row: opens a panel below it, or links somewhere. */ +function Row({ + label, + value, + open, + onClick, + href, + external, + danger, +}: { + label: string; + value?: React.ReactNode; + open?: boolean; + onClick?: () => void; + href?: string; + external?: boolean; + danger?: boolean; +}) { + const body = ( + <> + {label} + {value != null && {value}} + + › + + + ); + const cls = `list-row ${danger ? 'danger' : ''}`; + if (href) { + return external ? ( + + {body} + + ) : ( + + {body} + + ); + } + return ( + + ); +} + +function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) { + return ( + + ); +} + +function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx index 5ddf4dc..b93c8f2 100644 --- a/apps/web/src/components/Nav.tsx +++ b/apps/web/src/components/Nav.tsx @@ -1,49 +1,57 @@ 'use client'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { useState } from 'react'; -import { api, client, useMe } from '@/lib/api'; -import { SupportLink } from './SupportLink'; +import { client, useMe } from '@/lib/api'; +/** Top bar: logo and the three main places. On phones those move to a bottom tab bar. */ export function Nav() { - const { me, mutate } = useMe(); - const router = useRouter(); - const logout = async () => { - await api('/auth/logout', { method: 'POST', body: {} }); - await mutate(undefined, { revalidate: true }); - router.push('/'); - }; + const { me } = useMe(); + const path = usePathname(); + const tabs = me + ? [ + { href: '/', label: 'Feed', icon: , active: path === '/' }, + { href: `/u/${me.username}`, label: 'Profile', icon: , active: path.startsWith(`/u/${me.username}`) }, + { href: '/settings', label: 'Settings', icon: , active: path.startsWith('/settings') || path === '/admin' }, + ] + : []; + return ( <> - - {me && !me.emailVerified && } + ))} + + )} + {me && !me.emailVerified && } ); } @@ -62,7 +70,34 @@ function VerifyBanner({ email }: { email: string }) { return (
Confirm your email: we sent a link to {email}.{' '} - {sent ? Sent again. : } + {sent ? ( + Sent again. + ) : ( + + )}
); } + +const icon = { width: 22, height: 22, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' } as const; +const FeedIcon = () => ( + + + + + +); +const ProfileIcon = () => ( + + + + +); +const SettingsIcon = () => ( + + + + +);