Teal.fm frontend powered by slices.network tealfm-slices.wisp.place
tealfm slices
at main 6.0 kB view raw
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 // Only add plays from the last 24 hours 93 const playedTime = newPlay.getValue("playedTime") as string | null; 94 if (!playedTime) return; 95 96 const playDate = new Date(playedTime); 97 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); 98 99 if (playDate < cutoff) { 100 // Play is too old, don't add it to the feed 101 return; 102 } 103 104 const root = store.getRoot(); 105 const connection = ConnectionHandler.getConnection( 106 root, 107 "App_fmTealAlphaFeedPlays", 108 { sortBy: [{ field: "playedTime", direction: "desc" }] } 109 ); 110 111 if (!connection) return; 112 113 const edge = ConnectionHandler.createEdge( 114 store, 115 connection, 116 newPlay, 117 "FmTealAlphaFeedPlayEdge" 118 ); 119 120 ConnectionHandler.insertEdgeBefore(connection, edge); 121 122 // Update totalCount 123 const totalCountRecord = root.getLinkedRecord("fmTealAlphaFeedPlays", { 124 sortBy: [{ field: "playedTime", direction: "desc" }], 125 }); 126 if (totalCountRecord) { 127 const currentCount = totalCountRecord.getValue("totalCount") as number; 128 if (typeof currentCount === "number") { 129 totalCountRecord.setValue(currentCount + 1, "totalCount"); 130 } 131 } 132 }, 133 }; 134 135 useSubscription(subscriptionConfig); 136 137 useEffect(() => { 138 window.scrollTo(0, 0); 139 }, []); 140 141 const plays = 142 data?.fmTealAlphaFeedPlays?.edges 143 ?.map((edge) => edge.node) 144 .filter((n) => n != null) || []; 145 146 useEffect(() => { 147 if (!loadMoreRef.current || !hasNext) return; 148 149 const observer = new IntersectionObserver( 150 (entries) => { 151 if (entries[0].isIntersecting && hasNext && !isLoadingNext) { 152 loadNext(20); 153 } 154 }, 155 { threshold: 0.1 } 156 ); 157 158 observer.observe(loadMoreRef.current); 159 160 return () => observer.disconnect(); 161 }, [hasNext, isLoadingNext, loadNext]); 162 163 // Group plays by date 164 const groupedPlays: { date: string; plays: typeof plays }[] = []; 165 let currentDate = ""; 166 167 plays.forEach((play) => { 168 if (!play?.playedTime) return; 169 170 const playDate = new Date(play.playedTime).toLocaleDateString("en-US", { 171 weekday: "long", 172 day: "numeric", 173 month: "long", 174 year: "numeric", 175 }); 176 177 if (playDate !== currentDate) { 178 currentDate = playDate; 179 groupedPlays.push({ date: playDate, plays: [play] }); 180 } else { 181 groupedPlays[groupedPlays.length - 1].plays.push(play); 182 } 183 }); 184 185 return ( 186 <Layout headerChart={<ScrobbleChart queryRef={queryData} />}> 187 <div className="mb-8"> 188 <p className="text-xs text-zinc-500 uppercase tracking-wider"> 189 {data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles 190 </p> 191 </div> 192 193 <div> 194 {groupedPlays.map((group, groupIndex) => ( 195 <div key={groupIndex} className="mb-12"> 196 <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider"> 197 {group.date} 198 </h2> 199 <div className="space-y-1"> 200 {group.plays.map((play, index) => ( 201 <TrackItem key={index} play={play} /> 202 ))} 203 </div> 204 </div> 205 ))} 206 </div> 207 208 {hasNext && ( 209 <div ref={loadMoreRef} className="py-12 text-center"> 210 {isLoadingNext ? ( 211 <p className="text-xs text-zinc-600 uppercase tracking-wider"> 212 Loading... 213 </p> 214 ) : ( 215 <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p> 216 )} 217 </div> 218 )} 219 </Layout> 220 ); 221}