Add Next.js web app

Feed with live updates and pinning, profiles with follow/pin, featured song
picker, settings for all music services, and account deletion.

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-03 09:59:02 -04:00
co-authored by Claude Opus 5.5
parent 6791b62d87
commit 5004beb10d
22 changed files with 1127 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState } from 'react';
import useSWR from 'swr';
import {
SERVICE_LABELS,
STREAMING_SERVICES,
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
type TrackSearchResultDTO,
} from '@musicshare/shared';
import { api, fetcher, useMe } from '@/lib/api';
import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced';
export default function SettingsPage() {
return (
<Suspense>
<Settings />
</Suspense>
);
}
function Settings() {
const { me, loggedOut } = useMe();
const router = useRouter();
if (loggedOut) router.replace('/login');
if (!me) return null;
return (
<div className="stack">
<h1>Settings</h1>
<ProfileForm me={me} />
<Connections />
<FeaturedPicker />
<DangerZone />
</div>
);
}
function ProfileForm({ me }: { me: MeDTO }) {
const { mutate } = useMe();
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
const str = (k: string) => (f.get(k) as string).trim() || null;
try {
const updated = await api<MeDTO>('/me', {
method: 'PATCH',
body: { displayName: str('displayName'), bio: str('bio'), preferredService: str('preferredService') },
});
await mutate(updated, { revalidate: false });
setError(null);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError((err as Error).message);
}
};
return (
<form className="card form" onSubmit={onSubmit}>
<h2>Profile</h2>
<label>
Display name
<input name="displayName" defaultValue={me.displayName ?? ''} maxLength={64} />
</label>
<label>
Bio
<textarea name="bio" defaultValue={me.bio ?? ''} maxLength={280} rows={2} />
</label>
<label>
Open songs in
<select name="preferredService" defaultValue={me.preferredService ?? ''}>
<option value="">Ask every time</option>
{STREAMING_SERVICES.map((s) => (
<option key={s} value={s}>
{SERVICE_LABELS[s]}
</option>
))}
</select>
</label>
{error && <p className="error">{error}</p>}
<button className="btn btn-primary">{saved ? 'Saved ✓' : 'Save'}</button>
</form>
);
}
function Connections() {
const params = useSearchParams();
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
const [error, setError] = useState<string | null>(params.get('error'));
const byProvider = new Map(connections.map((c) => [c.provider, c]));
const run = async (fn: () => Promise<unknown>) => {
setError(null);
try {
await fn();
await mutate();
} catch (err) {
setError((err as Error).message);
}
};
const connectOAuth = (provider: 'spotify' | 'plex') =>
run(async () => {
const { url } = await api<{ url: string }>(`/connections/${provider}/authorize`, { body: { client: 'web' } });
window.location.href = url;
});
const connectJellyfin = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const body = Object.fromEntries(new FormData(e.currentTarget));
run(() => api('/connections/jellyfin', { body }));
};
const connectUsername = (provider: 'lastfm' | 'listenbrainz') => (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const username = new FormData(e.currentTarget).get('username');
run(() => api(`/connections/${provider}`, { body: { username } }));
};
const disconnect = (provider: NowPlayingProvider) =>
run(() => api(`/connections/${provider}`, { method: 'DELETE' }));
const status = (p: NowPlayingProvider) => {
const c = byProvider.get(p);
if (!c) return null;
return (
<span className={c.status === 'error' ? 'error' : 'muted'}>
{c.status === 'error'
? `Needs reconnecting: ${c.lastError}`
: `Connected as ${c.displayName ?? c.externalUserId}${c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}`}
</span>
);
};
return (
<section className="card">
<h2>Music services</h2>
<p className="muted">Connect where you listen. Your friends see what you&apos;re playing in real time.</p>
{error && <p className="error">{error}</p>}
<div className="connections">
{(['spotify', 'plex'] as const).map((p) => (
<div className="connection" key={p}>
<div>
<strong>{SERVICE_LABELS[p]}</strong>
<div>{status(p) ?? (p === 'plex' && <span className="muted">Music you play from your Plex servers</span>)}</div>
<WebhookHint connection={byProvider.get(p)} />
</div>
{byProvider.get(p)?.status === 'active' ? (
<button className="btn" onClick={() => disconnect(p)}>
Disconnect
</button>
) : (
<button className="btn btn-primary" onClick={() => connectOAuth(p)}>
{byProvider.has(p) ? 'Reconnect' : 'Connect'}
</button>
)}
</div>
))}
<div className="connection">
<div className="grow">
<strong>Jellyfin</strong>
<div>{status('jellyfin') ?? <span className="muted">Sign in to your Jellyfin server. Your password isn&apos;t stored.</span>}</div>
<WebhookHint connection={byProvider.get('jellyfin')} />
{byProvider.get('jellyfin')?.status !== 'active' && (
<form className="jellyfin-form" onSubmit={connectJellyfin}>
<input name="serverUrl" placeholder="https://jellyfin.example.com" required type="url" />
<input name="username" placeholder="Username" required autoComplete="off" />
<input name="password" placeholder="Password" type="password" autoComplete="new-password" />
<button className="btn btn-primary">Connect</button>
</form>
)}
</div>
{byProvider.get('jellyfin')?.status === 'active' && (
<button className="btn" onClick={() => disconnect('jellyfin')}>
Disconnect
</button>
)}
</div>
{(['lastfm', 'listenbrainz'] as const).map((p) => (
<div className="connection" key={p}>
<div>
<strong>{SERVICE_LABELS[p]}</strong>
<div>
{status(p) ?? (
<span className="muted">
{p === 'lastfm'
? 'Works with anything that scrobbles: YouTube Music, Tidal, Deezer, SoundCloud…'
: 'Open-source scrobbling'}
</span>
)}
</div>
</div>
{byProvider.has(p) ? (
<button className="btn" onClick={() => disconnect(p)}>
Disconnect
</button>
) : (
<form className="row" onSubmit={connectUsername(p)}>
<input name="username" placeholder="Username" required />
<button className="btn btn-primary">Connect</button>
</form>
)}
</div>
))}
<div className="connection disabled">
<div>
<strong>Apple Music</strong>
<div className="muted">Coming with the iOS app</div>
</div>
</div>
</div>
</section>
);
}
const WEBHOOK_HELP: Partial<Record<NowPlayingProvider, string>> = {
plex: 'Plex Pass: add this under Plex → Settings → Webhooks for instant updates.',
jellyfin: 'Optional: add this URL in the Jellyfin Webhook plugin (Playback Start/Stop) for instant updates.',
};
function WebhookHint({ connection }: { connection: ConnectionDTO | undefined }) {
const [copied, setCopied] = useState(false);
if (!connection?.webhookUrl || connection.status !== 'active') return null;
const copy = async () => {
await navigator.clipboard.writeText(connection.webhookUrl!);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<details className="webhook">
<summary>Instant updates</summary>
<p className="muted">{WEBHOOK_HELP[connection.provider]}</p>
<div className="row">
<code>{connection.webhookUrl}</code>
<button type="button" className="btn btn-small" onClick={copy}>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</details>
);
}
function FeaturedPicker() {
const { me } = useMe();
const [q, setQ] = useState('');
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
const [comment, setComment] = useState('');
const [saved, setSaved] = useState(false);
const debounced = useDebounced(q.trim(), 300);
const { data: results } = useSWR<TrackSearchResultDTO[]>(
debounced.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(debounced)}` : null,
fetcher,
);
const save = async () => {
if (!selected) return;
await api('/me/featured', { method: 'PUT', body: { deezerId: selected.deezerId, comment: comment || null } });
setSaved(true);
};
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>
{selected ? (
<div className="picked">
{selected.artworkUrl && <img className="artwork-sm" src={selected.artworkUrl} alt="" />}
<div className="grow">
<div className="track-title">{selected.title}</div>
<div className="muted">{selected.artist}</div>
</div>
<button
className="link-button"
onClick={() => {
setSelected(null);
setSaved(false);
}}
>
Change
</button>
</div>
) : (
<>
<input placeholder="Search for a song" value={q} onChange={(e) => setQ(e.target.value)} />
{results && (
<ul className="search-results">
{results.map((r) => (
<li key={r.deezerId}>
<button className="result" onClick={() => setSelected(r)}>
{r.artworkUrl && <img className="artwork-sm" src={r.artworkUrl} alt="" />}
<span>
{r.title} <span className="muted">· {r.artist}</span>
</span>
</button>
</li>
))}
</ul>
)}
</>
)}
{selected && (
<>
<textarea
placeholder="Say something about it (optional)"
maxLength={280}
rows={2}
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<div className="row">
<button className="btn btn-primary" onClick={save}>
{saved ? 'Featured ✓' : 'Feature this song'}
</button>
{saved && me && (
<a href={`/u/${me.username}`} className="muted">
View profile →
</a>
)}
</div>
</>
)}
</section>
);
}
function DangerZone() {
const [confirming, setConfirming] = useState(false);
const router = useRouter();
const { mutate } = useMe();
const remove = async () => {
await api('/me', { method: 'DELETE' });
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>
)}
</section>
);
}