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:
@@ -0,0 +1,136 @@
|
||||
:root {
|
||||
--bg: #0e0f13;
|
||||
--surface: #171920;
|
||||
--surface-2: #20232c;
|
||||
--border: #2a2e39;
|
||||
--text: #eceef3;
|
||||
--muted: #9097a6;
|
||||
--accent: #7c5cff;
|
||||
--accent-text: #fff;
|
||||
--live: #3ddc84;
|
||||
--danger: #ff5c6c;
|
||||
--radius: 14px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
a { color: inherit; }
|
||||
h1 { font-size: 1.6rem; margin: 0; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 0.5rem; }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.container { max-width: 680px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||||
.stack { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: var(--danger); margin: 0; }
|
||||
.notice { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; margin: 0; }
|
||||
.narrow { max-width: 420px; margin: 2rem auto; }
|
||||
|
||||
.nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 800; letter-spacing: -0.02em; text-decoration: none; font-size: 1.1rem; }
|
||||
.nav-links { display: flex; gap: 1rem; align-items: center; }
|
||||
.nav-links a { text-decoration: none; color: var(--muted); }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-links a.btn-primary { color: var(--accent-text); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 0.5rem 0.95rem; border-radius: 999px; border: 1px solid var(--border);
|
||||
background: var(--surface-2); color: var(--text); font: inherit; font-weight: 600;
|
||||
text-decoration: none; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--muted); }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.btn-primary:hover { border-color: var(--accent); filter: brightness(1.1); }
|
||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.link-button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; }
|
||||
.link-button:hover { color: var(--text); }
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
|
||||
.card-pinned { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
|
||||
.pin { background: none; border: 0; cursor: pointer; font-size: 1.15rem; line-height: 1; color: var(--muted); padding: 0.1rem 0.2rem; }
|
||||
.pin:hover, .pin.pinned { color: #f5c542; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
|
||||
.form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.form label { display: flex; flex-direction: column; gap: 0.3rem; font-weight: 600; font-size: 0.9rem; }
|
||||
|
||||
.user-chip { display: inline-flex; align-items: center; gap: 0.5rem; text-decoration: none; font-weight: 600; }
|
||||
.avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.avatar-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); font-weight: 700; }
|
||||
|
||||
.status { font-size: 0.85rem; color: var(--muted); }
|
||||
.status-live { color: var(--live); }
|
||||
.status-live::before {
|
||||
content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--live); margin-right: 6px; animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
|
||||
.track { display: flex; gap: 1rem; align-items: flex-start; }
|
||||
.artwork { width: 104px; height: 104px; border-radius: 10px; object-fit: cover; flex-shrink: 0; background: var(--surface-2); }
|
||||
.artwork-empty { display: grid; place-items: center; font-size: 2rem; color: var(--muted); }
|
||||
.artwork-sm { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
|
||||
.track-body { min-width: 0; flex: 1; }
|
||||
.track-title { font-weight: 700; font-size: 1.05rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track-artist { color: var(--text); opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; align-items: center; margin-top: 0.65rem; }
|
||||
.service-link { font-size: 0.8rem; color: var(--muted); text-decoration: none; }
|
||||
.service-link:hover { color: var(--text); text-decoration: underline; }
|
||||
.service-link.inexact { opacity: 0.6; }
|
||||
|
||||
.profile-header { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
||||
.profile-header p { margin: 0.4rem 0 0; }
|
||||
.featured blockquote { margin: 0.85rem 0 0; padding-left: 0.85rem; border-left: 3px solid var(--accent); font-style: italic; }
|
||||
|
||||
.search { position: relative; }
|
||||
.search-results { list-style: none; margin: 0.4rem 0 0; padding: 0.35rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.search-results li { padding: 0.35rem 0.4rem; }
|
||||
.result { display: flex; gap: 0.6rem; align-items: center; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; width: 100%; text-align: left; padding: 0; }
|
||||
.result:hover { color: var(--accent); }
|
||||
.picked { display: flex; gap: 0.75rem; align-items: center; }
|
||||
|
||||
.connections { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem; }
|
||||
.connection { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.75rem 0; border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.connection form input { width: 160px; }
|
||||
.connection.disabled { opacity: 0.55; }
|
||||
.jellyfin-form { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 0.6rem; }
|
||||
.jellyfin-form input[name='serverUrl'] { grid-column: 1 / -1; }
|
||||
.jellyfin-form .btn { justify-self: start; }
|
||||
.webhook { margin-top: 0.4rem; font-size: 0.85rem; }
|
||||
.webhook summary { cursor: pointer; color: var(--muted); }
|
||||
.webhook p { margin: 0.35rem 0; }
|
||||
.webhook code { font-size: 0.75rem; background: var(--surface-2); padding: 0.3rem 0.5rem; border-radius: 6px; word-break: break-all; flex: 1; min-width: 0; }
|
||||
.btn-small { padding: 0.25rem 0.65rem; font-size: 0.8rem; }
|
||||
@media (max-width: 480px) { .jellyfin-form { grid-template-columns: 1fr; } }
|
||||
|
||||
.hero { padding: 3rem 0; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.hero h1 { font-size: 2.4rem; letter-spacing: -0.03em; line-height: 1.1; }
|
||||
.hero p { color: var(--muted); font-size: 1.1rem; margin: 0; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.artwork { width: 76px; height: 76px; }
|
||||
.nav-links { gap: 0.7rem; font-size: 0.9rem; }
|
||||
.hero h1 { font-size: 1.9rem; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Nav } from '@/components/Nav';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'musicshare',
|
||||
description: "See what your friends are listening to, and play it on your own service.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Nav />
|
||||
<main className="container">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from '@/components/AuthForm';
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthForm mode="login" />;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import useSWR from 'swr';
|
||||
import { sortFeed, type FeedItemDTO } from '@musicshare/shared';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { UserSearch } from '@/components/UserSearch';
|
||||
import { api, fetcher, useMe } from '@/lib/api';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
|
||||
export default function Home() {
|
||||
const { me, isLoading, loggedOut } = useMe();
|
||||
if (isLoading) return null;
|
||||
if (loggedOut || !me) return <Landing />;
|
||||
return <Feed />;
|
||||
}
|
||||
|
||||
function Feed() {
|
||||
const { me } = useMe();
|
||||
const { data: feed, mutate } = useSWR<FeedItemDTO[]>('/feed', fetcher);
|
||||
|
||||
useRealtime(true, (msg) => {
|
||||
if (msg.type !== 'now_playing' || msg.data.user.id === me?.id) return;
|
||||
mutate(
|
||||
(current = []) => {
|
||||
const existing = current.find((n) => n.user.id === msg.data.user.id);
|
||||
const item = { ...msg.data, pinnedAt: existing?.pinnedAt ?? null };
|
||||
return sortFeed([item, ...current.filter((n) => n.user.id !== msg.data.user.id)]);
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
});
|
||||
|
||||
const togglePin = async (item: FeedItemDTO) => {
|
||||
const pinnedAt = item.pinnedAt ? null : new Date().toISOString();
|
||||
// optimistic: reorder immediately, roll back if the server refuses (e.g. pin limit)
|
||||
const previous = feed;
|
||||
mutate(sortFeed((feed ?? []).map((n) => (n.user.id === item.user.id ? { ...n, pinnedAt } : n))), { revalidate: false });
|
||||
try {
|
||||
await api(`/users/${item.user.username}/pin`, { method: pinnedAt ? 'PUT' : 'DELETE', body: pinnedAt ? {} : undefined });
|
||||
} catch (err) {
|
||||
mutate(previous, { revalidate: false });
|
||||
alert((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<UserSearch />
|
||||
<h1>Friends listening</h1>
|
||||
{!me?.preferredService && (
|
||||
<p className="notice">
|
||||
Pick your streaming service in <Link href="/settings">Settings</Link> so songs open in the right app.
|
||||
</p>
|
||||
)}
|
||||
{feed?.length === 0 && (
|
||||
<p className="muted">Nothing yet. Follow some friends and their music will show up here live.</p>
|
||||
)}
|
||||
{feed?.map((np) => (
|
||||
<NowPlayingCard
|
||||
key={np.user.id}
|
||||
np={np}
|
||||
preferred={me?.preferredService}
|
||||
pinned={Boolean(np.pinnedAt)}
|
||||
onTogglePin={() => togglePin(np)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Landing() {
|
||||
return (
|
||||
<section className="hero">
|
||||
<h1>See what your friends are playing.</h1>
|
||||
<p>
|
||||
Connect Spotify, Last.fm, ListenBrainz and more. When a friend on Apple Music is playing something you like,
|
||||
one tap opens it in your app.
|
||||
</p>
|
||||
<div className="row">
|
||||
<Link href="/register" className="btn btn-primary">
|
||||
Create account
|
||||
</Link>
|
||||
<Link href="/login" className="btn">
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from '@/components/AuthForm';
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <AuthForm mode="register" />;
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { ProfileDTO } from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { TrackCard } from '@/components/TrackCard';
|
||||
import { api, fetcher, useMe } from '@/lib/api';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { username } = useParams<{ username: string }>();
|
||||
const { me } = useMe();
|
||||
const { data: profile, error, mutate } = useSWR<ProfileDTO>(`/users/${username}`, fetcher);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Live updates arrive for people you follow (and yourself)
|
||||
useRealtime(Boolean(me), (msg) => {
|
||||
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
||||
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: false });
|
||||
}
|
||||
});
|
||||
|
||||
if (error) return <p className="muted">User not found.</p>;
|
||||
if (!profile) return null;
|
||||
|
||||
const togglePin = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/users/${username}/pin`, { method: profile.isPinned ? 'DELETE' : 'PUT', body: profile.isPinned ? undefined : {} });
|
||||
await mutate();
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const toggleFollow = async () => {
|
||||
setBusy(true);
|
||||
await api(`/users/${username}/follow`, { method: profile.isFollowing ? 'DELETE' : 'POST', body: {} });
|
||||
await mutate();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const { user } = profile;
|
||||
const preferred = me?.preferredService;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="profile-header">
|
||||
<Avatar user={user} size={72} />
|
||||
<div className="grow">
|
||||
<h1>{user.displayName ?? user.username}</h1>
|
||||
<div className="muted">
|
||||
@{user.username} · {profile.followerCount} followers · {profile.followingCount} following
|
||||
</div>
|
||||
{user.bio && <p>{user.bio}</p>}
|
||||
</div>
|
||||
{profile.isSelf ? (
|
||||
<Link href="/settings" className="btn">
|
||||
Edit profile
|
||||
</Link>
|
||||
) : me ? (
|
||||
<div className="row">
|
||||
{profile.isFollowing && (
|
||||
<button className="btn" onClick={togglePin} disabled={busy}>
|
||||
{profile.isPinned ? '★ Pinned' : '☆ Pin'}
|
||||
</button>
|
||||
)}
|
||||
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
|
||||
{profile.isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/register" className="btn btn-primary">
|
||||
Sign up to follow
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{profile.featured && (
|
||||
<section className="card featured">
|
||||
<h2>Featured song</h2>
|
||||
<TrackCard track={profile.featured.track} preferred={preferred} />
|
||||
{profile.featured.comment && <blockquote>{profile.featured.comment}</blockquote>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{profile.nowPlaying ? (
|
||||
<NowPlayingCard np={profile.nowPlaying} preferred={preferred} showUser={false} />
|
||||
) : (
|
||||
<p className="muted">Nothing playing right now.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { api, useMe } from '@/lib/api';
|
||||
|
||||
export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
||||
const router = useRouter();
|
||||
const { mutate } = useMe();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body = Object.fromEntries(new FormData(e.currentTarget));
|
||||
try {
|
||||
const { user } = await api<{ user: unknown }>(`/auth/${mode}`, { body });
|
||||
await mutate(user as never, { revalidate: false });
|
||||
router.push(mode === 'register' ? '/settings' : '/');
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="card form narrow" onSubmit={onSubmit}>
|
||||
<h1>{mode === 'login' ? 'Log in' : 'Create your account'}</h1>
|
||||
{mode === 'register' && (
|
||||
<label>
|
||||
Username
|
||||
<input name="username" required minLength={3} maxLength={32} pattern="[A-Za-z0-9_]+" autoComplete="username" />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Email
|
||||
<input name="email" type="email" required autoComplete="email" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={mode === 'register' ? 8 : 1}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="btn btn-primary" disabled={busy}>
|
||||
{mode === 'login' ? 'Log in' : 'Sign up'}
|
||||
</button>
|
||||
<p className="muted">
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
New here? <Link href="/register">Create an account</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account? <Link href="/login">Log in</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
|
||||
export function Avatar({ user, size = 40 }: { user: UserSummaryDTO; size?: number }) {
|
||||
const label = (user.displayName ?? user.username).slice(0, 1).toUpperCase();
|
||||
return user.avatarUrl ? (
|
||||
<img className="avatar" src={user.avatarUrl} alt="" style={{ width: size, height: size }} />
|
||||
) : (
|
||||
<div className="avatar avatar-empty" style={{ width: size, height: size, fontSize: size * 0.45 }}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, useMe } from '@/lib/api';
|
||||
|
||||
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('/');
|
||||
};
|
||||
return (
|
||||
<nav className="nav">
|
||||
<Link href="/" className="brand">
|
||||
musicshare
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
{me ? (
|
||||
<>
|
||||
<Link href="/">Feed</Link>
|
||||
<Link href={`/u/${me.username}`}>Profile</Link>
|
||||
<Link href="/settings">Settings</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
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { SERVICE_LABELS, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { timeAgo } from '@musicshare/shared';
|
||||
import { Avatar } from './Avatar';
|
||||
import { TrackCard } from './TrackCard';
|
||||
|
||||
export function NowPlayingCard({
|
||||
np,
|
||||
preferred,
|
||||
showUser = true,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
np: NowPlayingDTO;
|
||||
preferred: StreamingService | null | undefined;
|
||||
showUser?: boolean;
|
||||
pinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className={`card ${pinned ? 'card-pinned' : ''}`}>
|
||||
<header className="card-header">
|
||||
{showUser && (
|
||||
<Link href={`/u/${np.user.username}`} className="user-chip">
|
||||
<Avatar user={np.user} size={28} />
|
||||
<span>{np.user.displayName ?? np.user.username}</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className="row">
|
||||
<span className={`status ${np.isPlaying ? 'status-live' : ''}`}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<span className="muted"> via {SERVICE_LABELS[np.source]}</span>
|
||||
</span>
|
||||
{onTogglePin && (
|
||||
<button
|
||||
className={`pin ${pinned ? 'pinned' : ''}`}
|
||||
onClick={onTogglePin}
|
||||
aria-pressed={pinned}
|
||||
title={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
|
||||
>
|
||||
{pinned ? '★' : '☆'}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</header>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { STREAMING_SERVICES, SERVICE_LABELS, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
|
||||
/**
|
||||
* Artwork + title, with a primary button for the viewer's preferred service
|
||||
* and quieter links for the rest. Links are https universal links, so on
|
||||
* mobile they open the native app when it's installed.
|
||||
*/
|
||||
export function TrackCard({
|
||||
track,
|
||||
preferred,
|
||||
compact,
|
||||
}: {
|
||||
track: TrackDTO;
|
||||
preferred: StreamingService | null | undefined;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
|
||||
return (
|
||||
<div className={`track ${compact ? 'track-compact' : ''}`}>
|
||||
{track.artworkUrl ? (
|
||||
<img className="artwork" src={track.artworkUrl} alt="" />
|
||||
) : (
|
||||
<div className="artwork artwork-empty">♪</div>
|
||||
)}
|
||||
<div className="track-body">
|
||||
<div className="track-title">{track.title}</div>
|
||||
<div className="track-artist">
|
||||
{track.artist}
|
||||
{track.album && <span className="muted"> · {track.album}</span>}
|
||||
</div>
|
||||
<div className="track-actions">
|
||||
{preferred && primary && (
|
||||
<a className="btn btn-primary" href={primary.url} target="_blank" rel="noreferrer">
|
||||
{primary.exact ? 'Open' : 'Search'} in {SERVICE_LABELS[preferred]}
|
||||
</a>
|
||||
)}
|
||||
{others.map((s) => {
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<a
|
||||
key={s}
|
||||
className={`service-link ${exact ? '' : 'inexact'}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={exact ? `Open in ${SERVICE_LABELS[s]}` : `Search ${SERVICE_LABELS[s]}`}
|
||||
>
|
||||
{SERVICE_LABELS[s]}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
import { fetcher } from '@/lib/api';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
export function UserSearch() {
|
||||
const [q, setQ] = useState('');
|
||||
const debounced = useDebounced(q.trim(), 250);
|
||||
const { data } = useSWR<UserSummaryDTO[]>(
|
||||
debounced.length >= 2 ? `/users/search?q=${encodeURIComponent(debounced)}` : null,
|
||||
fetcher,
|
||||
);
|
||||
return (
|
||||
<div className="search">
|
||||
<input placeholder="Find friends by username" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
{data && debounced.length >= 2 && (
|
||||
<ul className="search-results">
|
||||
{data.length === 0 && <li className="muted">No one found</li>}
|
||||
{data.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.username}`} className="user-chip">
|
||||
<Avatar user={u} size={28} />
|
||||
<span>{u.displayName ?? u.username}</span>
|
||||
<span className="muted">@{u.username}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import type { MeDTO } from '@musicshare/shared';
|
||||
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
||||
const res = await fetch(API_URL + path, {
|
||||
method: init.method ?? (init.body ? 'POST' : 'GET'),
|
||||
credentials: 'include',
|
||||
headers: init.body !== undefined ? { 'content-type': 'application/json' } : undefined,
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? res.statusText);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const fetcher = <T,>(path: string) => api<T>(path);
|
||||
|
||||
export function useMe() {
|
||||
const { data, error, isLoading, mutate } = useSWR<MeDTO, ApiError>('/me', fetcher, {
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
return { me: data, isLoading, loggedOut: error?.status === 401, mutate };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useEffectEvent } from 'react';
|
||||
import type { RealtimeMessage } from '@musicshare/shared';
|
||||
import { API_URL } from './api';
|
||||
|
||||
/** Subscribes to live now-playing updates for the signed-in user's follows. Reconnects with backoff. */
|
||||
export function useRealtime(enabled: boolean, onMessage: (msg: RealtimeMessage) => void) {
|
||||
const handleMessage = useEffectEvent(onMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let socket: WebSocket | null = null;
|
||||
let attempts = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let closed = false;
|
||||
|
||||
const connect = () => {
|
||||
socket = new WebSocket(API_URL.replace(/^http/, 'ws') + '/ws');
|
||||
socket.onopen = () => (attempts = 0);
|
||||
socket.onmessage = (e) => handleMessage(JSON.parse(e.data) as RealtimeMessage);
|
||||
socket.onclose = (e) => {
|
||||
if (closed || e.code === 4401) return;
|
||||
timer = setTimeout(connect, Math.min(30_000, 1000 * 2 ** attempts++));
|
||||
};
|
||||
};
|
||||
connect();
|
||||
return () => {
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user