|
| 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}'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 | +} |
0 commit comments