diff --git a/src/adapters/akatsuki-api/authentication.ts b/src/adapters/akatsuki-api/authentication.ts index e74925b..171bf6b 100644 --- a/src/adapters/akatsuki-api/authentication.ts +++ b/src/adapters/akatsuki-api/authentication.ts @@ -21,6 +21,7 @@ export const authenticate = async ( privileges: response.data.privileges, } } catch (e: any) { + console.log(e) throw new Error(e.response.data.user_feedback) } } @@ -29,6 +30,7 @@ export const logout = async () => { try { await authApiInstance.post("/api/v1/logout") } catch (e: any) { + console.log(e) throw new Error(e.response.data.user_feedback) } } diff --git a/src/adapters/akatsuki-api/leaderboards.ts b/src/adapters/akatsuki-api/leaderboards.ts index 5163c7a..8309feb 100644 --- a/src/adapters/akatsuki-api/leaderboards.ts +++ b/src/adapters/akatsuki-api/leaderboards.ts @@ -1,6 +1,6 @@ import axios from "axios" -interface ChosenModeStats { +export interface ChosenModeStats { rankedScore: number totalScore: number playcount: number @@ -12,7 +12,7 @@ interface ChosenModeStats { pp: number globalLeaderboardRank: number countryLeaderboardRank: number - max_combo: number + maxCombo: number } export interface LeaderboardUser { @@ -90,6 +90,7 @@ export const fetchLeaderboard = async ( })), } } catch (e: any) { + console.log(e) throw new Error(e.response.data.user_feedback) } } diff --git a/src/adapters/akatsuki-api/search.ts b/src/adapters/akatsuki-api/search.ts index dc6b943..518a14a 100644 --- a/src/adapters/akatsuki-api/search.ts +++ b/src/adapters/akatsuki-api/search.ts @@ -37,6 +37,7 @@ export const searchUsers = async ( : null, } } catch (e: any) { + console.log(e) throw new Error(e.response.data.user_feedback) } } diff --git a/src/adapters/akatsuki-api/userScores.ts b/src/adapters/akatsuki-api/userScores.ts new file mode 100644 index 0000000..0298af1 --- /dev/null +++ b/src/adapters/akatsuki-api/userScores.ts @@ -0,0 +1,131 @@ +import axios from "axios" + +interface UserScoresRequest { + type: "best" | "recent" + mode: number + p: number + l: number + rx: number + id: number +} + +export interface UserScoreBeatmap { + beatmapId: number + beatmapsetId: number + beatmapMd5: string + songName: string + ar: number + od: number + difficulty: number + difficulty2?: { + std: number + taiko: number + ctb: number + mania: number + } + maxCombo: number + hitLength: number + ranked: number + rankedStatusFrozen: number + latestUpdate: string +} + +export interface UserScore { + id: string + beatmapMd5: string + score: number + maxCombo: number + fullCombo: boolean + mods: number + count300: number + count100: number + count50: number + countGeki: number + countKatu: number + countMiss: number + time: string + playMode: number + accuracy: number + pp: number + rank: string + completed: number + pinned: boolean + beatmap: UserScoreBeatmap +} + +export interface UserScoresResponse { + code: number + scores: UserScore[] +} + +const scoresApiInstance = axios.create({ + baseURL: process.env.REACT_APP_SCORES_API_BASE_URL, +}) + +export const fetchUserScores = async ( + request: UserScoresRequest +): Promise => { + try { + const response = await scoresApiInstance.get( + `/v1/users/scores/${request.type}`, + { + params: { + mode: request.mode, + rx: request.rx, + p: request.p, + l: request.l, + id: request.id, + }, + } + ) + return { + code: response.status, + scores: response.data.scores.map((score: any) => ({ + id: score.id, + beatmapMd5: score.beatmap_md5, + score: score.score, + maxCombo: score.max_combo, + fullCombo: score.full_combo, + mods: score.mods, + count300: score.count_300, + count100: score.count_100, + count50: score.count_50, + countGeki: score.count_geki, + countKatu: score.count_katu, + countMiss: score.count_miss, + time: score.time, + playMode: score.play_mode, + accuracy: score.accuracy, + pp: score.pp, + rank: score.rank, + completed: score.completed, + pinned: score.pinned, + beatmap: { + beatmapId: score.beatmap.beatmap_id, + beatmapsetId: score.beatmap.beatmapset_id, + beatmapMd5: score.beatmap.beatmap_md5, + songName: score.beatmap.song_name, + ar: score.beatmap.ar, + od: score.beatmap.od, + difficulty: score.beatmap.difficulty, + difficulty2: score.beatmap.difficulty_2 + ? { + std: score.beatmap.difficulty_2.std, + taiko: score.beatmap.difficulty_2.taiko, + ctb: score.beatmap.difficulty_2.ctb, + mania: score.beatmap.difficulty_2.mania, + } + : null, + maxCombo: score.beatmap.max_combo, + hitLength: score.beatmap.hit_length, + ranked: score.beatmap.ranked, + rankedStatusFrozen: score.beatmap.ranked_status_frozen, + latestUpdate: score.beatmap.latest_update, + }, + })), + } + } catch (e: any) { + console.log(e) + throw new Error(e.response.data.user_feedback) + } +} diff --git a/src/adapters/akatsuki-api/users.ts b/src/adapters/akatsuki-api/users.ts new file mode 100644 index 0000000..91a6b16 --- /dev/null +++ b/src/adapters/akatsuki-api/users.ts @@ -0,0 +1,197 @@ +import axios from "axios" + +export interface UserResponse { + id: number + username: string + usernameAka: string + registeredOn: Date + privileges: number + latestActivity: Date + country: string +} + +export interface UserStats { + rankedScore: number + totalScore: number + playcount: number + playtime: number + replaysWatched: number + totalHits: number + level: number + accuracy: number + pp: number + globalLeaderboardRank: number + countryLeaderboardRank: number + maxCombo: number +} + +export interface AllModeUserStats { + std: UserStats + taiko: UserStats + ctb: UserStats + mania: UserStats +} + +export interface UserBadge { + id: number + name: string + icon: string + colour: string +} + +export interface UserTournamentBadge { + id: number + name: string + icon: string +} + +export interface UserClan { + id: number + name: string + tag: string + description: string + icon: string + owner: number + status: number // todo enum +} + +export interface UserSilenceInfo { + reason: string + end: Date +} + +export interface UserFullResponse extends UserResponse { + stats: AllModeUserStats[] + playStyle: number // todo enum + favouriteMode: number // todo enum + badges: UserBadge[] + clan: UserClan + followers: number + tbadges?: UserTournamentBadge[] + customBadge: UserBadge + silenceInfo: UserSilenceInfo + + // only visible to admins + cmNotes?: string[] // TODO + banDate?: Date + email?: string +} + +const userApiInstance = axios.create({ + baseURL: process.env.REACT_APP_USER_API_BASE_URL, +}) + +export const fetchUser = async (userId: number): Promise => { + try { + const response = await userApiInstance.get("/v1/users/full", { + params: { id: userId }, + }) + return { + id: response.data.id, + username: response.data.username, + usernameAka: response.data.username_aka, + registeredOn: new Date(response.data.registered_on), + privileges: response.data.privileges, + latestActivity: new Date(response.data.latest_activity), + country: response.data.country, + stats: response.data.stats.map((stats: any) => ({ + std: { + rankedScore: stats.std.ranked_score, + totalScore: stats.std.total_score, + playcount: stats.std.playcount, + playtime: stats.std.playtime, + replaysWatched: stats.std.replays_watched, + totalHits: stats.std.total_hits, + level: stats.std.level, + accuracy: stats.std.accuracy, + pp: stats.std.pp, + globalLeaderboardRank: stats.std.global_leaderboard_rank, + countryLeaderboardRank: stats.std.country_leaderboard_rank, + maxCombo: stats.std.max_combo, + }, + taiko: { + rankedScore: stats.taiko.ranked_score, + totalScore: stats.taiko.total_score, + playcount: stats.taiko.playcount, + playtime: stats.taiko.playtime, + replaysWatched: stats.taiko.replays_watched, + totalHits: stats.taiko.total_hits, + level: stats.taiko.level, + accuracy: stats.taiko.accuracy, + pp: stats.taiko.pp, + globalLeaderboardRank: stats.taiko.global_leaderboard_rank, + countryLeaderboardRank: stats.taiko.country_leaderboard_rank, + maxCombo: stats.taiko.max_combo, + }, + ctb: { + rankedScore: stats.ctb.ranked_score, + totalScore: stats.ctb.total_score, + playcount: stats.ctb.playcount, + playtime: stats.ctb.playtime, + replaysWatched: stats.ctb.replays_watched, + totalHits: stats.ctb.total_hits, + level: stats.ctb.level, + accuracy: stats.ctb.accuracy, + pp: stats.ctb.pp, + globalLeaderboardRank: stats.ctb.global_leaderboard_rank, + countryLeaderboardRank: stats.ctb.country_leaderboard_rank, + maxCombo: stats.ctb.max_combo, + }, + mania: { + rankedScore: stats.mania.ranked_score, + totalScore: stats.mania.total_score, + playcount: stats.mania.playcount, + playtime: stats.mania.playtime, + replaysWatched: stats.mania.replays_watched, + totalHits: stats.mania.total_hits, + level: stats.mania.level, + accuracy: stats.mania.accuracy, + pp: stats.mania.pp, + globalLeaderboardRank: stats.mania.global_leaderboard_rank, + countryLeaderboardRank: stats.mania.country_leaderboard_rank, + maxCombo: stats.mania.max_combo, + }, + })), + playStyle: response.data.play_style, + favouriteMode: response.data.favourite_mode, + badges: response.data.badges.map((badge: any) => ({ + id: badge.id, + name: badge.name, + icon: badge.icon, + colour: badge.colour, + })), + clan: { + id: response.data.clan.id, + name: response.data.clan.name, + tag: response.data.clan.tag, + description: response.data.clan.description, + icon: response.data.clan.icon, + owner: response.data.clan.owner, + status: response.data.clan.status, + }, + followers: response.data.followers, + tbadges: response.data.tbadges?.map((tbadge: any) => ({ + id: tbadge.id, + name: tbadge.name, + icon: tbadge.icon, + })), + customBadge: { + id: response.data.custom_badge.id, + name: response.data.custom_badge.name, + icon: response.data.custom_badge.icon, + colour: response.data.custom_badge.colour, + }, + silenceInfo: { + reason: response.data.silence_info.reason, + end: new Date(response.data.silence_info.end), + }, + // TODO? + cmNotes: response.data.cm_notes, + banDate: response.data.ban_date, + email: response.data.email, + } + } catch (e: any) { + console.log(e) + throw new Error(e.response.data.user_feedback) + } +} diff --git a/src/components/ProfileSelectionBar.tsx b/src/components/ProfileSelectionBar.tsx new file mode 100644 index 0000000..a6de5f1 --- /dev/null +++ b/src/components/ProfileSelectionBar.tsx @@ -0,0 +1,77 @@ +import { Button, Stack } from "@mui/material" +import { GameMode, RelaxMode, isRealGameMode } from "../gameModes" + +export const ProfileSelectionBar = ({ + gameMode, + relaxMode, + setGameMode, + setRelaxMode, +}: { + gameMode: GameMode + relaxMode: RelaxMode + setGameMode: (gameMode: GameMode) => void + setRelaxMode: (relaxMode: RelaxMode) => void +}): JSX.Element => { + return ( + <> + + + + + + + + + + + + + {/* TODO: country and sort param selection */} + + + ) +} diff --git a/src/components/UserProfileHistoryGraph.tsx b/src/components/UserProfileHistoryGraph.tsx new file mode 100644 index 0000000..bf94cb5 --- /dev/null +++ b/src/components/UserProfileHistoryGraph.tsx @@ -0,0 +1,7 @@ +export const UserProfileHistoryGraph = ({ + rankHistoryData, +}: { + rankHistoryData: any +}) => { + return <>Profile history graph here +} diff --git a/src/components/UserProfileScores.tsx b/src/components/UserProfileScores.tsx new file mode 100644 index 0000000..7314681 --- /dev/null +++ b/src/components/UserProfileScores.tsx @@ -0,0 +1,11 @@ +import { type UserScore } from "../adapters/akatsuki-api/userScores" + +export const UserProfileScores = ({ + scoresData, + title, +}: { + scoresData: UserScore[] + title: string +}) => { + return <>Profile scores here +} diff --git a/src/components/UserProfileStats.tsx b/src/components/UserProfileStats.tsx new file mode 100644 index 0000000..fd10099 --- /dev/null +++ b/src/components/UserProfileStats.tsx @@ -0,0 +1,5 @@ +import { type UserStats } from "../adapters/akatsuki-api/users" + +export const UserProfileStats = ({ statsData }: { statsData: UserStats }) => { + return <>Profile stats here +} diff --git a/src/pages/LeaderboardsPage.tsx b/src/pages/LeaderboardsPage.tsx index a7099f9..dcc00e2 100644 --- a/src/pages/LeaderboardsPage.tsx +++ b/src/pages/LeaderboardsPage.tsx @@ -34,9 +34,8 @@ export const LeaderboardsPage = () => { useEffect(() => { ;(async () => { - let leaderboardResponse try { - leaderboardResponse = await fetchLeaderboard({ + const leaderboardResponse = await fetchLeaderboard({ mode: gameMode, rx: relaxMode, p: 1, @@ -44,12 +43,11 @@ export const LeaderboardsPage = () => { country: country, sort: sortParam, }) + setLeaderboardData(leaderboardResponse) } catch (e: any) { setError("Failed to fetch data from server") return } - - setLeaderboardData(leaderboardResponse) })() }, [gameMode, relaxMode, country, sortParam]) diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 2047aea..6a56189 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,7 +1,236 @@ +import { useParams } from "react-router-dom" +import { Typography, Paper, Alert, Avatar } from "@mui/material" +import Stack from "@mui/material/Stack" +import Box from "@mui/material/Box" +import { useEffect, useState } from "react" +import PublicIcon from "@mui/icons-material/Public" +import WifiIcon from "@mui/icons-material/Wifi" +import WifiOffIcon from "@mui/icons-material/WifiOff" +import { GameMode, RelaxMode } from "../gameModes" +import { getFlagUrl } from "../utils/countries" +import { ProfileSelectionBar } from "../components/ProfileSelectionBar" +import { + fetchUserScores, + UserScoresResponse, +} from "../adapters/akatsuki-api/userScores" +import { fetchUser, UserFullResponse } from "../adapters/akatsuki-api/users" import { useIdentityContext } from "../context" +import { UserProfileHistoryGraph } from "../components/UserProfileHistoryGraph" +import { UserProfileStats } from "../components/UserProfileStats" +import { UserProfileScores } from "../components/UserProfileScores" export const ProfilePage = () => { const { identity } = useIdentityContext() + const { userId: profileUserId } = useParams() - return <> + // TODO: better error handling; each component in the page + // may be able to raise multiple errors; one global one won't cut it + const [error, setError] = useState("") + + const [userProfile, setUserProfile] = useState(null) + const rankHistoryData = null // TODO + const isOnline = true // TODO + + const [gameMode, setGameMode] = useState(GameMode.Standard) + const [relaxMode, setRelaxMode] = useState(RelaxMode.Vanilla) + + const [bestScores, setBestScores] = useState(null) + const [recentScores, setRecentScores] = useState( + null + ) + + useEffect(() => { + const fetchProfileAccount = async () => { + if (!profileUserId) return + + try { + const usersResponse = await fetchUser(parseInt(profileUserId)) + setUserProfile(usersResponse) + } catch (e: any) { + setError("Failed to fetch user profile data from server") + return + } + } + + // run this asynchronously + fetchProfileAccount().catch(console.error) + }, [profileUserId]) + + useEffect(() => { + if (!profileUserId) return + ;(async () => { + try { + const playerBestScores = await fetchUserScores({ + type: "best", + mode: gameMode, + p: 1, // todo pagination + l: 50, + rx: relaxMode, + id: parseInt(profileUserId), + }) + setBestScores(playerBestScores) + } catch (e: any) { + setError("Failed to fetch best scores data from server") + return + } + })() + }, [profileUserId, gameMode, relaxMode]) + + useEffect(() => { + if (!profileUserId) return + ;(async () => { + try { + const playerRecentScores = await fetchUserScores({ + type: "recent", + mode: gameMode, + p: 1, // todo pagination + l: 50, + rx: relaxMode, + id: parseInt(profileUserId), + }) + setRecentScores(playerRecentScores) + } catch (e: any) { + setError("Failed to fetch recent scores data from server") + return + } + })() + }, [profileUserId, gameMode, relaxMode]) + + if (!profileUserId) { + return ( + <> + + Must provide an account id in the path. + + + ) + } + + if (error) { + return ( + + Something went wrong while loading the page {error} + + ) + } + if (!userProfile || !bestScores || !recentScores) { + return <>loading data + } + + const modeToStatsIndex = ( + mode: GameMode + ): "std" | "taiko" | "ctb" | "mania" => { + switch (mode) { + case GameMode.Standard: + return "std" + case GameMode.Taiko: + return "taiko" + case GameMode.Catch: + return "ctb" + case GameMode.Mania: + return "mania" + } + } + + return ( + <> + + + + {/* Avatar / Name / Online Status */} + + + + + + {userProfile.username} + {isOnline ? ( + + + Online + + ) : ( + + + Offline + + )} + + + + {/* TODO: add a method for fetching global & country rank from the backend */} + + + # + {userProfile.stats[relaxMode][modeToStatsIndex(gameMode)] + .globalLeaderboardRank ?? "N/A"} + + + + + + # + {userProfile.stats[relaxMode][modeToStatsIndex(gameMode)] + .countryLeaderboardRank ?? "N/A"} + + + + + + + + + + + + + + {/* TODO: figure out how to model rank vs. pp/score/etc. */} + + + + + + + + + + + + + ) }