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
+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>
);
}