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