Skip to content

Commit 06f0838

Browse files
Implement UserGameList for viewing other players' games
Co-authored-by: kevinjosethomas <46242684+kevinjosethomas@users.noreply.github.com>
1 parent daba63a commit 06f0838

4 files changed

Lines changed: 365 additions & 2 deletions

File tree

src/api/analysis/analysis.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,22 @@ export const getAnalysisList = async (): Promise<
7676
return data
7777
}
7878

79-
export const getAnalysisGameList = async (type = 'play', page = 1) => {
80-
const res = await fetch(buildUrl(`analysis/user/list/${type}/${page}`))
79+
export const getAnalysisGameList = async (
80+
type = 'play',
81+
page = 1,
82+
lichessId?: string,
83+
) => {
84+
const url = buildUrl(`analysis/user/list/${type}/${page}`)
85+
const searchParams = new URLSearchParams()
86+
87+
if (lichessId) {
88+
searchParams.append('lichess_id', lichessId)
89+
}
90+
91+
const fullUrl = searchParams.toString()
92+
? `${url}?${searchParams.toString()}`
93+
: url
94+
const res = await fetch(fullUrl)
8195

8296
if (res.status === 401) {
8397
throw new Error('Unauthorized')
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
import { motion } from 'framer-motion'
2+
import React, { useState, useEffect } from 'react'
3+
4+
import { AnalysisWebGame } from 'src/types'
5+
import { getAnalysisGameList } from 'src/api'
6+
7+
interface GameData {
8+
game_id: string
9+
maia_name: string
10+
result: string
11+
player_color: 'white' | 'black'
12+
}
13+
14+
interface UserGameListProps {
15+
lichessId: string
16+
userName: string
17+
}
18+
19+
export const UserGameList = ({ lichessId, userName }: UserGameListProps) => {
20+
const [selected, setSelected] = useState<'play' | 'hb'>('play')
21+
const [hbSubsection, setHbSubsection] = useState<'hand' | 'brain'>('hand')
22+
const [playGames, setPlayGames] = useState<AnalysisWebGame[]>([])
23+
const [handGames, setHandGames] = useState<AnalysisWebGame[]>([])
24+
const [brainGames, setBrainGames] = useState<AnalysisWebGame[]>([])
25+
const [currentPage, setCurrentPage] = useState(1)
26+
const [totalPages, setTotalPages] = useState(1)
27+
const [loading, setLoading] = useState(false)
28+
29+
const [fetchedCache, setFetchedCache] = useState<{
30+
[key: string]: { [page: number]: boolean }
31+
}>({
32+
play: {},
33+
hand: {},
34+
brain: {},
35+
})
36+
37+
const [totalPagesCache, setTotalPagesCache] = useState<{
38+
[key: string]: number
39+
}>({})
40+
41+
const [currentPagePerTab, setCurrentPagePerTab] = useState<{
42+
[key: string]: number
43+
}>({
44+
play: 1,
45+
hand: 1,
46+
brain: 1,
47+
})
48+
49+
useEffect(() => {
50+
if (lichessId) {
51+
const gameType = selected === 'hb' ? hbSubsection : selected
52+
const isAlreadyFetched = fetchedCache[gameType]?.[currentPage]
53+
54+
if (!isAlreadyFetched) {
55+
setLoading(true)
56+
57+
setFetchedCache((prev) => ({
58+
...prev,
59+
[gameType]: { ...prev[gameType], [currentPage]: true },
60+
}))
61+
62+
getAnalysisGameList(gameType, currentPage, lichessId)
63+
.then((data) => {
64+
const parse = (
65+
game: {
66+
game_id: string
67+
maia_name: string
68+
result: string
69+
player_color: 'white' | 'black'
70+
},
71+
type: string,
72+
) => {
73+
const raw = game.maia_name.replace('_kdd_', ' ')
74+
const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
75+
76+
return {
77+
id: game.game_id,
78+
label:
79+
game.player_color === 'white'
80+
? `${userName} vs. ${maia}`
81+
: `${maia} vs. ${userName}`,
82+
result: game.result,
83+
type,
84+
}
85+
}
86+
87+
const parsedGames = data.games.map((game: GameData) =>
88+
parse(game, gameType),
89+
)
90+
const calculatedTotalPages =
91+
data.total_pages || Math.ceil(data.total_games / 25)
92+
93+
setTotalPagesCache((prev) => ({
94+
...prev,
95+
[gameType]: calculatedTotalPages,
96+
}))
97+
98+
if (gameType === 'play') {
99+
setPlayGames(parsedGames)
100+
} else if (gameType === 'hand') {
101+
setHandGames(parsedGames)
102+
} else if (gameType === 'brain') {
103+
setBrainGames(parsedGames)
104+
}
105+
106+
setLoading(false)
107+
})
108+
.catch(() => {
109+
setFetchedCache((prev) => {
110+
const newCache = { ...prev }
111+
delete newCache[gameType][currentPage]
112+
return newCache
113+
})
114+
setLoading(false)
115+
})
116+
}
117+
}
118+
}, [lichessId, selected, hbSubsection, currentPage, fetchedCache, userName])
119+
120+
useEffect(() => {
121+
if (selected === 'hb') {
122+
const gameType = hbSubsection
123+
if (totalPagesCache[gameType]) {
124+
setTotalPages(totalPagesCache[gameType])
125+
}
126+
setCurrentPage(currentPagePerTab[gameType])
127+
} else if (totalPagesCache[selected]) {
128+
setTotalPages(totalPagesCache[selected])
129+
}
130+
131+
if (selected !== 'hb') {
132+
setCurrentPage(currentPagePerTab[selected])
133+
}
134+
}, [selected, hbSubsection, totalPagesCache, currentPagePerTab])
135+
136+
const handlePageChange = (newPage: number) => {
137+
if (newPage >= 1 && newPage <= totalPages) {
138+
setCurrentPage(newPage)
139+
if (selected === 'hb') {
140+
const gameType = hbSubsection
141+
setCurrentPagePerTab((prev) => ({
142+
...prev,
143+
[gameType]: newPage,
144+
}))
145+
} else {
146+
setCurrentPagePerTab((prev) => ({
147+
...prev,
148+
[selected]: newPage,
149+
}))
150+
}
151+
}
152+
}
153+
154+
const handleTabChange = (newTab: 'play' | 'hb') => {
155+
setSelected(newTab)
156+
}
157+
158+
const getCurrentGames = () => {
159+
if (selected === 'play') {
160+
return playGames
161+
} else if (selected === 'hb') {
162+
return hbSubsection === 'hand' ? handGames : brainGames
163+
}
164+
return []
165+
}
166+
167+
return (
168+
<div className="flex w-full flex-col overflow-hidden rounded border border-white border-opacity-10 md:w-[600px]">
169+
<div className="flex flex-row items-center justify-start gap-4 border-b border-white border-opacity-10 bg-background-1 px-2 py-3 md:px-4">
170+
<p className="text-xl font-bold md:text-xl">{userName}&apos;s Games</p>
171+
</div>
172+
<div className="grid select-none grid-cols-2 border-b-2 border-white border-opacity-10">
173+
<Header
174+
label="Play"
175+
name="play"
176+
selected={selected}
177+
setSelected={handleTabChange}
178+
/>
179+
<Header
180+
label="H&B"
181+
name="hb"
182+
selected={selected}
183+
setSelected={handleTabChange}
184+
/>
185+
</div>
186+
187+
{/* H&B Subsections */}
188+
{selected === 'hb' && (
189+
<div className="flex border-b border-white border-opacity-10">
190+
<button
191+
onClick={() => setHbSubsection('hand')}
192+
className={`flex-1 px-3 py-1.5 text-sm ${
193+
hbSubsection === 'hand'
194+
? 'bg-background-2 text-primary'
195+
: 'bg-background-1/50 text-secondary hover:bg-background-2'
196+
}`}
197+
>
198+
<div className="flex items-center justify-center gap-2">
199+
<span className="material-symbols-outlined text-xs">
200+
hand_gesture
201+
</span>
202+
<span className="text-xs">Hand ({handGames.length})</span>
203+
</div>
204+
</button>
205+
<button
206+
onClick={() => setHbSubsection('brain')}
207+
className={`flex-1 px-3 py-1.5 text-sm ${
208+
hbSubsection === 'brain'
209+
? 'bg-background-2 text-primary'
210+
: 'bg-background-1/50 text-secondary hover:bg-background-2'
211+
}`}
212+
>
213+
<div className="flex items-center justify-center gap-2">
214+
<span className="material-symbols-outlined text-xs">
215+
psychology
216+
</span>
217+
<span className="text-xs">Brain ({brainGames.length})</span>
218+
</div>
219+
</button>
220+
</div>
221+
)}
222+
223+
<div className="red-scrollbar flex max-h-64 flex-col overflow-y-scroll md:max-h-96">
224+
{loading ? (
225+
<div className="flex h-full items-center justify-center py-8">
226+
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-white"></div>
227+
</div>
228+
) : (
229+
<>
230+
{getCurrentGames().map((game, index) => (
231+
<a
232+
key={index}
233+
href={`/analysis/${game.id}/${game.type}`}
234+
className={`group flex w-full cursor-pointer items-center gap-2 pr-2 ${
235+
index % 2 === 0
236+
? 'bg-background-1/30 hover:bg-background-2'
237+
: 'bg-background-1/10 hover:bg-background-2'
238+
}`}
239+
>
240+
<div className="flex h-full w-10 items-center justify-center bg-background-2 py-1 group-hover:bg-white/5">
241+
<p className="text-sm text-secondary">
242+
{(currentPage - 1) * 100 + index + 1}
243+
</p>
244+
</div>
245+
<div className="flex flex-1 items-center justify-between overflow-hidden py-1">
246+
<div className="flex items-center gap-2 overflow-hidden">
247+
<p className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-primary">
248+
{game.label}
249+
</p>
250+
</div>
251+
<p className="whitespace-nowrap text-sm text-secondary">
252+
{game.result}
253+
</p>
254+
</div>
255+
</a>
256+
))}
257+
</>
258+
)}
259+
</div>
260+
261+
{/* Pagination */}
262+
{totalPages > 1 && (
263+
<div className="flex items-center justify-center gap-2 border-t border-white border-opacity-10 bg-background-1 py-2">
264+
<button
265+
onClick={() => handlePageChange(1)}
266+
disabled={currentPage === 1}
267+
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
268+
>
269+
<span className="material-symbols-outlined">first_page</span>
270+
</button>
271+
<button
272+
onClick={() => handlePageChange(currentPage - 1)}
273+
disabled={currentPage === 1}
274+
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
275+
>
276+
<span className="material-symbols-outlined">arrow_back_ios</span>
277+
</button>
278+
<span className="text-sm text-secondary">
279+
Page {currentPage} of {totalPages}
280+
</span>
281+
<button
282+
onClick={() => handlePageChange(currentPage + 1)}
283+
disabled={currentPage === totalPages}
284+
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
285+
>
286+
<span className="material-symbols-outlined">arrow_forward_ios</span>
287+
</button>
288+
<button
289+
onClick={() => handlePageChange(totalPages)}
290+
disabled={currentPage === totalPages}
291+
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
292+
>
293+
<span className="material-symbols-outlined">last_page</span>
294+
</button>
295+
</div>
296+
)}
297+
</div>
298+
)
299+
}
300+
301+
function Header({
302+
name,
303+
label,
304+
selected,
305+
setSelected,
306+
}: {
307+
label: string
308+
name: 'play' | 'hb'
309+
selected: 'play' | 'hb'
310+
setSelected: (name: 'play' | 'hb') => void
311+
}) {
312+
return (
313+
<button
314+
onClick={() => setSelected(name)}
315+
className={`relative flex items-center justify-center py-0.5 ${
316+
selected === name
317+
? 'bg-human-4/30'
318+
: 'bg-background-1/80 hover:bg-background-2'
319+
} transition duration-200`}
320+
>
321+
<div className="flex items-center justify-start gap-1">
322+
<p
323+
className={`text-sm transition duration-200 ${
324+
selected === name ? 'text-human-2' : 'text-primary'
325+
}`}
326+
>
327+
{label}
328+
</p>
329+
<i
330+
className={`material-symbols-outlined text-base transition duration-200 ${
331+
selected === name ? 'text-human-2/80' : 'text-primary/80'
332+
}`}
333+
>
334+
keyboard_arrow_down
335+
</i>
336+
</div>
337+
{selected === name && (
338+
<motion.div
339+
layoutId="underline"
340+
className="absolute -bottom-0.5 h-0.5 w-full bg-human-2/80"
341+
></motion.div>
342+
)}
343+
</button>
344+
)
345+
}

src/components/Profile/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './GameList'
2+
export * from './UserGameList'
23
export * from './UserProfile'
34
export * from './ProfileColumn'

src/pages/profile/[name].tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { WindowSizeContext } from 'src/contexts'
1010
import {
1111
AuthenticatedWrapper,
1212
UserProfile,
13+
UserGameList,
1314
DelayedLoading,
1415
} from 'src/components'
1516

@@ -167,6 +168,7 @@ const Profile: React.FC<Props> = (props: Props) => {
167168
variants={itemVariants}
168169
className="flex w-full flex-col items-start gap-6 md:flex-row"
169170
>
171+
<UserGameList lichessId={props.name} userName={props.name} />
170172
<UserProfile stats={props.stats} wide />
171173
</motion.div>
172174
</motion.div>
@@ -194,6 +196,7 @@ const Profile: React.FC<Props> = (props: Props) => {
194196
variants={itemVariants}
195197
className="flex w-full flex-col gap-6"
196198
>
199+
<UserGameList lichessId={props.name} userName={props.name} />
197200
<UserProfile stats={props.stats} wide />
198201
</motion.div>
199202
</motion.div>

0 commit comments

Comments
 (0)