forked from
chadtmiller.com/slices-teal-relay
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}