Files
musicshare/apps/web/src/app/page.tsx
T
chrisandClaude Opus 5.5 08631589e3 Friends tab shows who you follow and who follows you
The app's Friends tab was only a search box. It now lists Following and
Followers (with Follow / Remove buttons) until you type a search. The
website gets the same Friends page and tab; search moves there from the
top of the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:43:03 -04:00

151 lines
5.6 KiB
TypeScript

'use client';
import { AnimatePresence, motion } from 'motion/react';
import Link from 'next/link';
import useSWR from 'swr';
import { sortFeed, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Equalizer } from '@/components/Equalizer';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { SharingControls } from '@/components/SharingControls';
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);
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly.
const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null, fetcher);
// Everyone you follow; the feed only has the ones who've played something.
const { data: following } = useSWR<FollowListDTO>(me ? `/users/${me.username}/following` : null, fetcher);
const inFeed = new Set(feed?.map((n) => n.user.id));
const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? [];
useRealtime(true, (msg) => {
if (msg.type !== 'now_playing') return;
if (msg.data.user.id === me?.id) {
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
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">
<SharingControls compact />
{mine?.nowPlaying && (
<section className="stack">
<h2 className="section-label">You</h2>
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
</section>
)}
<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 &&
(following?.users.length ? (
<p className="muted">Nobody you follow has played anything yet. Their music will show up here live.</p>
) : (
<p className="muted">Nothing yet. <Link href="/friends">Find friends</Link>, and their music will show up here live.</p>
))}
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
<AnimatePresence initial={true}>
{feed?.map((np, i) => (
<motion.div
key={np.user.id}
layout
initial={{ opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 380, damping: 32, delay: Math.min(i, 8) * 0.05 }}
>
<NowPlayingCard
np={np}
preferred={me?.preferredService}
pinned={Boolean(np.pinnedAt)}
onTogglePin={() => togglePin(np)}
/>
</motion.div>
))}
</AnimatePresence>
{quiet.length > 0 && (
<section className="stack">
<h2 className="section-label">Following · nothing played yet</h2>
<div className="card quiet-list">
{quiet.map((u) => (
<Link key={u.id} href={`/u/${u.username}`} className="quiet-person" title={`@${u.username}`}>
<Avatar user={u} size={44} />
<span>{u.displayName ?? u.username}</span>
</Link>
))}
{following?.nextCursor && me && (
<Link href={`/u/${me.username}/following`} className="quiet-person quiet-more">
See all
</Link>
)}
</div>
</section>
)}
</div>
);
}
function Landing() {
return (
<motion.section
className="hero"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<Equalizer playing />
<h1>
See what your friends are <span className="gradient-text">playing.</span>
</h1>
<p>
Share what you play on Spotify, Apple Music, YouTube Music, Plex 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>
</motion.section>
);
}