Teal.fm frontend powered by slices.network tealfm-slices.wisp.place
tealfm slices
1import { 2 graphql, 3 useLazyLoadQuery, 4 usePaginationFragment, 5 useSubscription, 6} from "react-relay"; 7import { useEffect, useRef, useMemo } from "react"; 8import type { AppQuery } from "./__generated__/AppQuery.graphql"; 9import type { App_plays$key } from "./__generated__/App_plays.graphql"; 10import type { AppSubscription } from "./__generated__/AppSubscription.graphql"; 11import TrackItem from "./TrackItem"; 12import Layout from "./Layout"; 13import ScrobbleChart from "./ScrobbleChart"; 14import { 15 ConnectionHandler, 16 type GraphQLSubscriptionConfig, 17} from "relay-runtime"; 18 19export default function App() { 20 const queryVariables = useMemo(() => { 21 // Round to start of day to keep timestamp stable 22 const now = new Date(); 23 now.setHours(0, 0, 0, 0); 24 const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); 25 26 return { 27 chartWhere: { 28 playedTime: { 29 gte: ninetyDaysAgo.toISOString(), 30 }, 31 }, 32 }; 33 }, []); 34 35 const queryData = useLazyLoadQuery<AppQuery>( 36 graphql` 37 query AppQuery($chartWhere: FmTealAlphaFeedPlayWhereInput!) { 38 ...App_plays 39 ...ScrobbleChart_data 40 } 41 `, 42 queryVariables 43 ); 44 45 const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment< 46 AppQuery, 47 App_plays$key 48 >( 49 graphql` 50 fragment App_plays on Query 51 @refetchable(queryName: "AppPaginationQuery") 52 @argumentDefinitions( 53 cursor: { type: "String" } 54 count: { type: "Int", defaultValue: 20 } 55 ) { 56 fmTealAlphaFeedPlays( 57 first: $count 58 after: $cursor 59 sortBy: [{ field: playedTime, direction: desc }] 60 ) @connection(key: "App_fmTealAlphaFeedPlays", filters: ["sortBy"]) { 61 totalCount 62 edges { 63 node { 64 playedTime 65 ...TrackItem_play 66 } 67 } 68 } 69 } 70 `, 71 queryData 72 ); 73 74 const loadMoreRef = useRef<HTMLDivElement>(null); 75 76 // Subscribe to new plays 77 const subscriptionConfig: GraphQLSubscriptionConfig<AppSubscription> = { 78 subscription: graphql` 79 subscription AppSubscription { 80 fmTealAlphaFeedPlayCreated { 81 uri 82 playedTime 83 ...TrackItem_play 84 } 85 } 86 `, 87 variables: {}, 88 updater: (store) => { 89 const newPlay = store.getRootField("fmTealAlphaFeedPlayCreated"); 90 if (!newPlay) return; 91 92 const root = store.getRoot(); 93 const connection = ConnectionHandler.getConnection( 94 root, 95 "App_fmTealAlphaFeedPlays", 96 { sortBy: [{ field: "playedTime", direction: "desc" }] } 97 ); 98 99 if (!connection) return; 100 101 const edge = ConnectionHandler.createEdge( 102 store, 103 connection, 104 newPlay, 105 "FmTealAlphaFeedPlayEdge" 106 ); 107 108 ConnectionHandler.insertEdgeBefore(connection, edge); 109 110 // Update totalCount 111 const totalCountRecord = root.getLinkedRecord("fmTealAlphaFeedPlays", { 112 sortBy: [{ field: "playedTime", direction: "desc" }], 113 }); 114 if (totalCountRecord) { 115 const currentCount = totalCountRecord.getValue("totalCount") as number; 116 if (typeof currentCount === "number") { 117 totalCountRecord.setValue(currentCount + 1, "totalCount"); 118 } 119 } 120 }, 121 }; 122 123 useSubscription(subscriptionConfig); 124 125 useEffect(() => { 126 window.scrollTo(0, 0); 127 }, []); 128 129 const plays = 130 data?.fmTealAlphaFeedPlays?.edges 131 ?.map((edge) => edge.node) 132 .filter((n) => n != null) || []; 133 134 useEffect(() => { 135 if (!loadMoreRef.current || !hasNext) return; 136 137 const observer = new IntersectionObserver( 138 (entries) => { 139 if (entries[0].isIntersecting && hasNext && !isLoadingNext) { 140 loadNext(20); 141 } 142 }, 143 { threshold: 0.1 } 144 ); 145 146 observer.observe(loadMoreRef.current); 147 148 return () => observer.disconnect(); 149 }, [hasNext, isLoadingNext, loadNext]); 150 151 // Group plays by date 152 const groupedPlays: { date: string; plays: typeof plays }[] = []; 153 let currentDate = ""; 154 155 plays.forEach((play) => { 156 if (!play?.playedTime) return; 157 158 const playDate = new Date(play.playedTime).toLocaleDateString("en-US", { 159 weekday: "long", 160 day: "numeric", 161 month: "long", 162 year: "numeric", 163 }); 164 165 if (playDate !== currentDate) { 166 currentDate = playDate; 167 groupedPlays.push({ date: playDate, plays: [play] }); 168 } else { 169 groupedPlays[groupedPlays.length - 1].plays.push(play); 170 } 171 }); 172 173 return ( 174 <Layout headerChart={<ScrobbleChart queryRef={queryData} />}> 175 <div className="mb-8"> 176 <p className="text-xs text-zinc-500 uppercase tracking-wider"> 177 {data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles 178 </p> 179 </div> 180 181 <div> 182 {groupedPlays.map((group, groupIndex) => ( 183 <div key={groupIndex} className="mb-12"> 184 <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider"> 185 {group.date} 186 </h2> 187 <div className="space-y-1"> 188 {group.plays.map((play, index) => ( 189 <TrackItem key={index} play={play} /> 190 ))} 191 </div> 192 </div> 193 ))} 194 </div> 195 196 {hasNext && ( 197 <div ref={loadMoreRef} className="py-12 text-center"> 198 {isLoadingNext ? ( 199 <p className="text-xs text-zinc-600 uppercase tracking-wider"> 200 Loading... 201 </p> 202 ) : ( 203 <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p> 204 )} 205 </div> 206 )} 207 </Layout> 208 ); 209}