A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

Select the types of activity you want to include in your feed.

add 'Share on Bluesky' button to artist, album, song, scrobble, and profile pages

+281 -95
+53
apps/web-mobile/src/components/ShareOnBluesky/index.tsx
··· 1 + import { useState } from "react"; 2 + import { IconShare3 } from "@tabler/icons-react"; 3 + import SignInModal from "../SignInModal/SignInModal"; 4 + 5 + interface Props { 6 + text: string; 7 + className?: string; 8 + variant?: "full" | "pill"; 9 + } 10 + 11 + export default function ShareOnBluesky({ text, className = "", variant = "full" }: Props) { 12 + const [signInOpen, setSignInOpen] = useState(false); 13 + 14 + const onClick = (e: React.MouseEvent) => { 15 + if (!localStorage.getItem("did")) { 16 + e.preventDefault(); 17 + setSignInOpen(true); 18 + } 19 + }; 20 + 21 + const href = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; 22 + 23 + return ( 24 + <> 25 + {variant === "pill" ? ( 26 + <a 27 + href={href} 28 + target="_blank" 29 + rel="noopener noreferrer" 30 + onClick={onClick} 31 + className={`flex items-center gap-1.5 px-5 py-2 rounded-full no-underline font-semibold text-sm !text-white ${className}`} 32 + style={{ backgroundColor: "var(--color-surface-2)", color: "#fff" }} 33 + > 34 + <IconShare3 size={14} /> 35 + Share 36 + </a> 37 + ) : ( 38 + <a 39 + href={href} 40 + target="_blank" 41 + rel="noopener noreferrer" 42 + onClick={onClick} 43 + className={`flex items-center justify-center gap-2 py-3 rounded-2xl no-underline font-semibold text-sm !text-white ${className}`} 44 + style={{ backgroundColor: "var(--color-surface-2)", color: "#fff" }} 45 + > 46 + <IconShare3 size={16} /> 47 + Share on Bluesky 48 + </a> 49 + )} 50 + <SignInModal isOpen={signInOpen} onClose={() => setSignInOpen(false)} /> 51 + </> 52 + ); 53 + }
+6
apps/web-mobile/src/hooks/useLibrary.tsx
··· 61 61 queryKey: ["artists", did, offset, limit], 62 62 queryFn: () => getArtists(did, offset, limit), 63 63 enabled: !!did, 64 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 65 + select: (data: any) => data?.artists?.map((x: any) => ({ ...x, scrobbles: x.playCount })), 64 66 }); 65 67 66 68 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => ··· 68 70 queryKey: ["albums", did, offset, limit], 69 71 queryFn: () => getAlbums(did, offset, limit), 70 72 enabled: !!did, 73 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 + select: (data: any) => data?.albums?.map((x: any) => ({ ...x, scrobbles: x.playCount })), 71 75 }); 72 76 73 77 export const useTracksQuery = (did: string, offset = 0, limit = 20) => ··· 75 79 queryKey: ["tracks", did, offset, limit], 76 80 queryFn: () => getTracks(did, offset, limit), 77 81 enabled: !!did, 82 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 83 + select: (data: any) => data?.tracks?.map((x: any) => ({ ...x, scrobbles: x.playCount })), 78 84 }); 79 85 80 86 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) =>
+59 -28
apps/web-mobile/src/pages/album/Album.tsx
··· 4 4 import dayjs from "dayjs"; 5 5 import Main from "../../layouts/Main"; 6 6 import { useAlbumQuery } from "../../hooks/useLibrary"; 7 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 7 8 8 9 function formatDuration(ms: number) { 9 10 if (!ms) return ""; ··· 98 99 ))} 99 100 </div> 100 101 )} 102 + 103 + <div className="mt-4 w-full px-4"> 104 + <ShareOnBluesky 105 + text={`${album.title} by ${album.artist} on Rocksky 🎵\n${window.location.href}`} 106 + /> 107 + </div> 101 108 </div> 102 109 103 110 {/* Track listing */} 104 111 <div className="px-4 pt-3"> 105 112 <h3 className="font-semibold text-base mb-2" style={{ color: "var(--color-text)" }}>Tracks</h3> 106 - {(album.tracks || []).map((track: Record<string, unknown>, i: number) => { 107 - const uri = track.uri as string; 108 - const href = uri ? `/${uri.split("at://")[1].replace("app.rocksky.", "")}` : null; 109 - return ( 110 - <div key={String(track.id || i)} className="flex items-center gap-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}> 111 - <span className="text-sm w-7 text-center opacity-40 shrink-0" style={{ color: "var(--color-text)" }}> 112 - {(track.trackNumber as number) || i + 1} 113 - </span> 114 - <div className="flex-1 min-w-0"> 115 - {href ? ( 116 - <Link to={href} className="no-underline font-medium text-sm truncate block" style={{ color: "var(--color-text)" }}> 117 - {track.title as string} 118 - </Link> 119 - ) : ( 120 - <p className="font-medium text-sm truncate m-0" style={{ color: "var(--color-text)" }}>{track.title as string}</p> 121 - )} 122 - {!!(track.artist as string) && ( 123 - <p className="text-xs m-0 truncate" style={{ color: "var(--color-text-muted)" }}>{track.artist as string}</p> 124 - )} 125 - </div> 126 - {!!(track.duration as number) && ( 127 - <span className="text-xs shrink-0" style={{ color: "var(--color-text-muted)" }}> 128 - {formatDuration(track.duration as number)} 129 - </span> 130 - )} 131 - </div> 132 - ); 133 - })} 134 113 {(!album.tracks || album.tracks.length === 0) && ( 135 114 <p className="text-sm text-center py-8" style={{ color: "var(--color-text-muted)" }}>No tracks found</p> 136 115 )} 116 + {(album.tracks || []).length > 0 && (() => { 117 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 118 + const tracks = album.tracks as any[]; 119 + const maxDisc = Math.max(...tracks.map((t) => t.discNumber || 1)); 120 + const multiDisc = maxDisc > 1; 121 + 122 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 123 + function TrackRow({ track, i }: { track: any; i: number }) { 124 + const uri = track.uri as string; 125 + const href = uri ? `/${uri.split("at://")[1].replace("app.rocksky.", "")}` : null; 126 + return ( 127 + <div key={String(track.id || i)} className="flex items-center gap-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}> 128 + <span className="text-sm w-7 text-center opacity-40 shrink-0" style={{ color: "var(--color-text)" }}> 129 + {track.trackNumber || i + 1} 130 + </span> 131 + <div className="flex-1 min-w-0"> 132 + {href ? ( 133 + <Link to={href} className="no-underline font-medium text-sm truncate block" style={{ color: "var(--color-text)" }}> 134 + {track.title} 135 + </Link> 136 + ) : ( 137 + <p className="font-medium text-sm truncate m-0" style={{ color: "var(--color-text)" }}>{track.title}</p> 138 + )} 139 + {!!(track.artist || track.albumArtist) && ( 140 + <p className="text-xs m-0 truncate" style={{ color: "var(--color-text-muted)" }}>{track.artist || track.albumArtist}</p> 141 + )} 142 + </div> 143 + {!!track.duration && ( 144 + <span className="text-xs shrink-0" style={{ color: "var(--color-text-muted)" }}> 145 + {formatDuration(track.duration)} 146 + </span> 147 + )} 148 + </div> 149 + ); 150 + } 151 + 152 + if (!multiDisc) { 153 + return tracks.map((track, i) => <TrackRow key={String(track.id || i)} track={track} i={i} />); 154 + } 155 + 156 + return Array.from({ length: maxDisc }, (_, di) => { 157 + const discTracks = tracks.filter((t) => (t.discNumber || 1) === di + 1); 158 + return ( 159 + <div key={di} className="mb-4"> 160 + <p className="font-semibold text-sm mt-3 mb-1" style={{ color: "var(--color-text-muted)" }}> 161 + Disc {di + 1} 162 + </p> 163 + {discTracks.map((track, i) => <TrackRow key={String(track.id || i)} track={track} i={i} />)} 164 + </div> 165 + ); 166 + }); 167 + })()} 137 168 </div> 138 169 </> 139 170 )}
+7
apps/web-mobile/src/pages/artist/Artist.tsx
··· 8 8 useArtistQuery, 9 9 useArtistTracksQuery, 10 10 } from "../../hooks/useLibrary"; 11 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 11 12 12 13 export default function Artist() { 13 14 const { did, rkey } = useParams<{ did: string; rkey: string }>(); ··· 88 89 ))} 89 90 </div> 90 91 )} 92 + 93 + <div className="mt-4 w-full px-4"> 94 + <ShareOnBluesky 95 + text={`Listening to ${artist.name} on Rocksky 🎵\n${window.location.href}`} 96 + /> 97 + </div> 91 98 </div> 92 99 93 100 {/* Tabs */}
+94 -62
apps/web-mobile/src/pages/profile/Profile.tsx
··· 34 34 useTracksQuery, 35 35 useLovedTracksQuery, 36 36 } from "../../hooks/useLibrary"; 37 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 37 38 38 39 dayjs.extend(relativeTime); 39 40 ··· 166 167 167 168 const artistList = Array.isArray(artists) ? artists : (artists as Record<string, unknown[]>)?.artists ?? []; 168 169 const albumList = Array.isArray(albums) ? albums : (albums as Record<string, unknown[]>)?.albums ?? []; 169 - const trackList = Array.isArray(tracks) ? tracks : (tracks as Record<string, unknown[]>)?.songs ?? []; 170 + const trackList = Array.isArray(tracks) ? tracks : (tracks as Record<string, unknown[]>)?.tracks ?? []; 170 171 171 172 return ( 172 173 <div> ··· 314 315 // ─── library ────────────────────────────────────────────────────────────────── 315 316 316 317 const LIBRARY_TABS = ["Scrobbles", "Artists", "Albums", "Tracks"]; 318 + const SCROBBLE_PAGE = 30; 319 + const ARTIST_PAGE = 50; 320 + const ALBUM_PAGE = 50; 321 + const TRACK_PAGE = 50; 322 + 323 + function Pager({ page, totalPages, onPrev, onNext }: { 324 + page: number; 325 + totalPages: number; 326 + onPrev: () => void; 327 + onNext: () => void; 328 + }) { 329 + return ( 330 + <div className="flex items-center justify-between pt-4 pb-2"> 331 + <button 332 + onClick={onPrev} 333 + disabled={page === 1} 334 + className="px-4 py-2 text-sm rounded-full border-none cursor-pointer disabled:opacity-30" 335 + style={{ backgroundColor: "var(--color-surface-2)", color: "var(--color-text)" }} 336 + > 337 + ← Prev 338 + </button> 339 + <span className="text-xs" style={{ color: "var(--color-text-muted)" }}> 340 + Page {page}{totalPages > 1 ? ` / ${totalPages}` : ""} 341 + </span> 342 + <button 343 + onClick={onNext} 344 + disabled={page >= totalPages} 345 + className="px-4 py-2 text-sm rounded-full border-none cursor-pointer disabled:opacity-30" 346 + style={{ backgroundColor: "var(--color-surface-2)", color: "var(--color-text)" }} 347 + > 348 + Next → 349 + </button> 350 + </div> 351 + ); 352 + } 317 353 318 354 function LibraryTab({ did }: { did: string }) { 319 355 const [sub, setSub] = useState(0); 320 - const PAGE = 30; 321 - const [page, setPage] = useState(1); 356 + const [scrobblePage, setScrobblePage] = useState(1); 357 + const [artistPage, setArtistPage] = useState(1); 358 + const [albumPage, setAlbumPage] = useState(1); 359 + const [trackPage, setTrackPage] = useState(1); 322 360 323 - const { data: scrobbles } = useRecentTracksByDidQuery(did, (page - 1) * PAGE, PAGE); 324 - const { data: artists } = useArtistsQuery(did, (page - 1) * PAGE, PAGE); 325 - const { data: albums } = useAlbumsQuery(did, (page - 1) * PAGE, PAGE); 326 - const { data: tracks } = useTracksQuery(did, (page - 1) * PAGE, PAGE); 361 + const { data: stats } = useProfileStatsByDidQuery(did); 327 362 328 - useEffect(() => { setPage(1); }, [sub]); 363 + const { data: scrobbles } = useRecentTracksByDidQuery(did, (scrobblePage - 1) * SCROBBLE_PAGE, SCROBBLE_PAGE); 364 + const { data: artists } = useArtistsQuery(did, (artistPage - 1) * ARTIST_PAGE, ARTIST_PAGE); 365 + const { data: albums } = useAlbumsQuery(did, (albumPage - 1) * ALBUM_PAGE, ALBUM_PAGE); 366 + const { data: tracks } = useTracksQuery(did, (trackPage - 1) * TRACK_PAGE, TRACK_PAGE); 329 367 330 - const artistList = Array.isArray(artists) ? artists : (artists as Record<string, unknown[]>)?.artists ?? []; 331 - const albumList = Array.isArray(albums) ? albums : (albums as Record<string, unknown[]>)?.albums ?? []; 332 - const trackList = Array.isArray(tracks) ? tracks : (tracks as Record<string, unknown[]>)?.songs ?? []; 368 + const artistList: unknown[] = Array.isArray(artists) ? artists : []; 369 + const albumList: unknown[] = Array.isArray(albums) ? albums : []; 370 + const trackList: unknown[] = Array.isArray(tracks) ? tracks : []; 333 371 const scrobbleList: Record<string, unknown>[] = scrobbles || []; 372 + 373 + const scrobbleTotalPages = stats?.scrobbles ? Math.ceil(stats.scrobbles / SCROBBLE_PAGE) : Math.max(1, Math.ceil(scrobbleList.length / SCROBBLE_PAGE) + (scrobbleList.length >= SCROBBLE_PAGE ? 1 : 0)); 374 + const artistTotalPages = stats?.artists ? Math.ceil(stats.artists / ARTIST_PAGE) : Math.max(1, artistList.length >= ARTIST_PAGE ? artistPage + 1 : artistPage); 375 + const albumTotalPages = stats?.albums ? Math.ceil(stats.albums / ALBUM_PAGE) : Math.max(1, albumList.length >= ALBUM_PAGE ? albumPage + 1 : albumPage); 376 + const trackTotalPages = trackList.length >= TRACK_PAGE ? trackPage + 1 : Math.max(1, trackPage); 334 377 335 378 return ( 336 379 <div> ··· 382 425 ); 383 426 })} 384 427 {scrobbleList.length === 0 && <p className="text-sm text-center py-8" style={{ color: "var(--color-text-muted)" }}>No scrobbles yet</p>} 428 + <Pager page={scrobblePage} totalPages={scrobbleTotalPages} onPrev={() => setScrobblePage((p) => Math.max(1, p - 1))} onNext={() => setScrobblePage((p) => p + 1)} /> 385 429 </div> 386 430 )} 387 431 ··· 393 437 const href = toPath(a.uri); 394 438 return ( 395 439 <div key={a.id || a.uri || i} className="flex items-center gap-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}> 396 - <span className="text-xs w-6 text-center opacity-40 shrink-0" style={{ color: "var(--color-text)" }}>{(page - 1) * PAGE + i + 1}</span> 440 + <span className="text-xs w-6 text-center opacity-40 shrink-0" style={{ color: "var(--color-text)" }}>{(artistPage - 1) * ARTIST_PAGE + i + 1}</span> 397 441 <div className="w-10 h-10 rounded-full overflow-hidden shrink-0" style={{ backgroundColor: "var(--color-surface-2)" }}> 398 442 {(a.picture || a.photo) ? <img src={a.picture || a.photo} alt={a.name} className="w-full h-full object-cover" /> : ( 399 443 <div className="w-full h-full flex items-center justify-center"><span className="opacity-20">♬</span></div> ··· 411 455 ); 412 456 })} 413 457 {artistList.length === 0 && <p className="text-sm text-center py-8" style={{ color: "var(--color-text-muted)" }}>No artists yet</p>} 458 + <Pager page={artistPage} totalPages={artistTotalPages} onPrev={() => setArtistPage((p) => Math.max(1, p - 1))} onNext={() => setArtistPage((p) => p + 1)} /> 414 459 </div> 415 460 )} 416 461 417 462 {/* Albums */} 418 463 {sub === 2 && ( 419 - <div className="grid grid-cols-3 gap-2"> 420 - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} 421 - {albumList.map((a: any, i: number) => { 422 - const href = toPath(a.uri); 423 - const art = a.albumArt || a.album_art; 424 - return ( 425 - <div key={a.id || a.uri || i}> 426 - {href ? ( 427 - <Link to={href} className="no-underline block"> 428 - <div className="aspect-square rounded-xl overflow-hidden mb-1" style={{ backgroundColor: "var(--color-surface-2)" }}> 429 - {art ? <img src={art} alt={a.title} className="w-full h-full object-cover" /> : ( 464 + <div> 465 + <div className="grid grid-cols-3 gap-2"> 466 + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} 467 + {albumList.map((a: any, i: number) => { 468 + const href = toPath(a.uri); 469 + const art = a.albumArt || a.album_art; 470 + return ( 471 + <div key={a.id || a.uri || i}> 472 + {href ? ( 473 + <Link to={href} className="no-underline block"> 474 + <div className="aspect-square rounded-xl overflow-hidden mb-1" style={{ backgroundColor: "var(--color-surface-2)" }}> 475 + {art ? <img src={art} alt={a.title} className="w-full h-full object-cover" /> : ( 476 + <div className="w-full h-full flex items-center justify-center"><span className="text-xl opacity-20">💿</span></div> 477 + )} 478 + </div> 479 + <p className="text-[11px] m-0 truncate font-medium" style={{ color: "var(--color-text)" }}>{a.title}</p> 480 + <p className="text-[10px] m-0 truncate" style={{ color: "var(--color-text-muted)" }}>{numeral(a.playCount || a.scrobbles).format("0,0")} plays</p> 481 + </Link> 482 + ) : ( 483 + <> 484 + <div className="aspect-square rounded-xl overflow-hidden mb-1" style={{ backgroundColor: "var(--color-surface-2)" }}> 430 485 <div className="w-full h-full flex items-center justify-center"><span className="text-xl opacity-20">💿</span></div> 431 - )} 432 - </div> 433 - <p className="text-[11px] m-0 truncate font-medium" style={{ color: "var(--color-text)" }}>{a.title}</p> 434 - <p className="text-[10px] m-0 truncate" style={{ color: "var(--color-text-muted)" }}>{numeral(a.playCount || a.scrobbles).format("0,0")} plays</p> 435 - </Link> 436 - ) : ( 437 - <> 438 - <div className="aspect-square rounded-xl overflow-hidden mb-1" style={{ backgroundColor: "var(--color-surface-2)" }}> 439 - <div className="w-full h-full flex items-center justify-center"><span className="text-xl opacity-20">💿</span></div> 440 - </div> 441 - <p className="text-[11px] m-0 truncate font-medium" style={{ color: "var(--color-text)" }}>{a.title}</p> 442 - </> 443 - )} 444 - </div> 445 - ); 446 - })} 447 - {albumList.length === 0 && <p className="text-sm text-center py-8 col-span-3" style={{ color: "var(--color-text-muted)" }}>No albums yet</p>} 486 + </div> 487 + <p className="text-[11px] m-0 truncate font-medium" style={{ color: "var(--color-text)" }}>{a.title}</p> 488 + </> 489 + )} 490 + </div> 491 + ); 492 + })} 493 + </div> 494 + {albumList.length === 0 && <p className="text-sm text-center py-8" style={{ color: "var(--color-text-muted)" }}>No albums yet</p>} 495 + <Pager page={albumPage} totalPages={albumTotalPages} onPrev={() => setAlbumPage((p) => Math.max(1, p - 1))} onNext={() => setAlbumPage((p) => p + 1)} /> 448 496 </div> 449 497 )} 450 498 ··· 457 505 const art = t.albumArt || t.album_art; 458 506 return ( 459 507 <div key={t.id || t.uri || i} className="flex items-center gap-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}> 460 - <span className="text-xs w-6 text-center opacity-40 shrink-0" style={{ color: "var(--color-text)" }}>{(page - 1) * PAGE + i + 1}</span> 508 + <span className="text-xs w-6 text-center opacity-40 shrink-0" style={{ color: "var(--color-text)" }}>{(trackPage - 1) * TRACK_PAGE + i + 1}</span> 461 509 <div className="w-10 h-10 rounded-lg overflow-hidden shrink-0" style={{ backgroundColor: "var(--color-surface-2)" }}> 462 510 {art ? <img src={art} alt={t.title} className="w-full h-full object-cover" /> : ( 463 511 <div className="w-full h-full flex items-center justify-center"><span className="opacity-20">♪</span></div> ··· 476 524 ); 477 525 })} 478 526 {trackList.length === 0 && <p className="text-sm text-center py-8" style={{ color: "var(--color-text-muted)" }}>No tracks yet</p>} 527 + <Pager page={trackPage} totalPages={trackTotalPages} onPrev={() => setTrackPage((p) => Math.max(1, p - 1))} onNext={() => setTrackPage((p) => p + 1)} /> 479 528 </div> 480 529 )} 481 - 482 - {/* Pagination */} 483 - <div className="flex items-center justify-between pt-4 pb-2"> 484 - <button 485 - onClick={() => setPage((p) => Math.max(1, p - 1))} 486 - disabled={page === 1} 487 - className="px-4 py-2 text-sm rounded-full border-none cursor-pointer disabled:opacity-30" 488 - style={{ backgroundColor: "var(--color-surface-2)", color: "var(--color-text)" }} 489 - > 490 - ← Prev 491 - </button> 492 - <span className="text-xs" style={{ color: "var(--color-text-muted)" }}>Page {page}</span> 493 - <button 494 - onClick={() => setPage((p) => p + 1)} 495 - disabled={(sub === 0 ? scrobbleList : sub === 1 ? artistList : sub === 2 ? albumList : trackList).length < PAGE} 496 - className="px-4 py-2 text-sm rounded-full border-none cursor-pointer disabled:opacity-30" 497 - style={{ backgroundColor: "var(--color-surface-2)", color: "var(--color-text)" }} 498 - > 499 - Next → 500 - </button> 501 - </div> 502 530 </div> 503 531 ); 504 532 } ··· 846 874 <a href={`https://pdsls.dev/at/${profile?.did}`} target="_blank" rel="noopener noreferrer" className="flex items-center px-5 py-2 rounded-full no-underline font-medium text-sm" style={{ backgroundColor: "var(--color-surface-2)", color: "var(--color-text)" }}> 847 875 PDSls ↗ 848 876 </a> 877 + <ShareOnBluesky 878 + variant="pill" 879 + text={`Check out ${profile?.displayName || profile?.handle}'s music taste on Rocksky 🎵\n${window.location.href}`} 880 + /> 849 881 </div> 850 882 </> 851 883 )}
+8 -4
apps/web-mobile/src/pages/song/Song.tsx
··· 5 5 import { useSongByUriQuery } from "../../hooks/useLibrary"; 6 6 import { useScrobbleByUriQuery } from "../../hooks/useScrobble"; 7 7 import Main from "../../layouts/Main"; 8 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 8 9 9 10 export default function Song() { 10 11 const { did, rkey } = useParams<{ did: string; rkey: string }>(); ··· 135 136 )} 136 137 137 138 {/* Links */} 138 - {song.spotifyLink && ( 139 - <div className="mb-6"> 139 + <div className="mb-6 flex flex-col gap-3"> 140 + <ShareOnBluesky 141 + text={`${isScrobble ? "Just scrobbled" : "Listening to"} ${song.title} by ${song.albumArtist || song.artist} on Rocksky 🎵\n${window.location.href}`} 142 + /> 143 + {song.spotifyLink && ( 140 144 <a 141 145 href={song.spotifyLink} 142 146 target="_blank" ··· 146 150 > 147 151 Listen on Spotify ↗ 148 152 </a> 149 - </div> 150 - )} 153 + )} 154 + </div> 151 155 152 156 {/* Lyrics */} 153 157 {song.lyrics && (
+37
apps/web/src/components/ShareOnBluesky/index.tsx
··· 1 + import { useState } from "react"; 2 + import { IconShare3 } from "@tabler/icons-react"; 3 + import SignInModal from "../SignInModal/SignInModal"; 4 + 5 + interface Props { 6 + text: string; 7 + } 8 + 9 + export default function ShareOnBluesky({ text }: Props) { 10 + const [signInOpen, setSignInOpen] = useState(false); 11 + 12 + const onClick = (e: React.MouseEvent) => { 13 + if (!localStorage.getItem("did")) { 14 + e.preventDefault(); 15 + setSignInOpen(true); 16 + } 17 + }; 18 + 19 + const href = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; 20 + 21 + return ( 22 + <> 23 + <a 24 + href={href} 25 + target="_blank" 26 + rel="noopener noreferrer" 27 + onClick={onClick} 28 + className="no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px] ml-[10px] inline-flex items-center gap-[10px]" 29 + style={{ color: "var(--color-text)" }} 30 + > 31 + <IconShare3 size={20} /> 32 + Share on Bluesky 33 + </a> 34 + <SignInModal isOpen={signInOpen} onClose={() => setSignInOpen(false)} /> 35 + </> 36 + ); 37 + }
+4
apps/web/src/pages/album/Album.tsx
··· 12 12 import numeral from "numeral"; 13 13 import { useEffect, useState } from "react"; 14 14 import ContentLoader from "react-content-loader"; 15 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 15 16 import Disc from "../../components/Icons/Disc"; 16 17 import Shout from "../../components/Shout/Shout"; 17 18 import SongCover from "../../components/SongCover"; ··· 247 248 /> 248 249 View on PDSls 249 250 </a> 251 + <ShareOnBluesky 252 + text={`${album?.title} by ${album?.artist} on Rocksky 🎵\n${window.location.href}`} 253 + /> 250 254 </div> 251 255 </div> 252 256 <div className="mt-[10px]">
+4
apps/web/src/pages/artist/Artist.tsx
··· 20 20 import ArtistListeners from "./ArtistListeners"; 21 21 import PopularSongs from "./PopularSongs"; 22 22 import ContentLoader from "react-content-loader"; 23 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 23 24 24 25 const Group = styled.div` 25 26 display: flex; ··· 232 233 /> 233 234 View on PDSls 234 235 </a> 236 + <ShareOnBluesky 237 + text={`Listening to ${artist?.name} on Rocksky 🎵\n${window.location.href}`} 238 + /> 235 239 </div> 236 240 </div> 237 241 </div>
+5 -1
apps/web/src/pages/profile/Profile.tsx
··· 35 35 import { getLastDays } from "../../lib/date"; 36 36 import { Link } from "@tanstack/react-router"; 37 37 import ContentLoader from "react-content-loader"; 38 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 38 39 39 40 const Group = styled.div` 40 41 display: flex; ··· 268 269 {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 269 270 </span> 270 271 </LabelLarge> 271 - <div className="flex-1 mt-[30px] mr-[10px]"> 272 + <div className="flex items-center gap-[10px] mt-[30px] mr-[10px]"> 272 273 <a 273 274 href={`https://pdsls.dev/at/${profiles[did]?.did}`} 274 275 target="_blank" ··· 277 278 <ExternalLink size={24} style={{ marginRight: 10 }} /> 278 279 View on PDSls 279 280 </a> 281 + <ShareOnBluesky 282 + text={`Check out ${profiles[did]?.displayName || profiles[did]?.handle}'s music taste on Rocksky 🎵\n${window.location.href}`} 283 + /> 280 284 </div> 281 285 </div> 282 286 </ProfileInfo>
+4
apps/web/src/pages/song/Song.tsx
··· 15 15 import numeral from "numeral"; 16 16 import { useEffect, useState } from "react"; 17 17 import ContentLoader from "react-content-loader"; 18 + import ShareOnBluesky from "../../components/ShareOnBluesky"; 18 19 import { songAtom } from "../../atoms/song"; 19 20 import Disc from "../../components/Icons/Disc"; 20 21 import Shout from "../../components/Shout/Shout"; ··· 329 330 /> 330 331 View on PDSls 331 332 </a> 333 + <ShareOnBluesky 334 + text={`${pathname.includes("/scrobble/") ? "Just scrobbled" : "Listening to"} ${song?.title} by ${song?.albumArtist || song?.artist} on Rocksky 🎵\n${window.location.href}`} 335 + /> 332 336 </div> 333 337 </div> 334 338 </div>