Teal.fm frontend powered by slices.network tealfm-slices.wisp.place
tealfm slices
1import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay"; 2import { useParams, Link } from "react-router-dom"; 3import { useEffect, useRef } from "react"; 4import type { ProfileQuery as ProfileQueryType } from "./__generated__/ProfileQuery.graphql"; 5import type { Profile_plays$key } from "./__generated__/Profile_plays.graphql"; 6import TrackItem from "./TrackItem"; 7 8export default function Profile() { 9 const { handle } = useParams<{ handle: string }>(); 10 11 const queryData = useLazyLoadQuery<ProfileQueryType>( 12 graphql` 13 query ProfileQuery($where: JSON!) { 14 ...Profile_plays @arguments(where: $where) 15 } 16 `, 17 { 18 where: { actorHandle: { eq: handle } }, 19 } 20 ); 21 22 const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment< 23 ProfileQueryType, 24 Profile_plays$key 25 >( 26 graphql` 27 fragment Profile_plays on Query 28 @refetchable(queryName: "ProfilePaginationQuery") 29 @argumentDefinitions( 30 cursor: { type: "String" } 31 count: { type: "Int", defaultValue: 20 } 32 where: { type: "JSON!" } 33 ) { 34 fmTealAlphaFeedPlays( 35 first: $count 36 after: $cursor 37 sortBy: [{ field: "playedTime", direction: desc }] 38 where: $where 39 ) 40 @connection( 41 key: "Profile_fmTealAlphaFeedPlays" 42 filters: ["where", "sortBy"] 43 ) { 44 totalCount 45 edges { 46 node { 47 ...TrackItem_play 48 actorHandle 49 appBskyActorProfile { 50 displayName 51 description 52 avatar { 53 url(preset: "avatar") 54 } 55 } 56 } 57 } 58 } 59 } 60 `, 61 queryData 62 ); 63 64 const loadMoreRef = useRef<HTMLDivElement>(null); 65 66 const plays = data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) => n != null) || []; 67 const profile = plays?.[0]?.appBskyActorProfile; 68 69 useEffect(() => { 70 window.scrollTo(0, 0); 71 }, [handle]); 72 73 useEffect(() => { 74 if (!loadMoreRef.current || !hasNext) return; 75 76 const observer = new IntersectionObserver( 77 (entries) => { 78 if (entries[0].isIntersecting && hasNext && !isLoadingNext) { 79 loadNext(20); 80 } 81 }, 82 { threshold: 0.1 } 83 ); 84 85 observer.observe(loadMoreRef.current); 86 87 return () => observer.disconnect(); 88 }, [hasNext, isLoadingNext, loadNext]); 89 90 return ( 91 <div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono"> 92 <div className="max-w-4xl mx-auto px-6 py-12"> 93 <Link 94 to="/" 95 className="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors inline-block mb-8" 96 > 97 Back 98 </Link> 99 100 <div className="mb-12 flex items-start gap-6 border-b border-zinc-800 pb-6"> 101 {profile?.avatar?.url && ( 102 <img 103 src={profile.avatar.url} 104 alt={profile.displayName ?? handle ?? "User"} 105 className="w-16 h-16 flex-shrink-0 object-cover" 106 /> 107 )} 108 <div className="flex-1"> 109 <h1 className="text-lg font-medium mb-1 text-zinc-100"> 110 {profile?.displayName ?? handle} 111 </h1> 112 <p className="text-xs text-zinc-500 mb-2">@{handle}</p> 113 {profile?.description && ( 114 <p className="text-xs text-zinc-400">{profile.description}</p> 115 )} 116 </div> 117 </div> 118 119 <div className="mb-8"> 120 <h2 className="text-sm font-medium uppercase tracking-wider text-zinc-400 mb-2">Recent Tracks</h2> 121 <p className="text-xs text-zinc-500 uppercase tracking-wider"> 122 {(data?.fmTealAlphaFeedPlays?.totalCount ?? 0).toLocaleString()} scrobbles 123 </p> 124 </div> 125 126 <div className="space-y-1"> 127 {plays && plays.length > 0 ? ( 128 plays.map((play, index) => <TrackItem key={index} play={play} />) 129 ) : ( 130 <p className="text-zinc-600 text-center py-8 text-xs uppercase tracking-wider"> 131 No tracks found for this user 132 </p> 133 )} 134 </div> 135 136 {hasNext && ( 137 <div ref={loadMoreRef} className="py-12 text-center"> 138 {isLoadingNext ? ( 139 <p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p> 140 ) : ( 141 <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p> 142 )} 143 </div> 144 )} 145 </div> 146 </div> 147 ); 148}