diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 0000000..3eec15e
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1 @@
+NEXT_PUBLIC_API_URL=http://localhost:4000
diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md
new file mode 100644
index 0000000..643577d
--- /dev/null
+++ b/apps/web/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/apps/web/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
new file mode 100644
index 0000000..764d8b8
--- /dev/null
+++ b/apps/web/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from 'next';
+
+const config: NextConfig = {
+ transpilePackages: ['@musicshare/shared'],
+};
+
+export default config;
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..a01e02d
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@musicshare/web",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev --port 3737",
+ "build": "next build",
+ "start": "next start --port 3737",
+ "typecheck": "tsc -p ."
+ },
+ "devDependencies": {
+ "@types/react": "~19.2.2",
+ "@types/react-dom": "~19.2.2",
+ "typescript": "~6.0.3"
+ },
+ "dependencies": {
+ "next": "^16.3.8",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "swr": "^2.5.1",
+ "@musicshare/shared": "*"
+ }
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
new file mode 100644
index 0000000..6ff83e6
--- /dev/null
+++ b/apps/web/src/app/globals.css
@@ -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; }
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
new file mode 100644
index 0000000..a23714e
--- /dev/null
+++ b/apps/web/src/app/layout.tsx
@@ -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 (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
new file mode 100644
index 0000000..df66bd3
--- /dev/null
+++ b/apps/web/src/app/login/page.tsx
@@ -0,0 +1,5 @@
+import { AuthForm } from '@/components/AuthForm';
+
+export default function LoginPage() {
+ return ;
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
new file mode 100644
index 0000000..687d377
--- /dev/null
+++ b/apps/web/src/app/page.tsx
@@ -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 ;
+ return ;
+}
+
+function Feed() {
+ const { me } = useMe();
+ const { data: feed, mutate } = useSWR('/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 (
+
+
+
Friends listening
+ {!me?.preferredService && (
+
+ Pick your streaming service in Settings so songs open in the right app.
+
+ )}
+ {feed?.length === 0 && (
+
Nothing yet. Follow some friends and their music will show up here live.
+ )}
+ {feed?.map((np) => (
+
togglePin(np)}
+ />
+ ))}
+
+ );
+}
+
+function Landing() {
+ return (
+
+ See what your friends are playing.
+
+ 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.
+
+
+
+ Create account
+
+
+ Log in
+
+
+
+ );
+}
diff --git a/apps/web/src/app/register/page.tsx b/apps/web/src/app/register/page.tsx
new file mode 100644
index 0000000..3b20e61
--- /dev/null
+++ b/apps/web/src/app/register/page.tsx
@@ -0,0 +1,5 @@
+import { AuthForm } from '@/components/AuthForm';
+
+export default function RegisterPage() {
+ return ;
+}
diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx
new file mode 100644
index 0000000..f09f253
--- /dev/null
+++ b/apps/web/src/app/settings/page.tsx
@@ -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 (
+
+
+
+ );
+}
+
+function Settings() {
+ const { me, loggedOut } = useMe();
+ const router = useRouter();
+ if (loggedOut) router.replace('/login');
+ if (!me) return null;
+ return (
+
+ );
+}
+
+function ProfileForm({ me }: { me: MeDTO }) {
+ const { mutate } = useMe();
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const f = new FormData(e.currentTarget);
+ const str = (k: string) => (f.get(k) as string).trim() || null;
+ try {
+ const updated = await api('/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 (
+
+ );
+}
+
+function Connections() {
+ const params = useSearchParams();
+ const { data: connections = [], mutate } = useSWR('/connections', fetcher);
+ const [error, setError] = useState(params.get('error'));
+ const byProvider = new Map(connections.map((c) => [c.provider, c]));
+
+ const run = async (fn: () => Promise) => {
+ 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) => {
+ e.preventDefault();
+ const body = Object.fromEntries(new FormData(e.currentTarget));
+ run(() => api('/connections/jellyfin', { body }));
+ };
+
+ const connectUsername = (provider: 'lastfm' | 'listenbrainz') => (e: React.FormEvent) => {
+ 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 (
+
+ {c.status === 'error'
+ ? `Needs reconnecting: ${c.lastError}`
+ : `Connected as ${c.displayName ?? c.externalUserId}${c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}`}
+
+ );
+ };
+
+ return (
+
+ Music services
+ Connect where you listen. Your friends see what you're playing in real time.
+ {error && {error}
}
+
+ {(['spotify', 'plex'] as const).map((p) => (
+
+
+
{SERVICE_LABELS[p]}
+
{status(p) ?? (p === 'plex' && Music you play from your Plex servers)}
+
+
+ {byProvider.get(p)?.status === 'active' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {byProvider.get('jellyfin')?.status === 'active' && (
+
+ )}
+
+
+ {(['lastfm', 'listenbrainz'] as const).map((p) => (
+
+
+
{SERVICE_LABELS[p]}
+
+ {status(p) ?? (
+
+ {p === 'lastfm'
+ ? 'Works with anything that scrobbles: YouTube Music, Tidal, Deezer, SoundCloud…'
+ : 'Open-source scrobbling'}
+
+ )}
+
+
+ {byProvider.has(p) ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
Apple Music
+
Coming with the iOS app
+
+
+
+
+ );
+}
+
+const WEBHOOK_HELP: Partial> = {
+ 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 (
+
+ Instant updates
+ {WEBHOOK_HELP[connection.provider]}
+
+ {connection.webhookUrl}
+
+
+
+ );
+}
+
+function FeaturedPicker() {
+ const { me } = useMe();
+ const [q, setQ] = useState('');
+ const [selected, setSelected] = useState(null);
+ const [comment, setComment] = useState('');
+ const [saved, setSaved] = useState(false);
+ const debounced = useDebounced(q.trim(), 300);
+ const { data: results } = useSWR(
+ 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 (
+
+ Featured song
+ Pin a song to your profile, with an optional note about why.
+ {selected ? (
+
+ {selected.artworkUrl &&

}
+
+
{selected.title}
+
{selected.artist}
+
+
+
+ ) : (
+ <>
+ setQ(e.target.value)} />
+ {results && (
+
+ {results.map((r) => (
+ -
+
+
+ ))}
+
+ )}
+ >
+ )}
+ {selected && (
+ <>
+
+ );
+}
+
+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 (
+
+ Delete account
+ Permanently removes your profile, follows and connected services.
+ {confirming ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/u/[username]/page.tsx b/apps/web/src/app/u/[username]/page.tsx
new file mode 100644
index 0000000..8b6ecfc
--- /dev/null
+++ b/apps/web/src/app/u/[username]/page.tsx
@@ -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(`/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 User not found.
;
+ 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 (
+
+
+
+
+
{user.displayName ?? user.username}
+
+ @{user.username} · {profile.followerCount} followers · {profile.followingCount} following
+
+ {user.bio &&
{user.bio}
}
+
+ {profile.isSelf ? (
+
+ Edit profile
+
+ ) : me ? (
+
+ {profile.isFollowing && (
+
+ )}
+
+
+ ) : (
+
+ Sign up to follow
+
+ )}
+
+
+ {profile.featured && (
+
+ Featured song
+
+ {profile.featured.comment && {profile.featured.comment}
}
+
+ )}
+
+ {profile.nowPlaying ? (
+
+ ) : (
+
Nothing playing right now.
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx
new file mode 100644
index 0000000..bb16da3
--- /dev/null
+++ b/apps/web/src/components/AuthForm.tsx
@@ -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(null);
+ const [busy, setBusy] = useState(false);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ 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 (
+
+ );
+}
diff --git a/apps/web/src/components/Avatar.tsx b/apps/web/src/components/Avatar.tsx
new file mode 100644
index 0000000..19ebe20
--- /dev/null
+++ b/apps/web/src/components/Avatar.tsx
@@ -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 ? (
+
+ ) : (
+
+ {label}
+
+ );
+}
diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx
new file mode 100644
index 0000000..e3a3d0a
--- /dev/null
+++ b/apps/web/src/components/Nav.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/NowPlayingCard.tsx b/apps/web/src/components/NowPlayingCard.tsx
new file mode 100644
index 0000000..497375d
--- /dev/null
+++ b/apps/web/src/components/NowPlayingCard.tsx
@@ -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 (
+
+
+ {showUser && (
+
+
+ {np.user.displayName ?? np.user.username}
+
+ )}
+
+
+ {np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
+ via {SERVICE_LABELS[np.source]}
+
+ {onTogglePin && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/TrackCard.tsx b/apps/web/src/components/TrackCard.tsx
new file mode 100644
index 0000000..28cd709
--- /dev/null
+++ b/apps/web/src/components/TrackCard.tsx
@@ -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 (
+
+ {track.artworkUrl ? (
+

+ ) : (
+
♪
+ )}
+
+
{track.title}
+
+ {track.artist}
+ {track.album && · {track.album}}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/UserSearch.tsx b/apps/web/src/components/UserSearch.tsx
new file mode 100644
index 0000000..7138acf
--- /dev/null
+++ b/apps/web/src/components/UserSearch.tsx
@@ -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(
+ debounced.length >= 2 ? `/users/search?q=${encodeURIComponent(debounced)}` : null,
+ fetcher,
+ );
+ return (
+
+
setQ(e.target.value)} />
+ {data && debounced.length >= 2 && (
+
+ {data.length === 0 && - No one found
}
+ {data.map((u) => (
+ -
+
+
+ {u.displayName ?? u.username}
+ @{u.username}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
new file mode 100644
index 0000000..91e4c80
--- /dev/null
+++ b/apps/web/src/lib/api.ts
@@ -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(path: string, init: { method?: string; body?: unknown } = {}): Promise {
+ 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 = (path: string) => api(path);
+
+export function useMe() {
+ const { data, error, isLoading, mutate } = useSWR('/me', fetcher, {
+ shouldRetryOnError: false,
+ revalidateOnFocus: false,
+ });
+ return { me: data, isLoading, loggedOut: error?.status === 401, mutate };
+}
diff --git a/apps/web/src/lib/useDebounced.ts b/apps/web/src/lib/useDebounced.ts
new file mode 100644
index 0000000..02afd87
--- /dev/null
+++ b/apps/web/src/lib/useDebounced.ts
@@ -0,0 +1,12 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+export function useDebounced(value: T, ms: number): T {
+ const [v, setV] = useState(value);
+ useEffect(() => {
+ const t = setTimeout(() => setV(value), ms);
+ return () => clearTimeout(t);
+ }, [value, ms]);
+ return v;
+}
diff --git a/apps/web/src/lib/useRealtime.ts b/apps/web/src/lib/useRealtime.ts
new file mode 100644
index 0000000..8d7a7eb
--- /dev/null
+++ b/apps/web/src/lib/useRealtime.ts
@@ -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 | 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]);
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..4dad555
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2023"],
+ "jsx": "preserve",
+ "allowJs": false,
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "src", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}