CI / check (push) Canceled after 0s
React to a friend's current song or any of their recent plays: tap an emoji to add yours (or take it back), "☺+" for the quick six, and a full picker with search and categories (Unicode emoji up to 13.1, generated into the shared package so web and mobile match). One reaction per person per play; a new one replaces the old. Chips show counts and, on hover/ long label, who reacted; yours is highlighted. Listeners and followers get the update live. Not on your own plays, not across a block (blocking also clears reactions between the two people). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
'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't see your profile or what you play, and you won'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} ownerId={user.id} />
|
|
</div>
|
|
);
|
|
}
|