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>
151 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|