- Web header is just the logo and Feed / Profile / Settings (current page highlighted). On phones those move to a bottom tab bar. Log out, Admin and Support moved into Settings. - The bottom of Settings (web and app) is now one Account list: email, password, blocked accounts, admin, support, privacy, terms, log out and delete, each a row that opens in place (web) or a screen (app). - Featured song shows what's featured now, with Change and Remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -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 (
|
||||
<Card>
|
||||
<Heading>Account</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
<Card style={{ gap: 0 }}>
|
||||
<Heading style={{ marginBottom: space.sm }}>Account</Heading>
|
||||
<ListRow first label="Email" value={me?.emailVerified ? me.email : `${me?.email ?? ''} · not confirmed`} onPress={email} />
|
||||
<ListRow label="Password" value="Change" onPress={() => router.push('/change-password')} />
|
||||
<ListRow label="Blocked accounts" onPress={() => router.push('/blocked')} />
|
||||
<ListRow label="Privacy policy" onPress={() => Linking.openURL(`${WEB_URL}/privacy`)} />
|
||||
<ListRow label="Terms of use" onPress={() => Linking.openURL(`${WEB_URL}/terms`)} />
|
||||
<ListRow label="Log out" onPress={confirmSignOut} />
|
||||
<ListRow label="Delete account" danger onPress={confirmDelete} />
|
||||
<Body muted style={[styles.small, { marginTop: space.sm }]}>
|
||||
Signed in as @{me?.username}
|
||||
</Body>
|
||||
<Body style={styles.small}>
|
||||
{me?.email} · {me?.emailVerified ? 'confirmed' : 'not confirmed'}
|
||||
</Body>
|
||||
{me?.pendingEmail ? (
|
||||
<Body muted style={styles.small}>
|
||||
Changing to {me.pendingEmail}: open the link we sent there.
|
||||
</Body>
|
||||
) : null}
|
||||
<Row>
|
||||
{me && !me.emailVerified && !me.pendingEmail && <Button small variant="primary" title="Resend confirmation" onPress={resend} />}
|
||||
{me?.pendingEmail && <Button small title="Cancel email change" onPress={cancelChange} />}
|
||||
<Button small title="Change email" onPress={() => router.push('/change-email')} />
|
||||
<Button small title="Change password" onPress={() => router.push('/change-password')} />
|
||||
</Row>
|
||||
<Row>
|
||||
<Button small title="Privacy policy" onPress={() => Linking.openURL(`${WEB_URL}/privacy`)} />
|
||||
<Button small title="Terms" onPress={() => Linking.openURL(`${WEB_URL}/terms`)} />
|
||||
</Row>
|
||||
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
|
||||
<Row>
|
||||
<Button small title="Log out" onPress={signOut} />
|
||||
<Button small variant="danger" title="Delete account" onPress={confirmDelete} />
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,24 @@ import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import type { TrackSearchResultDTO } from '@musicshare/shared';
|
||||
import type { ProfileDTO, TrackSearchResultDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { Body, Button, Card, Field, Heading } from './ui';
|
||||
import { Body, Button, Card, Field, Heading, Row } from './ui';
|
||||
|
||||
/**
|
||||
* Search for a song and pin it to your profile with an optional note.
|
||||
* `bare` drops the card and heading (for onboarding, which has its own title).
|
||||
*/
|
||||
export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: () => void }) {
|
||||
const { me } = useSession();
|
||||
// In Settings, show what's featured now (with Change / Remove) before the search.
|
||||
const { data: profile, mutate: refreshProfile } = useSWR<ProfileDTO>(!bare && me ? `/users/${me.username}` : null);
|
||||
const current = profile?.featured ?? null;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
@@ -31,6 +37,12 @@ export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: ()
|
||||
await api.setFeatured({ deezerId: selected.deezerId, comment: comment.trim() || null });
|
||||
setSaved(true);
|
||||
onSaved?.();
|
||||
if (!bare) {
|
||||
setEditing(false);
|
||||
setSelected(null);
|
||||
setComment('');
|
||||
await refreshProfile();
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
@@ -86,12 +98,49 @@ export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: ()
|
||||
);
|
||||
|
||||
if (bare) return <View style={{ gap: space.md }}>{body}</View>;
|
||||
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.clearFeatured();
|
||||
setSaved(false);
|
||||
await refreshProfile();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (current && !editing) {
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<View style={styles.result}>
|
||||
{current.track.artworkUrl && <Image source={current.track.artworkUrl} style={styles.thumb} />}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{current.track.title}
|
||||
</Text>
|
||||
<Body muted style={styles.small}>
|
||||
{current.track.artist}
|
||||
</Body>
|
||||
</View>
|
||||
</View>
|
||||
{current.comment ? <Body style={styles.small}>“{current.comment}”</Body> : null}
|
||||
<Row>
|
||||
<Button small title="Change" onPress={() => (setEditing(true), setComment(current.comment ?? ''))} />
|
||||
<Button small title="Remove" loading={busy} onPress={remove} />
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Pin a song to your profile, with an optional note about why.
|
||||
</Body>
|
||||
{current && <Button small title={`Keep ${current.track.title}`} onPress={() => setEditing(false)} style={{ alignSelf: 'flex-start' }} />}
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -132,6 +132,38 @@ export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(
|
||||
);
|
||||
});
|
||||
|
||||
/** A settings row: label, optional value, chevron. `first` drops the divider above it. */
|
||||
export function ListRow({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
danger,
|
||||
first,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
onPress: () => void;
|
||||
danger?: boolean;
|
||||
first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.listRow, !first && styles.listDivider, pressed && { backgroundColor: 'rgba(255,255,255,0.05)' }]}>
|
||||
<Text style={[styles.listLabel, danger && { color: colors.danger }]}>{label}</Text>
|
||||
{value ? (
|
||||
<Text style={styles.listValue} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ flex: 1 }} />
|
||||
)}
|
||||
{!danger && <Text style={styles.listChevron}>›</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
|
||||
const press = usePressScale(0.92);
|
||||
return (
|
||||
@@ -190,6 +222,11 @@ const styles = StyleSheet.create({
|
||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
buttonTextSmall: { fontSize: 13 },
|
||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||
listRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: 13, paddingHorizontal: 2, borderRadius: radius.sm },
|
||||
listDivider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
||||
listLabel: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
listValue: { flex: 1, textAlign: 'right', color: colors.muted, fontSize: 14 },
|
||||
listChevron: { color: colors.muted, fontSize: 20, lineHeight: 20 },
|
||||
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
|
||||
revealText: { color: colors.accent2, fontWeight: '600', fontSize: 14 },
|
||||
input: {
|
||||
|
||||
@@ -128,6 +128,18 @@ input:focus, textarea:focus, select:focus {
|
||||
.emoji-grid button { all: unset; cursor: pointer; font-size: 1.55rem; text-align: center; padding: 0.2rem; border-radius: 0.5rem; }
|
||||
.emoji-grid button:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
@media (prefers-reduced-motion: reduce) { .reaction-quick { animation: none; } }
|
||||
.list { display: flex; flex-direction: column; margin: 0.25rem -0.4rem 0; }
|
||||
.list-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 0.75rem; padding: 0.8rem 0.4rem; border-top: 1px solid var(--border); cursor: pointer; border-radius: 0.5rem; transition: background 0.15s; }
|
||||
.list-row:first-child { border-top: 0; }
|
||||
.list-row:hover, .list-row:focus-visible { background: rgba(255, 255, 255, 0.05); }
|
||||
.list-label { font-weight: 600; flex-shrink: 0; }
|
||||
.list-value { flex: 1; min-width: 0; text-align: right; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-chevron { color: var(--muted); font-size: 1.2rem; line-height: 1; transition: transform 0.2s; margin-left: auto; }
|
||||
.list-value + .list-chevron { margin-left: 0; }
|
||||
.list-chevron.open { transform: rotate(90deg); }
|
||||
.list-row.danger .list-label { color: var(--danger); }
|
||||
.list-panel { padding: 0.25rem 0.4rem 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.list-message { margin: 0.5rem 0 0; }
|
||||
.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; }
|
||||
@@ -151,8 +163,27 @@ input:focus, textarea:focus, select:focus {
|
||||
.nav-links a { text-decoration: none; color: var(--muted); transition: color 0.2s; }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-links a.btn-primary { color: var(--accent-text); }
|
||||
.nav-links a.support-link { color: #f9a8d4; }
|
||||
@media (max-width: 480px) { .nav-links a.support-link { display: none; } }
|
||||
.nav-links a.active { color: var(--text); font-weight: 600; }
|
||||
/* Phones: main places live in a bottom tab bar, like a native app. */
|
||||
.tabbar { display: none; }
|
||||
@media (max-width: 640px) {
|
||||
/* Signed in, the tab bar has the links; the top bar is just the logo. */
|
||||
.nav:has(+ .tabbar) { justify-content: center; }
|
||||
.nav:has(+ .tabbar) .nav-links { display: none; }
|
||||
.tabbar {
|
||||
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
||||
background: rgba(7, 7, 11, 0.82); backdrop-filter: blur(18px) saturate(1.4); border-top: 1px solid var(--border);
|
||||
}
|
||||
.tabbar a {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding: 0.35rem 0;
|
||||
color: var(--muted); text-decoration: none; font-size: 0.7rem; font-weight: 600; transition: color 0.2s;
|
||||
}
|
||||
.tabbar a.active { color: var(--text); }
|
||||
.tabbar a.active svg { color: #8b9dff; }
|
||||
body:has(.tabbar) .container { padding-bottom: 6.5rem; }
|
||||
body:has(.tabbar) .footer { padding-bottom: 6rem; }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||
|
||||
+271
-175
@@ -12,13 +12,14 @@ import {
|
||||
type ConnectionDTO,
|
||||
type MeDTO,
|
||||
type NowPlayingProvider,
|
||||
type ProfileDTO,
|
||||
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 { DONATION_URL } from '@/components/SupportLink';
|
||||
import { api, client, fetcher, uploadFile, useMe } from '@/lib/api';
|
||||
import { timeAgo } from '@musicshare/shared';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
@@ -40,13 +41,10 @@ function Settings() {
|
||||
<div className="stack">
|
||||
<h1>Settings</h1>
|
||||
<ProfileForm me={me} />
|
||||
<AccountSettings me={me} />
|
||||
<SharingControls />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<BlockedAccounts />
|
||||
<SupportCard />
|
||||
<DangerZone />
|
||||
<AccountCard me={me} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,155 +147,6 @@ function AvatarPicker({ me }: { me: MeDTO }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSettings({ me }: { me: MeDTO }) {
|
||||
const { mutate } = useMe();
|
||||
const [editing, setEditing] = useState<'email' | 'password' | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, done: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
await mutate();
|
||||
setEditing(null);
|
||||
setMessage(done);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
const field = (f: FormData, k: string) => String(f.get(k) ?? '');
|
||||
|
||||
return (
|
||||
<section className="card form">
|
||||
<h2>Account</h2>
|
||||
<div>
|
||||
<div className="muted">Email</div>
|
||||
<div>
|
||||
{me.email}{' '}
|
||||
{me.emailVerified ? <span className="chip-small">Confirmed</span> : <span className="chip-small danger">Not confirmed</span>}
|
||||
</div>
|
||||
{me.pendingEmail && (
|
||||
<p className="muted">
|
||||
Changing to <strong>{me.pendingEmail}</strong>: open the link we sent there.{' '}
|
||||
<button type="button" className="link-button" onClick={() => run(() => client.cancelEmailChange(), 'Email change cancelled.')}>
|
||||
Cancel
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{!me.emailVerified && !me.pendingEmail && (
|
||||
<button type="button" className="link-button" onClick={() => run(() => client.resendVerification(), `Sent a new link to ${me.email}.`)}>
|
||||
Send the confirmation link again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing === 'email' ? (
|
||||
<form
|
||||
className="form"
|
||||
onSubmit={(e) => {
|
||||
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.`);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
New email
|
||||
<input name="email" type="email" required autoComplete="email" />
|
||||
</label>
|
||||
<label>
|
||||
Your password
|
||||
<input name="password" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
||||
</label>
|
||||
<ShowPassword show={show} setShow={setShow} />
|
||||
<FormButtons label="Send confirmation link" onCancel={() => setEditing(null)} />
|
||||
</form>
|
||||
) : editing === 'password' ? (
|
||||
<form
|
||||
className="form"
|
||||
onSubmit={(e) => {
|
||||
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.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Current password
|
||||
<input name="current" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input name="new" type={show ? 'text' : 'password'} required minLength={8} maxLength={200} autoComplete="new-password" />
|
||||
</label>
|
||||
<ShowPassword show={show} setShow={setShow} />
|
||||
<FormButtons label="Change password" onCancel={() => setEditing(null)} />
|
||||
</form>
|
||||
) : (
|
||||
<div className="row">
|
||||
<button type="button" className="btn" onClick={() => (setMessage(null), setEditing('email'))}>
|
||||
Change email
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => (setMessage(null), setEditing('password'))}>
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && <p className="muted">{message}</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={show} onChange={(e) => setShow(e.target.checked)} /> Show passwords
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<button className="btn btn-primary">{label}</button>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
const params = useSearchParams();
|
||||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
|
||||
@@ -507,6 +356,9 @@ function WebhookHint({ connection }: { connection: ConnectionDTO | undefined })
|
||||
|
||||
function FeaturedPicker() {
|
||||
const { me } = useMe();
|
||||
const { data: profile, mutate: refreshProfile } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null, fetcher);
|
||||
const current = profile?.featured ?? null;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(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 (
|
||||
<section className="card form">
|
||||
<h2>Featured song</h2>
|
||||
<div className="picked">
|
||||
{current.track.artworkUrl && <img className="artwork-sm" src={current.track.artworkUrl} alt="" />}
|
||||
<div className="grow">
|
||||
<div className="track-title">{current.track.title}</div>
|
||||
<div className="muted">{current.track.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
{current.comment && <blockquote>{current.comment}</blockquote>}
|
||||
<div className="row">
|
||||
<button className="btn" onClick={() => (setEditing(true), setComment(current.comment ?? ''))}>
|
||||
Change
|
||||
</button>
|
||||
<button className="btn" onClick={remove}>
|
||||
Remove
|
||||
</button>
|
||||
{saved && <span className="muted">Featured ✓</span>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card form">
|
||||
<h2>Featured song</h2>
|
||||
<p className="muted">Pin a song to your profile, with an optional note about why.</p>
|
||||
{current && (
|
||||
<button className="link-button" onClick={() => setEditing(false)}>
|
||||
← Keep {current.track.title}
|
||||
</button>
|
||||
)}
|
||||
{selected ? (
|
||||
<div className="picked">
|
||||
{selected.artworkUrl && <img className="artwork-sm" src={selected.artworkUrl} alt="" />}
|
||||
@@ -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<Panel>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
const { data: blocked = [], mutate: refreshBlocked } = useSWR<UserSummaryDTO[]>('/me/blocks', fetcher);
|
||||
|
||||
const toggle = (panel: Panel) => {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setOpen((p) => (p === panel ? null : panel));
|
||||
};
|
||||
const run = async (fn: () => Promise<unknown>, 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 (
|
||||
<section className="card">
|
||||
<h2>Delete account</h2>
|
||||
<p className="muted">Permanently removes your profile, follows and connected services.</p>
|
||||
{confirming ? (
|
||||
<div className="row">
|
||||
<button className="btn btn-danger" onClick={remove}>
|
||||
Yes, delete my account
|
||||
</button>
|
||||
<button className="btn" onClick={() => setConfirming(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn" onClick={() => setConfirming(true)}>
|
||||
Delete account…
|
||||
</button>
|
||||
)}
|
||||
<h2>Account</h2>
|
||||
<div className="list">
|
||||
<Row
|
||||
label="Email"
|
||||
value={
|
||||
<>
|
||||
{me.email} {me.emailVerified ? null : <span className="chip-small danger">Not confirmed</span>}
|
||||
</>
|
||||
}
|
||||
open={open === 'email'}
|
||||
onClick={() => toggle('email')}
|
||||
/>
|
||||
{open === 'email' && (
|
||||
<div className="list-panel">
|
||||
{me.pendingEmail && (
|
||||
<p className="muted">
|
||||
Changing to <strong>{me.pendingEmail}</strong>: open the link we sent there.{' '}
|
||||
<button type="button" className="link-button" onClick={() => run(() => client.cancelEmailChange(), 'Email change cancelled.')}>
|
||||
Cancel the change
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{!me.emailVerified && !me.pendingEmail && (
|
||||
<p className="muted">
|
||||
Not confirmed yet.{' '}
|
||||
<button type="button" className="link-button" onClick={() => run(() => client.resendVerification(), `Sent a new link to ${me.email}.`)}>
|
||||
Send the link again
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
className="form"
|
||||
onSubmit={(e) => {
|
||||
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.`);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
New email
|
||||
<input name="email" type="email" required autoComplete="email" />
|
||||
</label>
|
||||
<label>
|
||||
Your password
|
||||
<input name="password" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
||||
</label>
|
||||
<ShowPassword show={show} setShow={setShow} />
|
||||
<FormButtons label="Send confirmation link" onCancel={() => setOpen(null)} />
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row label="Password" value="Change" open={open === 'password'} onClick={() => toggle('password')} />
|
||||
{open === 'password' && (
|
||||
<form
|
||||
className="form list-panel"
|
||||
onSubmit={(e) => {
|
||||
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.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Current password
|
||||
<input name="current" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input name="new" type={show ? 'text' : 'password'} required minLength={8} maxLength={200} autoComplete="new-password" />
|
||||
</label>
|
||||
<ShowPassword show={show} setShow={setShow} />
|
||||
<FormButtons label="Change password" onCancel={() => setOpen(null)} />
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Row label="Blocked accounts" value={blocked.length ? String(blocked.length) : 'None'} open={open === 'blocked'} onClick={() => toggle('blocked')} />
|
||||
{open === 'blocked' && (
|
||||
<div className="list-panel">
|
||||
{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={32} />
|
||||
<span className="user-name">{u.displayName ?? u.username}</span>
|
||||
</Link>
|
||||
<button className="btn btn-small" onClick={async () => (await client.unblock(u.username), refreshBlocked())}>
|
||||
Unblock
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{me.role === 'admin' && <Row label="Admin" value="Reports and accounts" href="/admin" />}
|
||||
{DONATION_URL && <Row label="♥ Support musicshare" value="Donate" href={DONATION_URL} external />}
|
||||
<Row label="Privacy policy" href="/privacy" />
|
||||
<Row label="Terms of use" href="/terms" />
|
||||
|
||||
<Row label="Log out" onClick={logout} />
|
||||
<Row label="Delete account" danger open={open === 'delete'} onClick={() => toggle('delete')} />
|
||||
{open === 'delete' && (
|
||||
<div className="list-panel">
|
||||
<p className="muted">This permanently removes your profile, picture, plays, follows and connected services.</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-danger" onClick={remove}>
|
||||
Yes, delete my account
|
||||
</button>
|
||||
<button className="btn" onClick={() => setOpen(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message && <p className="muted list-message">{message}</p>}
|
||||
{error && <p className="error list-message">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 = (
|
||||
<>
|
||||
<span className="list-label">{label}</span>
|
||||
{value != null && <span className="list-value">{value}</span>}
|
||||
<span className={`list-chevron ${open ? 'open' : ''}`} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cls = `list-row ${danger ? 'danger' : ''}`;
|
||||
if (href) {
|
||||
return external ? (
|
||||
<a className={cls} href={href} target="_blank" rel="noreferrer">
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<Link className={cls} href={href}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cls} onClick={onClick} aria-expanded={open}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={show} onChange={(e) => setShow(e.target.checked)} /> Show passwords
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<button className="btn btn-primary">{label}</button>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: <FeedIcon />, active: path === '/' },
|
||||
{ href: `/u/${me.username}`, label: 'Profile', icon: <ProfileIcon />, active: path.startsWith(`/u/${me.username}`) },
|
||||
{ href: '/settings', label: 'Settings', icon: <SettingsIcon />, active: path.startsWith('/settings') || path === '/admin' },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="nav">
|
||||
<Link href="/" className="brand">
|
||||
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
|
||||
musicshare
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<SupportLink />
|
||||
{me ? (
|
||||
<>
|
||||
<Link href="/">Feed</Link>
|
||||
<Link href={`/u/${me.username}`}>Profile</Link>
|
||||
<Link href="/settings">Settings</Link>
|
||||
{me.role === 'admin' && <Link href="/admin">Admin</Link>}
|
||||
<button className="link-button" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">Log in</Link>
|
||||
<Link href="/register" className="btn btn-primary">
|
||||
Sign up
|
||||
<nav className="nav">
|
||||
<Link href="/" className="brand">
|
||||
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
|
||||
musicshare
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
{me ? (
|
||||
tabs.map((t) => (
|
||||
<Link key={t.href} href={t.href} className={t.active ? 'active' : ''} aria-current={t.active ? 'page' : undefined}>
|
||||
{t.label}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">Log in</Link>
|
||||
<Link href="/register" className="btn btn-primary">
|
||||
Sign up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
{me && (
|
||||
<nav className="tabbar" aria-label="Main">
|
||||
{tabs.map((t) => (
|
||||
<Link key={t.href} href={t.href} className={t.active ? 'active' : ''} aria-current={t.active ? 'page' : undefined}>
|
||||
{t.icon}
|
||||
<span>{t.label}</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
{me && !me.emailVerified && <VerifyBanner email={me.pendingEmail ?? me.email} />}
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{me && !me.emailVerified && <VerifyBanner email={me.pendingEmail ?? me.email} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +70,34 @@ function VerifyBanner({ email }: { email: string }) {
|
||||
return (
|
||||
<div className="banner">
|
||||
Confirm your email: we sent a link to <strong>{email}</strong>.{' '}
|
||||
{sent ? <span className="muted">Sent again.</span> : <button className="link-button" onClick={resend}>Send it again</button>}
|
||||
{sent ? (
|
||||
<span className="muted">Sent again.</span>
|
||||
) : (
|
||||
<button className="link-button" onClick={resend}>
|
||||
Send it again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
const ProfileIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21c0-4 3.6-7 8-7s8 3 8 7" />
|
||||
</svg>
|
||||
);
|
||||
const SettingsIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user