- 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:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user