Tidy the header and Settings; remove a featured song
CI / check (push) Canceled after 0s

- 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:
2026-10-06 14:15:14 -04:00
co-authored by Claude Opus 5.5
parent 75062d3644
commit 601398acd6
6 changed files with 499 additions and 244 deletions
+34 -27
View File
@@ -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>
);
}
+51 -2
View File
@@ -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>
);
+37
View File
@@ -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: {
+33 -2
View File
@@ -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
View File
@@ -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&apos;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&apos;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>
);
}
+73 -38
View File
@@ -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>
);