- "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:
@@ -1,6 +1,8 @@
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, shareText, trackUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||||
|
import { WEB_URL } from '@/lib/config';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
@@ -11,7 +13,15 @@ import { Button } from './ui';
|
|||||||
*/
|
*/
|
||||||
const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link"));
|
const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link"));
|
||||||
|
|
||||||
|
/** The phone's share sheet, with a link to the song's page (which opens it in any service). */
|
||||||
|
function shareTrack(track: TrackDTO, from: string | undefined) {
|
||||||
|
const url = trackUrl(WEB_URL, track.id, from);
|
||||||
|
// Android shows only `message`; iOS shows `url` as a rich link next to it.
|
||||||
|
Share.share({ message: `${shareText(track)} ${url}`, url, title: shareText(track) }).catch((e) => showError(e, "Couldn't share"));
|
||||||
|
}
|
||||||
|
|
||||||
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||||
|
const { me } = useSession();
|
||||||
const primary = preferred ? openUrl(track, preferred) : null;
|
const primary = preferred ? openUrl(track, preferred) : null;
|
||||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||||
|
|
||||||
@@ -50,6 +60,9 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
|
||||||
|
<Text style={[styles.service, styles.share]}>Share ↗</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -72,6 +85,7 @@ const styles = StyleSheet.create({
|
|||||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 18, letterSpacing: -0.2 },
|
title: { color: colors.text, fontFamily: fonts.display, fontSize: 18, letterSpacing: -0.2 },
|
||||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||||
muted: { color: colors.muted },
|
muted: { color: colors.muted },
|
||||||
|
share: { backgroundColor: 'rgba(123,47,247,0.28)', borderColor: 'rgba(123,47,247,0.5)' },
|
||||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||||
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
|
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
|
||||||
service: {
|
service: {
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.legal { line-height: 1.6; }
|
.legal { line-height: 1.6; }
|
||||||
.legal h2 { margin-top: 1.5rem; }
|
.legal h2 { margin-top: 1.5rem; }
|
||||||
.legal ul { padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.45rem; }
|
.legal ul { padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.45rem; }
|
||||||
|
.share-link { font: inherit; cursor: pointer; color: var(--text); }
|
||||||
|
.song-page { display: flex; flex-direction: column; gap: 1.25rem; align-items: stretch; }
|
||||||
|
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
|
||||||
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
|
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
|
||||||
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.grow { flex: 1; min-width: 0; }
|
.grow { flex: 1; min-width: 0; }
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import './globals.css';
|
|||||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
||||||
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap' });
|
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap' });
|
||||||
|
|
||||||
|
// Previews need absolute image URLs. In production the API lives at <site>/api, so its origin is the site's.
|
||||||
|
const siteUrl = process.env.SITE_URL ?? new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3737').origin;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(siteUrl),
|
||||||
title: 'musicshare',
|
title: 'musicshare',
|
||||||
description: "See what your friends are listening to, and play it on your own service.",
|
description: "See what your friends are listening to, and play it on your own service.",
|
||||||
applicationName: 'musicshare',
|
applicationName: 'musicshare',
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import type { TrackDTO } from '@musicshare/shared';
|
||||||
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
|
import { fetcher, useMe } from '@/lib/api';
|
||||||
|
|
||||||
|
/** A shared song: big artwork and "open in" buttons for every service. */
|
||||||
|
export function TrackClient({ id, from }: { id: string; from: string | null }) {
|
||||||
|
const { me } = useMe();
|
||||||
|
const { data: track, error } = useSWR<TrackDTO>(`/tracks/${id}`, fetcher);
|
||||||
|
|
||||||
|
if (error) return <p className="muted">This song couldn't be found.</p>;
|
||||||
|
if (!track) return null;
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{from && (
|
||||||
|
<p className="muted">
|
||||||
|
<Link href={`/u/${from}`}>@{from}</Link> shared a song with you
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<section className="card song-page">
|
||||||
|
{track.artworkUrl && <img className="song-art" src={track.artworkUrl} alt="" />}
|
||||||
|
<TrackCard track={track} preferred={me?.preferredService} hideArtwork />
|
||||||
|
</section>
|
||||||
|
{!me && (
|
||||||
|
<section className="card">
|
||||||
|
<h2>See what your friends are playing</h2>
|
||||||
|
<p className="muted">musicshare shows you what friends are listening to right now, and opens it in your app.</p>
|
||||||
|
<Link href="/register" className="btn btn-primary">
|
||||||
|
Join musicshare
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import type { TrackDTO } from '@musicshare/shared';
|
||||||
|
import { serverGet } from '@/lib/server';
|
||||||
|
import { TrackClient } from './TrackClient';
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ id: string }>; searchParams: Promise<{ from?: string }> };
|
||||||
|
|
||||||
|
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
|
||||||
|
const [{ id }, { from }] = await Promise.all([params, searchParams]);
|
||||||
|
const track = await serverGet<TrackDTO>(`/tracks/${encodeURIComponent(id)}`);
|
||||||
|
if (!track) return { title: 'musicshare' };
|
||||||
|
const title = `${track.title} · ${track.artist}`;
|
||||||
|
const description = from
|
||||||
|
? `@${from} shared this on musicshare. Play it on Spotify, Apple Music, YouTube Music and more.`
|
||||||
|
: `Play it on Spotify, Apple Music, YouTube Music and more.`;
|
||||||
|
const images = [track.artworkUrl ? { url: track.artworkUrl, width: 1000, height: 1000 } : { url: '/icon-512.png', width: 512, height: 512 }];
|
||||||
|
return {
|
||||||
|
title: `${title} · musicshare`,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description, type: 'music.song', images },
|
||||||
|
twitter: { card: 'summary', title, description, images: [track.artworkUrl ?? '/icon-512.png'] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TrackPage({ params, searchParams }: Props) {
|
||||||
|
const [{ id }, { from }] = await Promise.all([params, searchParams]);
|
||||||
|
return <TrackClient id={id} from={from ?? null} />;
|
||||||
|
}
|
||||||
@@ -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'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} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
type Props = { params: Promise<{ username: string }> };
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams } from 'next/navigation';
|
/** Link previews (iMessage, Discord, WhatsApp…): who it is, and what they're playing. */
|
||||||
import { useState } from 'react';
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
import useSWR from 'swr';
|
const { username } = await params;
|
||||||
import { profileUrl, type ProfileDTO } from '@musicshare/shared';
|
const profile = await serverGet<ProfileDTO>(`/users/${encodeURIComponent(username)}`);
|
||||||
import { Avatar } from '@/components/Avatar';
|
if (!profile) return { title: 'musicshare' };
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
const name = profile.user.displayName ?? profile.user.username;
|
||||||
import { ProfileMenu } from '@/components/ProfileMenu';
|
const np = profile.nowPlaying;
|
||||||
import { QrCode } from '@/components/QrCode';
|
const description = np?.isPlaying
|
||||||
import { RecentPlays } from '@/components/RecentPlays';
|
? `Listening to ${np.track.title} by ${np.track.artist}`
|
||||||
import { TrackCard } from '@/components/TrackCard';
|
: (profile.user.bio ?? `See what ${name} is listening to on musicshare.`);
|
||||||
import { api, fetcher, useMe } from '@/lib/api';
|
const image = (np?.isPlaying && np.track.artworkUrl) || profile.user.avatarUrl || '/icon-512.png';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
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() {
|
export default function ProfilePage() {
|
||||||
const { username } = useParams<{ username: string }>();
|
return <ProfileClient />;
|
||||||
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} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { shareText, trackUrl, type TrackDTO } from '@musicshare/shared';
|
||||||
|
import { useMe } from '@/lib/api';
|
||||||
|
|
||||||
|
/** Share a song: the phone's share sheet where there is one, otherwise copy the link. */
|
||||||
|
export function ShareButton({ track }: { track: TrackDTO }) {
|
||||||
|
const { me } = useMe();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const share = async () => {
|
||||||
|
const url = trackUrl(window.location.origin, track.id, me?.username);
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: shareText(track), text: shareText(track), url });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') return; // they closed the sheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="service-link share-link" onClick={share} title="Share this song">
|
||||||
|
{copied ? 'Link copied ✓' : 'Share ↗'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { STREAMING_SERVICES, SERVICE_LABELS, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
import { STREAMING_SERVICES, SERVICE_LABELS, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||||
|
import { ShareButton } from './ShareButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Artwork + title, with a primary button for the viewer's preferred service
|
* Artwork + title, with a primary button for the viewer's preferred service
|
||||||
@@ -11,17 +12,20 @@ export function TrackCard({
|
|||||||
track,
|
track,
|
||||||
preferred,
|
preferred,
|
||||||
compact,
|
compact,
|
||||||
|
hideArtwork,
|
||||||
}: {
|
}: {
|
||||||
track: TrackDTO;
|
track: TrackDTO;
|
||||||
preferred: StreamingService | null | undefined;
|
preferred: StreamingService | null | undefined;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** When the page already shows the artwork big. */
|
||||||
|
hideArtwork?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||||
const primary = preferred ? openUrl(track, preferred) : null;
|
const primary = preferred ? openUrl(track, preferred) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`track ${compact ? 'track-compact' : ''}`}>
|
<div className={`track ${compact ? 'track-compact' : ''}`}>
|
||||||
{track.artworkUrl ? (
|
{hideArtwork ? null : track.artworkUrl ? (
|
||||||
<img className="artwork" src={track.artworkUrl} alt="" />
|
<img className="artwork" src={track.artworkUrl} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<div className="artwork artwork-empty">♪</div>
|
<div className="artwork artwork-empty">♪</div>
|
||||||
@@ -53,6 +57,7 @@ export function TrackCard({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<ShareButton track={track} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For server-rendered link previews. Inside Docker the web server reaches the API
|
||||||
|
* by service name (INTERNAL_API_URL); elsewhere it uses the public URL.
|
||||||
|
*/
|
||||||
|
const INTERNAL_API = process.env.INTERNAL_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
||||||
|
|
||||||
|
export async function serverGet<T>(path: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(INTERNAL_API + path, { next: { revalidate: 60 }, signal: AbortSignal.timeout(3000) });
|
||||||
|
return res.ok ? ((await res.json()) as T) : null;
|
||||||
|
} catch {
|
||||||
|
return null; // previews are best-effort; the page itself still loads
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,9 @@ services:
|
|||||||
# Optional donation link shown on the website, e.g. DONATION_URL=https://ko-fi.com/you
|
# Optional donation link shown on the website, e.g. DONATION_URL=https://ko-fi.com/you
|
||||||
NEXT_PUBLIC_DONATION_URL: ${DONATION_URL:-}
|
NEXT_PUBLIC_DONATION_URL: ${DONATION_URL:-}
|
||||||
image: musicshare-web:local
|
image: musicshare-web:local
|
||||||
|
environment:
|
||||||
|
# Server-side rendering (link previews) talks to the API inside the compose network.
|
||||||
|
INTERNAL_API_URL: http://api:4000
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-3737}:3737"
|
- "${WEB_PORT:-3737}:3737"
|
||||||
init: true
|
init: true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseProfileLink, profileUrl } from './links';
|
import { parseProfileLink, profileUrl, trackUrl } from './links';
|
||||||
|
|
||||||
describe('profileUrl', () => {
|
describe('profileUrl', () => {
|
||||||
it('builds a website profile link, tolerating a trailing slash', () => {
|
it('builds a website profile link, tolerating a trailing slash', () => {
|
||||||
@@ -34,3 +34,10 @@ describe('parseProfileLink', () => {
|
|||||||
expect(parseProfileLink(text)).toBeNull();
|
expect(parseProfileLink(text)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('trackUrl', () => {
|
||||||
|
it('links to the song page, optionally saying who shared it', () => {
|
||||||
|
expect(trackUrl('https://m.test/', 'abc')).toBe('https://m.test/t/abc');
|
||||||
|
expect(trackUrl('https://m.test', 'abc', 'alice')).toBe('https://m.test/t/abc?from=alice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export function parseProfileLink(text: string): string | null {
|
|||||||
return match ? valid(decodeURIComponent(match[1]!)) : null;
|
return match ? valid(decodeURIComponent(match[1]!)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shareable song page on the website, e.g. https://musicshare.app/t/<id>?from=alice */
|
||||||
|
export function trackUrl(webBase: string, trackId: string, from?: string | null): string {
|
||||||
|
const url = `${webBase.replace(/\/+$/, '')}/t/${encodeURIComponent(trackId)}`;
|
||||||
|
return from ? `${url}?from=${encodeURIComponent(from)}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What goes in the share sheet next to the link. */
|
||||||
|
export const shareText = (track: { title: string; artist: string }) => `${track.title} by ${track.artist}`;
|
||||||
|
|
||||||
function valid(name: string): string | null {
|
function valid(name: string): string | null {
|
||||||
const username = name.toLowerCase();
|
const username = name.toLowerCase();
|
||||||
return USERNAME.test(username) ? username : null;
|
return USERNAME.test(username) ? username : null;
|
||||||
|
|||||||
Reference in New Issue
Block a user