Share songs, with link previews
CI / check (push) Canceled after 0s

- "Share ↗" on every song (web and mobile): the phone's share sheet, or
  copy the link on desktop. Links go to a new song page, /t/<id>?from=you,
  with big artwork and open-in buttons for every service.
- Server-rendered previews (Open Graph / Twitter cards) for song pages and
  profiles: title, who shared it or what they're playing, and the album
  art, avatar or logo. The web server fetches from the API over the
  compose network (INTERNAL_API_URL).

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-06 13:31:47 -04:00
co-authored by Claude Opus 5.5
parent abe1a24bb4
commit edc0c7b999
13 changed files with 332 additions and 144 deletions
@@ -0,0 +1,144 @@
'use client';
import { motion } from 'motion/react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import useSWR from 'swr';
import { profileUrl, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { ProfileMenu } from '@/components/ProfileMenu';
import { QrCode } from '@/components/QrCode';
import { RecentPlays } from '@/components/RecentPlays';
import { TrackCard } from '@/components/TrackCard';
import { api, fetcher, useMe } from '@/lib/api';
import { useRealtime } from '@/lib/useRealtime';
export function ProfileClient() {
const { username } = useParams<{ username: string }>();
const { me } = useMe();
const { data: profile, error, mutate } = useSWR<ProfileDTO>(`/users/${username}`, fetcher);
const [busy, setBusy] = useState(false);
const [showQr, setShowQr] = 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) {
// A new track moves the previous one into "Recently played", so refetch; otherwise patch in place.
const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id;
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack });
}
});
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">
<motion.section
className="profile-header"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<Avatar user={user} size={96} live={Boolean(profile.nowPlaying?.isPlaying)} />
<div>
<h1>{user.displayName ?? user.username}</h1>
<div className="muted">
@{user.username}
{profile.followsYou && !profile.isSelf && <span className="chip-small">Follows you</span>}
</div>
</div>
<div className="stats">
<Link href={`/u/${user.username}/followers`}>
<strong>{profile.followerCount}</strong> {profile.followerCount === 1 ? 'follower' : 'followers'}
</Link>
<Link href={`/u/${user.username}/following`}>
<strong>{profile.followingCount}</strong> following
</Link>
</div>
{user.bio && <p className="bio">{user.bio}</p>}
{profile.isSelf ? (
<div className="row">
<button className="btn" onClick={() => setShowQr((v) => !v)}>
{showQr ? 'Hide QR code' : 'Show QR code'}
</button>
<Link href="/settings" className="btn">
Edit profile
</Link>
</div>
) : me ? (
<div className="row">
{profile.isFollowing && (
<button className="btn" onClick={togglePin} disabled={busy}>
{profile.isPinned ? '★ Pinned' : '☆ Pin'}
</button>
)}
{!profile.isBlocked && (
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
{profile.isFollowing ? 'Following' : profile.followsYou ? 'Follow back' : 'Follow'}
</button>
)}
<ProfileMenu profile={profile} onChanged={() => mutate()} />
</div>
) : (
<Link href="/register" className="btn btn-primary">
Sign up to follow
</Link>
)}
</motion.section>
{profile.isBlocked && (
<p className="card muted">
You blocked @{user.username}. They can&apos;t see your profile or what you play, and you won&apos;t see theirs. Unblock
them from the ⋯ menu.
</p>
)}
{profile.isSelf && showQr && (
<section className="card qr-card">
<QrCode value={profileUrl(window.location.origin, user.username)} size={220} />
<p className="muted">Friends scan this in the musicshare app (or their camera) to open your profile.</p>
</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>
)}
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
</div>
);
}
+25 -140
View File
@@ -1,144 +1,29 @@
'use client';
import type { Metadata } from 'next';
import type { ProfileDTO } from '@musicshare/shared';
import { serverGet } from '@/lib/server';
import { ProfileClient } from './ProfileClient';
import { motion } from 'motion/react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import useSWR from 'swr';
import { profileUrl, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { ProfileMenu } from '@/components/ProfileMenu';
import { QrCode } from '@/components/QrCode';
import { RecentPlays } from '@/components/RecentPlays';
import { TrackCard } from '@/components/TrackCard';
import { api, fetcher, useMe } from '@/lib/api';
import { useRealtime } from '@/lib/useRealtime';
type Props = { params: Promise<{ username: string }> };
/** Link previews (iMessage, Discord, WhatsApp…): who it is, and what they're playing. */
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { username } = await params;
const profile = await serverGet<ProfileDTO>(`/users/${encodeURIComponent(username)}`);
if (!profile) return { title: 'musicshare' };
const name = profile.user.displayName ?? profile.user.username;
const np = profile.nowPlaying;
const description = np?.isPlaying
? `Listening to ${np.track.title} by ${np.track.artist}`
: (profile.user.bio ?? `See what ${name} is listening to on musicshare.`);
const image = (np?.isPlaying && np.track.artworkUrl) || profile.user.avatarUrl || '/icon-512.png';
return {
title: `${name} (@${profile.user.username}) · musicshare`,
description,
openGraph: { title: `${name} on musicshare`, description, type: 'profile', images: [{ url: image }] },
twitter: { card: 'summary', title: `${name} on musicshare`, description, images: [image] },
};
}
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);
const [showQr, setShowQr] = 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) {
// A new track moves the previous one into "Recently played", so refetch; otherwise patch in place.
const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id;
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack });
}
});
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">
<motion.section
className="profile-header"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<Avatar user={user} size={96} live={Boolean(profile.nowPlaying?.isPlaying)} />
<div>
<h1>{user.displayName ?? user.username}</h1>
<div className="muted">
@{user.username}
{profile.followsYou && !profile.isSelf && <span className="chip-small">Follows you</span>}
</div>
</div>
<div className="stats">
<Link href={`/u/${user.username}/followers`}>
<strong>{profile.followerCount}</strong> {profile.followerCount === 1 ? 'follower' : 'followers'}
</Link>
<Link href={`/u/${user.username}/following`}>
<strong>{profile.followingCount}</strong> following
</Link>
</div>
{user.bio && <p className="bio">{user.bio}</p>}
{profile.isSelf ? (
<div className="row">
<button className="btn" onClick={() => setShowQr((v) => !v)}>
{showQr ? 'Hide QR code' : 'Show QR code'}
</button>
<Link href="/settings" className="btn">
Edit profile
</Link>
</div>
) : me ? (
<div className="row">
{profile.isFollowing && (
<button className="btn" onClick={togglePin} disabled={busy}>
{profile.isPinned ? '★ Pinned' : '☆ Pin'}
</button>
)}
{!profile.isBlocked && (
<button className={`btn ${profile.isFollowing ? '' : 'btn-primary'}`} onClick={toggleFollow} disabled={busy}>
{profile.isFollowing ? 'Following' : profile.followsYou ? 'Follow back' : 'Follow'}
</button>
)}
<ProfileMenu profile={profile} onChanged={() => mutate()} />
</div>
) : (
<Link href="/register" className="btn btn-primary">
Sign up to follow
</Link>
)}
</motion.section>
{profile.isBlocked && (
<p className="card muted">
You blocked @{user.username}. They can&apos;t see your profile or what you play, and you won&apos;t see theirs. Unblock
them from the ⋯ menu.
</p>
)}
{profile.isSelf && showQr && (
<section className="card qr-card">
<QrCode value={profileUrl(window.location.origin, user.username)} size={220} />
<p className="muted">Friends scan this in the musicshare app (or their camera) to open your profile.</p>
</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>
)}
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
</div>
);
return <ProfileClient />;
}