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
+99
View File
@@ -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>
);
}