aboutsummaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/pages/About/About.tsx36
-rw-r--r--frontend/src/pages/Games/Games.tsx29
-rw-r--r--frontend/src/pages/Home/Homepage.tsx31
-rw-r--r--frontend/src/pages/Maplist/Maplist.tsx249
-rw-r--r--frontend/src/pages/Maps/Maps.tsx185
-rw-r--r--frontend/src/pages/Profile/Profile.tsx633
-rw-r--r--frontend/src/pages/Rankings/Rankings.tsx203
-rw-r--r--frontend/src/pages/Rules/Rules.tsx37
-rw-r--r--frontend/src/pages/User/User.tsx410
9 files changed, 1813 insertions, 0 deletions
diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx
new file mode 100644
index 0000000..7802d75
--- /dev/null
+++ b/frontend/src/pages/About/About.tsx
@@ -0,0 +1,36 @@
1import React from "react";
2import ReactMarkdown from "react-markdown";
3import { Helmet } from "react-helmet";
4
5const About: React.FC = () => {
6 const [aboutText, setAboutText] = React.useState<string>("");
7
8 React.useEffect(() => {
9 const fetchReadme = async () => {
10 try {
11 const response = await fetch(
12 "https://raw.githubusercontent.com/pektezol/lphub/main/README.md"
13 );
14 if (!response.ok) {
15 throw new Error("Failed to fetch README");
16 }
17 const readmeText = await response.text();
18 setAboutText(readmeText);
19 } catch (error) {
20 console.error("Error fetching README:", error);
21 }
22 };
23 fetchReadme();
24 }, []);
25
26 return (
27 <div className="ml-16 p-8 text-foreground font-[--font-barlow-semicondensed-regular] prose prose-invert max-w-none">
28 <Helmet>
29 <title>LPHUB | About</title>
30 </Helmet>
31 <ReactMarkdown className={"overflow-auto"}>{aboutText}</ReactMarkdown>
32 </div>
33 );
34};
35
36export default About;
diff --git a/frontend/src/pages/Games/Games.tsx b/frontend/src/pages/Games/Games.tsx
new file mode 100644
index 0000000..e23b245
--- /dev/null
+++ b/frontend/src/pages/Games/Games.tsx
@@ -0,0 +1,29 @@
1import React from "react";
2import { Helmet } from "react-helmet";
3
4import GameEntry from "@components/GameEntry.tsx";
5import { Game } from "@customTypes/Game.ts";
6
7interface GamesProps {
8 games: Game[];
9}
10
11const Games: React.FC<GamesProps> = ({ games }) => {
12 return (
13 <div className="ml-20 min-h-screen text-foreground font-[--font-barlow-semicondensed-regular] overflow-y-auto scrollbar-thin">
14 <Helmet>
15 <title>LPHUB | Games</title>
16 </Helmet>
17 <section className="py-12 px-12 w-full">
18 <h1 className="text-3xl font-bold mb-8">Games</h1>
19 <div className="flex flex-col w-full">
20 {games.map((game, index) => (
21 <GameEntry game={game} key={index} />
22 ))}
23 </div>
24 </section>
25 </div>
26 );
27};
28
29export default Games;
diff --git a/frontend/src/pages/Home/Homepage.tsx b/frontend/src/pages/Home/Homepage.tsx
new file mode 100644
index 0000000..b4ac3b0
--- /dev/null
+++ b/frontend/src/pages/Home/Homepage.tsx
@@ -0,0 +1,31 @@
1import React from "react";
2import { Helmet } from "react-helmet";
3
4const Homepage: React.FC = () => {
5 return (
6 <main className="ml-12 relative left-0 w-fullmin-h-screen p-4 sm:p-8 text-foreground font-[--font-barlow-semicondensed-regular]">
7 <Helmet>
8 <title>LPHUB | Homepage</title>
9 </Helmet>
10 <section className="p-8">
11 <p />
12 <h1 className="text-5xl font-[--font-barlow-condensed-bold] mb-6 text-primary">Welcome to Least Portals Hub!</h1>
13 <p className="text-lg mb-4 leading-relaxed">
14 At the moment, LPHUB is in beta state. This means that the site has
15 only the core functionalities enabled for providing both collaborative
16 information and competitive leaderboards.
17 </p>
18 <p className="text-lg mb-4 leading-relaxed">
19 The website should feel intuitive to navigate around. For any type of
20 feedback, reach us at LPHUB Discord server.
21 </p>
22 <p className="text-lg mb-4 leading-relaxed">
23 By using LPHUB, you agree that you have read the 'Leaderboard Rules'
24 and the 'About LPHUB' pages.
25 </p>
26 </section>
27 </main>
28 );
29};
30
31export default Homepage;
diff --git a/frontend/src/pages/Maplist/Maplist.tsx b/frontend/src/pages/Maplist/Maplist.tsx
new file mode 100644
index 0000000..572eb27
--- /dev/null
+++ b/frontend/src/pages/Maplist/Maplist.tsx
@@ -0,0 +1,249 @@
1import React, { useEffect } from "react";
2import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
3import { Helmet } from "react-helmet";
4
5import { API } from "@api/Api.ts";
6import { Game } from "@customTypes/Game.ts";
7import { GameChapter, GamesChapters } from "@customTypes/Chapters.ts";
8
9const Maplist: React.FC = () => {
10 const [game, setGame] = React.useState<Game | null>(null);
11 const [catNum, setCatNum] = React.useState(0);
12 const [id, setId] = React.useState(0);
13 const [load, setLoad] = React.useState(false);
14 const [currentlySelected, setCurrentlySelected] = React.useState<number>(0);
15 const [hasClicked, setHasClicked] = React.useState(false);
16 const [gameChapters, setGameChapters] = React.useState<GamesChapters>();
17 const [curChapter, setCurChapter] = React.useState<GameChapter>();
18 const [numChapters, setNumChapters] = React.useState<number>(0);
19
20 const [dropdownActive, setDropdownActive] = React.useState("none");
21
22 const params = useParams<{ id: string; chapter: string }>();
23 const location = useLocation();
24 const navigate = useNavigate();
25
26 function _update_currently_selected(catNum2: number) {
27 setCurrentlySelected(catNum2);
28 navigate("/games/" + game?.id + "?cat=" + catNum2);
29 setHasClicked(true);
30 }
31
32 const _fetch_chapters = async (chapter_id: string) => {
33 const chapters = await API.get_chapters(chapter_id);
34 setCurChapter(chapters);
35 };
36
37 const _handle_dropdown_click = () => {
38 if (dropdownActive === "none") {
39 setDropdownActive("block");
40 } else {
41 setDropdownActive("none");
42 }
43 };
44
45 // im sorry but im too lazy to fix this right now
46 useEffect(() => {
47 // gameID
48 const gameId = parseFloat(params.id || "");
49 setId(gameId);
50
51 // location query params
52 const queryParams = new URLSearchParams(location.search);
53 if (queryParams.get("chapter")) {
54 let cat = parseFloat(queryParams.get("chapter") || "");
55 if (gameId === 2) {
56 cat += 10;
57 }
58 _fetch_chapters(cat.toString());
59 }
60
61 const _fetch_game = async () => {
62 const games = await API.get_games();
63 const foundGame = games.find(game => game.id === gameId);
64 // console.log(foundGame)
65 if (foundGame) {
66 setGame(foundGame);
67 setLoad(false);
68 }
69 };
70
71 const _fetch_game_chapters = async () => {
72 const games_chapters = await API.get_games_chapters(gameId.toString());
73 setGameChapters(games_chapters);
74 setNumChapters(games_chapters.chapters.length);
75 };
76
77 setLoad(true);
78 _fetch_game();
79 _fetch_game_chapters();
80 }, [location.search]);
81
82 useEffect(() => {
83 const queryParams = new URLSearchParams(location.search);
84 if (gameChapters !== undefined && !queryParams.get("chapter")) {
85 _fetch_chapters(gameChapters!.chapters[0].id.toString());
86 }
87 }, [gameChapters, location.search]);
88
89 return (
90 <main className="*:text-foreground w-[calc(100vw-80px)] relative left-0 ml-20 min-h-screen p-4 sm:p-8">
91 <Helmet>
92 <title>LPHUB | Maplist</title>
93 </Helmet>
94
95 <section className="mt-5">
96 <Link to="/games">
97 <button className="nav-button rounded-[20px] h-10 bg-surface border-0 text-foreground text-lg font-[--font-barlow-semicondensed-regular] transition-colors duration-100 hover:bg-surface2 flex items-center px-2">
98 <i className="triangle mr-2"></i>
99 <span className="px-2">Games List</span>
100 </button>
101 </Link>
102 </section>
103
104 {load ? (
105 <div></div>
106 ) : (
107 <section>
108 <h1 className="font-[--font-barlow-condensed-bold] text-3xl sm:text-6xl my-0 text-foreground">
109 {game?.name}
110 </h1>
111
112 <div
113 className="text-center rounded-3xl overflow-hidden bg-cover bg-[25%] mt-3 relative"
114 style={{ backgroundImage: `url(${game?.image})` }}
115 >
116 <div className="backdrop-blur-sm flex flex-col w-full">
117 <div className="h-full flex flex-col justify-center items-center py-6">
118 <h2 className="my-5 font-[--font-barlow-semicondensed-semibold] text-4xl sm:text-8xl text-foreground">
119 {
120 game?.category_portals.find(
121 obj => obj.category.id === catNum + 1
122 )?.portal_count
123 }
124 </h2>
125 <h3 className="font-[--font-barlow-semicondensed-regular] mx-2.5 text-2xl sm:text-4xl my-0 text-foreground">
126 portals
127 </h3>
128 </div>
129
130 <div className="flex h-12 bg-surface gap-0.5">
131 {game?.category_portals.map((cat, index) => (
132 <button
133 key={index}
134 className={`border-0 text-foreground font-[--font-barlow-semicondensed-regular] text-sm sm:text-xl cursor-pointer transition-all duration-100 w-full ${
135 currentlySelected === cat.category.id ||
136 (cat.category.id - 1 === catNum && !hasClicked)
137 ? "bg-surface"
138 : "bg-surface1 hover:bg-surface"
139 }`}
140 onClick={() => {
141 setCatNum(cat.category.id - 1);
142 _update_currently_selected(cat.category.id);
143 }}
144 >
145 <span className="truncate">{cat.category.name}</span>
146 </button>
147 ))}
148 </div>
149 </div>
150 </div>
151
152 <div>
153 <section>
154 <div>
155 <span className="text-lg sm:text-lg translate-y-1.5 block mt-2.5 text-foreground">
156 {curChapter?.chapter.name.split(" - ")[0]}
157 </span>
158 </div>
159 <div
160 onClick={_handle_dropdown_click}
161 className="cursor-pointer select-none flex w-fit items-center"
162 >
163 <span className="text-foreground text-base sm:text-2xl">
164 {curChapter?.chapter.name.split(" - ")[1]}
165 </span>
166 <i className="triangle translate-x-1.5 translate-y-2 -rotate-90"></i>
167 </div>
168 \
169 <div
170 className={`absolute z-[1000] bg-surface1 rounded-2xl overflow-hidden p-1 animate-in fade-in duration-100 ${
171 dropdownActive === "none" ? "hidden" : "block"
172 }`}
173 >
174 {gameChapters?.chapters.map((chapter, i) => {
175 return (
176 <div
177 key={i}
178 className="cursor-pointer text-base sm:text-xl rounded-[2000px] p-1 hover:bg-surface text-foreground"
179 onClick={() => {
180 _fetch_chapters(chapter.id.toString());
181 _handle_dropdown_click();
182 }}
183 >
184 {chapter.name}
185 </div>
186 );
187 })}
188 </div>
189 </section>
190
191 <section className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5 my-5">
192 {curChapter?.maps.map((map, i) => {
193 return (
194 <div key={i} className="bg-surface rounded-3xl overflow-hidden">
195 <Link to={`/maps/${map.id}`}>
196 <span className="text-center text-base sm:text-xl w-full block my-1.5 text-foreground truncate">
197 {map.name}
198 </span>
199 <div
200 className="flex h-40 sm:h-48 bg-cover relative"
201 style={{ backgroundImage: `url(${map.image})` }}
202 >
203 <div className="backdrop-blur-sm w-full flex items-center justify-center">
204 <span className="text-2xl sm:text-4xl font-[--font-barlow-semicondensed-semibold] text-white mr-1.5">
205 {map.is_disabled
206 ? map.category_portals[0].portal_count
207 : map.category_portals.find(
208 obj => obj.category.id === catNum + 1
209 )?.portal_count}
210 </span>
211 <span className="text-2xl sm:text-4xl font-[--font-barlow-semicondensed-regular] text-white">
212 portals
213 </span>
214 </div>
215 </div>
216
217 <div className="flex mx-2.5 my-4">
218 <div className="flex w-full items-center justify-center gap-1.5 rounded-[2000px] ml-0.5 translate-y-px">
219 {[1, 2, 3, 4, 5].map((point) => (
220 <div
221 key={point}
222 className={`flex h-0.5 w-full rounded-3xl ${
223 point <= (map.difficulty + 1)
224 ? map.difficulty === 0
225 ? "bg-green-500"
226 : map.difficulty === 1 || map.difficulty === 2
227 ? "bg-lime-500"
228 : map.difficulty === 3
229 ? "bg-red-400"
230 : "bg-red-600"
231 : "bg-surface1"
232 }`}
233 />
234 ))}
235 </div>
236 </div>
237 </Link>
238 </div>
239 );
240 })}
241 </section>
242 </div>
243 </section>
244 )}
245 </main>
246 );
247};
248
249export default Maplist;
diff --git a/frontend/src/pages/Maps/Maps.tsx b/frontend/src/pages/Maps/Maps.tsx
new file mode 100644
index 0000000..75e3635
--- /dev/null
+++ b/frontend/src/pages/Maps/Maps.tsx
@@ -0,0 +1,185 @@
1import React from "react";
2import { Link, useLocation } from "react-router-dom";
3import { Helmet } from "react-helmet";
4
5import { PortalIcon, FlagIcon, ChatIcon } from "../../images/Images.tsx";
6import Summary from "@components/Summary.tsx";
7import Leaderboards from "@components/Leaderboards.tsx";
8import Discussions from "@components/Discussions.tsx";
9import ModMenu from "@components/ModMenu.tsx";
10import { MapDiscussions, MapLeaderboard, MapSummary } from "@customTypes/Map.ts";
11import { API } from "@api/Api.ts";
12
13interface MapProps {
14 token?: string;
15 isModerator: boolean;
16}
17
18const Maps: React.FC<MapProps> = ({ token, isModerator }) => {
19 const [selectedRun, setSelectedRun] = React.useState<number>(0);
20
21 const [mapSummaryData, setMapSummaryData] = React.useState<
22 MapSummary | undefined
23 >(undefined);
24 const [mapLeaderboardData, setMapLeaderboardData] = React.useState<
25 MapLeaderboard | undefined
26 >(undefined);
27 const [mapDiscussionsData, setMapDiscussionsData] = React.useState<
28 MapDiscussions | undefined
29 >(undefined);
30
31 const [navState, setNavState] = React.useState<number>(0);
32
33 const location = useLocation();
34
35 const mapID = location.pathname.split("/")[2];
36
37 const _fetch_map_summary = React.useCallback(async () => {
38 const mapSummary = await API.get_map_summary(mapID);
39 setMapSummaryData(mapSummary);
40 }, [mapID]);
41
42 const _fetch_map_leaderboards = React.useCallback(async () => {
43 const mapLeaderboards = await API.get_map_leaderboard(mapID, "1");
44 setMapLeaderboardData(mapLeaderboards);
45 }, [mapID]);
46
47 const _fetch_map_discussions = React.useCallback(async () => {
48 const mapDiscussions = await API.get_map_discussions(mapID);
49 setMapDiscussionsData(mapDiscussions);
50 }, [mapID]);
51
52 React.useEffect(() => {
53 _fetch_map_summary();
54 _fetch_map_leaderboards();
55 _fetch_map_discussions();
56 }, [
57 mapID,
58 _fetch_map_discussions,
59 _fetch_map_leaderboards,
60 _fetch_map_summary,
61 ]);
62
63 if (!mapSummaryData) {
64 // loading placeholder
65 return (
66 <>
67 <main className="*:text-foreground relative left-0 w-[calc(100%-20rem)] min-h-screen p-4 sm:p-8">
68 <section id="section1" className="summary1">
69 <div>
70 <Link to="/games">
71 <button
72 className="nav-button rounded-[20px] h-10 bg-surface border-0 text-foreground text-lg font-[--font-barlow-semicondensed-regular] transition-colors duration-100 hover:bg-surface2 flex items-center px-2"
73 >
74 <i className="triangle"></i>
75 <span className="px-2">Games List</span>
76 </button>
77 </Link>
78 </div>
79 </section>
80
81 <section id="section2" className="summary1 mt-4 flex gap-2 flex-wrap">
82 <button className="nav-button">
83 <img src={PortalIcon} alt="" className="w-5 h-5 sm:w-6 sm:h-6" />
84 <span>Summary</span>
85 </button>
86 <button className="nav-button">
87 <img src={FlagIcon} alt="" className="w-5 h-5 sm:w-6 sm:h-6" />
88 <span>Leaderboards</span>
89 </button>
90 <button className="nav-button">
91 <img src={ChatIcon} alt="" className="w-5 h-5 sm:w-6 sm:h-6" />
92 <span>Discussions</span>
93 </button>
94 </section>
95
96 <section id="section6" className="summary2 mt-4" />
97 </main>
98 </>
99 );
100 }
101
102 return (
103 <>
104 <Helmet>
105 <title>LPHUB | {mapSummaryData.map.map_name}</title>
106 <meta name="description" content={mapSummaryData.map.map_name} />
107 </Helmet>
108 {isModerator && (
109 <ModMenu
110 token={token}
111 data={mapSummaryData}
112 selectedRun={selectedRun}
113 mapID={mapID}
114 />
115 )}
116
117 <div id="background-image">
118 <img src={mapSummaryData.map.image} alt="" />
119 </div>
120 <main className="relative left-0 w-full sm:ml-80 sm:w-[calc(100%-20rem)] min-h-screen max-h-screen overflow-y-auto p-4 sm:p-8 scrollbar-thin scrollbar-track-surface scrollbar-thumb-muted hover:scrollbar-thumb-surface1">
121 <section id="section1" className="summary1">
122 <div>
123 <Link to="/games">
124 <button
125 className="nav-button rounded-[20px] h-10 bg-surface border-0 text-foreground text-lg font-[--font-barlow-semicondensed-regular] transition-colors duration-100 hover:bg-surface2 flex items-center px-2"
126 >
127 <i className="triangle"></i>
128 <span className="px-2">Games List</span>
129 </button>
130 </Link>
131 <Link
132 to={`/games/${mapSummaryData.map.is_coop ? "2" : "1"}?chapter=${mapSummaryData.map.chapter_name.split(" ")[1]}`}
133 >
134 <button
135 className="nav-button ml-2"
136 >
137 <i className="triangle"></i>
138 <span className="px-2">{mapSummaryData.map.chapter_name}</span>
139 </button>
140 </Link>
141 <br />
142 <span className="block mt-2 text-lg sm:text-xl text-foreground">
143 <b>{mapSummaryData.map.map_name}</b>
144 </span>
145 </div>
146 </section>
147
148 <section id="section2" className="summary1 mt-4 flex gap-2 flex-wrap">
149 <button className="nav-button" onClick={() => setNavState(0)}>
150 <img src={PortalIcon} alt="" className="w-5 h-5 sm:w-6 sm:h-6" />
151 <span>Summary</span>
152 </button>
153 <button className="nav-button" onClick={() => setNavState(1)}>
154 <img src={FlagIcon} alt="" className="w-5 h-5 sm:w-6 sm:h-6" />
155 <span>Leaderboards</span>
156 </button>
157 <button className="nav-button" onClick={() => setNavState(2)}>
158 <img src={ChatIcon} alt="" className="w-5 h-5 sm:w-6 sm:h-6" />
159 <span>Discussions</span>
160 </button>
161 </section>
162
163 {navState === 0 && (
164 <Summary
165 selectedRun={selectedRun}
166 setSelectedRun={setSelectedRun}
167 data={mapSummaryData}
168 />
169 )}
170 {navState === 1 && <Leaderboards mapID={mapID} />}
171 {navState === 2 && (
172 <Discussions
173 data={mapDiscussionsData}
174 token={token}
175 isModerator={isModerator}
176 mapID={mapID}
177 onRefresh={() => _fetch_map_discussions()}
178 />
179 )}
180 </main>
181 </>
182 );
183};
184
185export default Maps;
diff --git a/frontend/src/pages/Profile/Profile.tsx b/frontend/src/pages/Profile/Profile.tsx
new file mode 100644
index 0000000..9aac386
--- /dev/null
+++ b/frontend/src/pages/Profile/Profile.tsx
@@ -0,0 +1,633 @@
1import React from "react";
2import { Link, useNavigate } from "react-router-dom";
3import { Helmet } from "react-helmet";
4
5import {
6 SteamIcon,
7 TwitchIcon,
8 YouTubeIcon,
9 PortalIcon,
10 FlagIcon,
11 StatisticsIcon,
12 SortIcon,
13 ThreedotIcon,
14 DownloadIcon,
15 HistoryIcon,
16 DeleteIcon,
17} from "@images/Images";
18import { UserProfile } from "@customTypes/Profile.ts";
19import { Game, GameChapters } from "@customTypes/Game.ts";
20import { Map } from "@customTypes/Map.ts";
21import { ticks_to_time } from "@utils/Time.ts";
22import { API } from "@api/Api.ts";
23import useConfirm from "@hooks/UseConfirm.tsx";
24import useMessage from "@hooks/UseMessage.tsx";
25import useMessageLoad from "@hooks/UseMessageLoad.tsx";
26
27interface ProfileProps {
28 profile?: UserProfile;
29 token?: string;
30 gameData: Game[];
31 onDeleteRecord: () => void;
32}
33
34const Profile: React.FC<ProfileProps> = ({
35 profile,
36 token,
37 gameData,
38 onDeleteRecord,
39}) => {
40 const { confirm, ConfirmDialogComponent } = useConfirm();
41 const { message, MessageDialogComponent } = useMessage();
42 const { messageLoad, messageLoadClose, MessageDialogLoadComponent } =
43 useMessageLoad();
44 const [navState, setNavState] = React.useState(0);
45 const [pageNumber, setPageNumber] = React.useState(1);
46 const [pageMax, setPageMax] = React.useState(0);
47
48 const [game, setGame] = React.useState("0");
49 const [chapter, setChapter] = React.useState("0");
50 const [chapterData, setChapterData] = React.useState<GameChapters | null>(
51 null
52 );
53 const [maps, setMaps] = React.useState<Map[]>([]);
54
55 const navigate = useNavigate();
56
57 const _update_profile = () => {
58 if (token) {
59 API.post_profile(token).then(() => navigate(0));
60 }
61 };
62
63 const _get_game_chapters = React.useCallback(async () => {
64 if (game && game !== "0") {
65 const gameChapters = await API.get_games_chapters(game);
66 setChapterData(gameChapters);
67 } else if (game && game === "0") {
68 setPageMax(Math.ceil(profile!.records.length / 20));
69 setPageNumber(1);
70 }
71 }, [game, profile]);
72
73 const _get_game_maps = React.useCallback(async () => {
74 if (chapter === "0") {
75 const gameMaps = await API.get_game_maps(game);
76 setMaps(gameMaps);
77 setPageMax(Math.ceil(gameMaps.length / 20));
78 setPageNumber(1);
79 } else {
80 const gameChapters = await API.get_chapters(chapter);
81 setMaps(gameChapters.maps);
82 setPageMax(Math.ceil(gameChapters.maps.length / 20));
83 setPageNumber(1);
84 }
85 }, [chapter, game]);
86
87 const _delete_submission = async (map_id: number, record_id: number) => {
88 const userConfirmed = await confirm(
89 "Delete Record",
90 "Are you sure you want to delete this record?"
91 );
92
93 if (!userConfirmed) {
94 return;
95 }
96
97 messageLoad("Deleting...");
98
99 const api_success = await API.delete_map_record(token!, map_id, record_id);
100 messageLoadClose();
101 if (api_success) {
102 await message("Delete Record", "Successfully deleted record.");
103 onDeleteRecord();
104 } else {
105 await message("Delete Record", "Could not delete record.");
106 }
107 };
108
109 React.useEffect(() => {
110 if (!profile) {
111 navigate("/");
112 }
113 }, [profile, navigate]);
114
115 React.useEffect(() => {
116 if (profile) {
117 _get_game_chapters();
118 }
119 }, [profile, game, _get_game_chapters]);
120
121 React.useEffect(() => {
122 if (profile && game !== "0") {
123 _get_game_maps();
124 }
125 }, [profile, game, chapter, chapterData, _get_game_maps]);
126
127 if (!profile) {
128 return <></>;
129 }
130
131 return (
132 <div>
133 <Helmet>
134 <title>LPHUB | {profile.user_name}</title>
135 <meta name="description" content={profile.user_name} />
136 </Helmet>
137 {MessageDialogComponent}
138 {MessageDialogLoadComponent}
139 {ConfirmDialogComponent}
140
141 <main>
142 <section id="section1" className="profile">
143 {profile.profile ? (
144 <div id="profile-image" onClick={_update_profile}>
145 <img src={profile.avatar_link} alt="profile-image"></img>
146 <span>Refresh</span>
147 </div>
148 ) : (
149 <div>
150 <img src={profile.avatar_link} alt="profile-image"></img>
151 </div>
152 )}
153
154 <div id="profile-top">
155 <div>
156 <div>{profile.user_name}</div>
157 <div>
158 {profile.country_code === "XX" ? (
159 ""
160 ) : (
161 <img
162 src={`https://flagcdn.com/w80/${profile.country_code.toLowerCase()}.jpg`}
163 alt={profile.country_code}
164 />
165 )}
166 </div>
167 <div>
168 {profile.titles.map(e => (
169 <span
170 className="titles"
171 style={{ backgroundColor: `#${e.color}` }}
172 >
173 {e.name}
174 </span>
175 ))}
176 </div>
177 </div>
178 <div>
179 {profile.links.steam === "-" ? (
180 ""
181 ) : (
182 <a href={profile.links.steam}>
183 <img src={SteamIcon} alt="Steam" />
184 </a>
185 )}
186 {profile.links.twitch === "-" ? (
187 ""
188 ) : (
189 <a href={profile.links.twitch}>
190 <img src={TwitchIcon} alt="Twitch" />
191 </a>
192 )}
193 {profile.links.youtube === "-" ? (
194 ""
195 ) : (
196 <a href={profile.links.youtube}>
197 <img src={YouTubeIcon} alt="Youtube" />
198 </a>
199 )}
200 {profile.links.p2sr === "-" ? (
201 ""
202 ) : (
203 <a href={profile.links.p2sr}>
204 <img src={PortalIcon} alt="P2SR" style={{ padding: "0" }} />
205 </a>
206 )}
207 </div>
208 </div>
209 <div id="profile-bottom">
210 <div>
211 <span>Overall</span>
212 <span>
213 {profile.rankings.overall.rank === 0
214 ? "N/A "
215 : "#" + profile.rankings.overall.rank + " "}
216 <span>
217 ({profile.rankings.overall.completion_count}/
218 {profile.rankings.overall.completion_total})
219 </span>
220 </span>
221 </div>
222 <div>
223 <span>Singleplayer</span>
224 <span>
225 {profile.rankings.singleplayer.rank === 0
226 ? "N/A "
227 : "#" + profile.rankings.singleplayer.rank + " "}
228 <span>
229 ({profile.rankings.singleplayer.completion_count}/
230 {profile.rankings.singleplayer.completion_total})
231 </span>
232 </span>
233 </div>
234 <div>
235 <span>Cooperative</span>
236 <span>
237 {profile.rankings.cooperative.rank === 0
238 ? "N/A "
239 : "#" + profile.rankings.cooperative.rank + " "}
240 <span>
241 ({profile.rankings.cooperative.completion_count}/
242 {profile.rankings.cooperative.completion_total})
243 </span>
244 </span>
245 </div>
246 </div>
247 </section>
248
249 <section id="section2" className="profile">
250 <button onClick={() => setNavState(0)}>
251 <img src={FlagIcon} alt="" />
252 &nbsp;Player Records
253 </button>
254 <button onClick={() => setNavState(1)}>
255 <img src={StatisticsIcon} alt="" />
256 &nbsp;Statistics
257 </button>
258 </section>
259
260 <section id="section3" className="profile1">
261 <div id="profileboard-nav">
262 {gameData === null ? (
263 <select>error</select>
264 ) : (
265 <select
266 id="select-game"
267 onChange={() => {
268 setGame(
269 (document.querySelector("#select-game") as HTMLInputElement)
270 .value
271 );
272 setChapter("0");
273 const chapterSelect = document.querySelector(
274 "#select-chapter"
275 ) as HTMLSelectElement;
276 if (chapterSelect) {
277 chapterSelect.value = "0";
278 }
279 }}
280 >
281 <option value={0} key={0}>
282 All Scores
283 </option>
284 {gameData.map((e, i) => (
285 <option value={e.id} key={i + 1}>
286 {e.name}
287 </option>
288 ))}
289 </select>
290 )}
291
292 {game === "0" ? (
293 <select disabled>
294 <option>All Chapters</option>
295 </select>
296 ) : chapterData === null ? (
297 <select></select>
298 ) : (
299 <select
300 id="select-chapter"
301 onChange={() =>
302 setChapter(
303 (
304 document.querySelector(
305 "#select-chapter"
306 ) as HTMLInputElement
307 ).value
308 )
309 }
310 >
311 <option value="0" key="0">
312 All Chapters
313 </option>
314 {chapterData.chapters
315 .filter(e => e.is_disabled === false)
316 .map((e, i) => (
317 <option value={e.id} key={i + 1}>
318 {e.name}
319 </option>
320 ))}
321 </select>
322 )}
323 </div>
324 <div id="profileboard-top">
325 <span>
326 <span>Map Name</span>
327 <img src={SortIcon} alt="" />
328 </span>
329 <span style={{ justifyContent: "center" }}>
330 <span>Portals</span>
331 <img src={SortIcon} alt="" />
332 </span>
333 <span style={{ justifyContent: "center" }}>
334 <span>WRΔ </span>
335 <img src={SortIcon} alt="" />
336 </span>
337 <span style={{ justifyContent: "center" }}>
338 <span>Time</span>
339 <img src={SortIcon} alt="" />
340 </span>
341 <span> </span>
342 <span>
343 <span>Rank</span>
344 <img src={SortIcon} alt="" />
345 </span>
346 <span>
347 <span>Date</span>
348 <img src={SortIcon} alt="" />
349 </span>
350 <div id="page-number">
351 <div>
352 <button
353 onClick={() => {
354 if (pageNumber !== 1) {
355 setPageNumber(prevPageNumber => prevPageNumber - 1);
356 const records = document.querySelectorAll(
357 ".profileboard-record"
358 );
359 records.forEach(r => {
360 (r as HTMLInputElement).style.height = "44px";
361 });
362 }
363 }}
364 >
365 <i
366 className="triangle"
367 style={{ position: "relative", left: "-5px" }}
368 ></i>{" "}
369 </button>
370 <span>
371 {pageNumber}/{pageMax}
372 </span>
373 <button
374 onClick={() => {
375 if (pageNumber !== pageMax) {
376 setPageNumber(prevPageNumber => prevPageNumber + 1);
377 const records = document.querySelectorAll(
378 ".profileboard-record"
379 );
380 records.forEach(r => {
381 (r as HTMLInputElement).style.height = "44px";
382 });
383 }
384 }}
385 >
386 <i
387 className="triangle"
388 style={{
389 position: "relative",
390 left: "5px",
391 transform: "rotate(180deg)",
392 }}
393 ></i>{" "}
394 </button>
395 </div>
396 </div>
397 </div>
398 <hr />
399 <div id="profileboard-records">
400 {game === "0" ? (
401 profile.records
402 .sort((a, b) => a.map_id - b.map_id)
403 .map((r, index) =>
404 Math.ceil((index + 1) / 20) === pageNumber ? (
405 <button className="profileboard-record" key={index}>
406 {r.scores.map((e, i) => (
407 <>
408 {i !== 0 ? (
409 <hr style={{ gridColumn: "1 / span 8" }} />
410 ) : (
411 ""
412 )}
413
414 <Link to={`/maps/${r.map_id}`}>
415 <span>{r.map_name}</span>
416 </Link>
417
418 <span style={{ display: "grid" }}>
419 {e.score_count}
420 </span>
421
422 <span style={{ display: "grid" }}>
423 {e.score_count - r.map_wr_count > 0
424 ? `+${e.score_count - r.map_wr_count}`
425 : `-`}
426 </span>
427 <span style={{ display: "grid" }}>
428 {ticks_to_time(e.score_time)}
429 </span>
430 <span> </span>
431 {i === 0 ? (
432 <span>#{r.placement}</span>
433 ) : (
434 <span> </span>
435 )}
436 <span>{e.date.split("T")[0]}</span>
437 <span style={{ flexDirection: "row-reverse" }}>
438 <button
439 style={{ marginRight: "10px" }}
440 onClick={() => {
441 message(
442 "Demo Information",
443 `Demo ID: ${e.demo_id}`
444 );
445 }}
446 >
447 <img src={ThreedotIcon} alt="demo_id" />
448 </button>
449 <button
450 onClick={() => {
451 _delete_submission(r.map_id, e.record_id);
452 }}
453 >
454 <img src={DeleteIcon} alt="delete icon"></img>
455 </button>
456 <button
457 onClick={() =>
458 (window.location.href = `/api/v1/demos?uuid=${e.demo_id}`)
459 }
460 >
461 <img src={DownloadIcon} alt="download" />
462 </button>
463 {i === 0 && r.scores.length > 1 ? (
464 <button
465 onClick={() => {
466 (
467 document.querySelectorAll(
468 ".profileboard-record"
469 )[index % 20] as HTMLInputElement
470 ).style.height === "44px" ||
471 (
472 document.querySelectorAll(
473 ".profileboard-record"
474 )[index % 20] as HTMLInputElement
475 ).style.height === ""
476 ? ((
477 document.querySelectorAll(
478 ".profileboard-record"
479 )[index % 20] as HTMLInputElement
480 ).style.height =
481 `${r.scores.length * 46}px`)
482 : ((
483 document.querySelectorAll(
484 ".profileboard-record"
485 )[index % 20] as HTMLInputElement
486 ).style.height = "44px");
487 }}
488 >
489 <img src={HistoryIcon} alt="history" />
490 </button>
491 ) : (
492 ""
493 )}
494 </span>
495 </>
496 ))}
497 </button>
498 ) : (
499 ""
500 )
501 )
502 ) : maps ? (
503 maps
504 .filter(e => e.is_disabled === false)
505 .sort((a, b) => a.id - b.id)
506 .map((r, index) => {
507 if (Math.ceil((index + 1) / 20) === pageNumber) {
508 let record = profile.records.find(e => e.map_id === r.id);
509 return record === undefined ? (
510 <button
511 className="profileboard-record"
512 key={index}
513 style={{ backgroundColor: "#1b1b20" }}
514 >
515 <Link to={`/maps/${r.id}`}>
516 <span>{r.name}</span>
517 </Link>
518 <span style={{ display: "grid" }}>N/A</span>
519 <span style={{ display: "grid" }}>N/A</span>
520 <span>N/A</span>
521 <span> </span>
522 <span>N/A</span>
523 <span>N/A</span>
524 <span style={{ flexDirection: "row-reverse" }}></span>
525 </button>
526 ) : (
527 <button className="profileboard-record" key={index}>
528 {record.scores.map((e, i) => (
529 <>
530 {i !== 0 ? (
531 <hr style={{ gridColumn: "1 / span 8" }} />
532 ) : (
533 ""
534 )}
535 <Link to={`/maps/${r.id}`}>
536 <span>{r.name}</span>
537 </Link>
538 <span style={{ display: "grid" }}>
539 {record!.scores[i].score_count}
540 </span>
541 <span style={{ display: "grid" }}>
542 {record!.scores[i].score_count -
543 record!.map_wr_count >
544 0
545 ? `+${record!.scores[i].score_count - record!.map_wr_count}`
546 : `-`}
547 </span>
548 <span style={{ display: "grid" }}>
549 {ticks_to_time(record!.scores[i].score_time)}
550 </span>
551 <span> </span>
552 {i === 0 ? (
553 <span>#{record!.placement}</span>
554 ) : (
555 <span> </span>
556 )}
557 <span>{record!.scores[i].date.split("T")[0]}</span>
558 <span style={{ flexDirection: "row-reverse" }}>
559 <button
560 onClick={() => {
561 message(
562 "Demo Information",
563 `Demo ID: ${e.demo_id}`
564 );
565 }}
566 >
567 <img src={ThreedotIcon} alt="demo_id" />
568 </button>
569 <button
570 onClick={() => {
571 _delete_submission(r.id, e.record_id);
572 }}
573 >
574 <img src={DeleteIcon} alt="delete icon"></img>
575 </button>
576 <button
577 onClick={() =>
578 (window.location.href = `/api/v1/demos?uuid=${e.demo_id}`)
579 }
580 >
581 <img src={DownloadIcon} alt="download" />
582 </button>
583 {i === 0 && record!.scores.length > 1 ? (
584 <button
585 onClick={() => {
586 (
587 document.querySelectorAll(
588 ".profileboard-record"
589 )[index % 20] as HTMLInputElement
590 ).style.height === "44px" ||
591 (
592 document.querySelectorAll(
593 ".profileboard-record"
594 )[index % 20] as HTMLInputElement
595 ).style.height === ""
596 ? ((
597 document.querySelectorAll(
598 ".profileboard-record"
599 )[index % 20] as HTMLInputElement
600 ).style.height =
601 `${record!.scores.length * 46}px`)
602 : ((
603 document.querySelectorAll(
604 ".profileboard-record"
605 )[index % 20] as HTMLInputElement
606 ).style.height = "44px");
607 }}
608 >
609 <img src={HistoryIcon} alt="history" />
610 </button>
611 ) : (
612 ""
613 )}
614 </span>
615 </>
616 ))}
617 </button>
618 );
619 } else {
620 return null;
621 }
622 })
623 ) : (
624 <>{console.warn(maps)}</>
625 )}
626 </div>
627 </section>
628 </main>
629 </div>
630 );
631};
632
633export default Profile;
diff --git a/frontend/src/pages/Rankings/Rankings.tsx b/frontend/src/pages/Rankings/Rankings.tsx
new file mode 100644
index 0000000..57b875f
--- /dev/null
+++ b/frontend/src/pages/Rankings/Rankings.tsx
@@ -0,0 +1,203 @@
1import React, { useEffect } from "react";
2import { Helmet } from "react-helmet";
3
4import RankingEntry from "@components/RankingEntry.tsx";
5import {
6 Ranking,
7 SteamRanking,
8 RankingType,
9 SteamRankingType,
10} from "@customTypes/Ranking.ts";
11import { API } from "@api/Api.ts";
12
13import "@css/Rankings.css";
14
15enum LeaderboardTypes {
16 official,
17 unofficial,
18}
19
20enum RankingCategories {
21 rankings_overall,
22 rankings_multiplayer,
23 rankings_singleplayer,
24}
25
26const Rankings: React.FC = () => {
27 const [leaderboardData, setLeaderboardData] = React.useState<
28 Ranking | SteamRanking
29 >();
30 const [currentLeaderboard, setCurrentLeaderboard] = React.useState<
31 RankingType[] | SteamRankingType[]
32 >();
33 const [currentRankingType, setCurrentRankingType] =
34 React.useState<LeaderboardTypes>(LeaderboardTypes.official);
35
36 const [leaderboardLoad, setLeaderboardLoad] = React.useState<boolean>(false);
37
38 const [currentLeaderboardType, setCurrentLeaderboardType] =
39 React.useState<RankingCategories>(RankingCategories.rankings_singleplayer);
40 const [load, setLoad] = React.useState<boolean>(false);
41
42 const _fetch_rankings = React.useCallback(async () => {
43 setLeaderboardLoad(false);
44 const rankings = await API.get_official_rankings();
45 setLeaderboardData(rankings);
46 if (currentLeaderboardType === RankingCategories.rankings_singleplayer) {
47 setCurrentLeaderboard(rankings.rankings_singleplayer);
48 } else if (
49 currentLeaderboardType === RankingCategories.rankings_multiplayer
50 ) {
51 setCurrentLeaderboard(rankings.rankings_multiplayer);
52 } else {
53 setCurrentLeaderboard(rankings.rankings_overall);
54 }
55 setLoad(true);
56 setLeaderboardLoad(true);
57 }, [currentLeaderboardType]);
58
59 const __dev_fetch_unofficial_rankings = async () => {
60 try {
61 setLeaderboardLoad(false);
62 const rankings = await API.get_unofficial_rankings();
63 setLeaderboardData(rankings);
64 if (currentLeaderboardType === RankingCategories.rankings_singleplayer) {
65 // console.log(_sort_rankings_steam(unofficialRanking.rankings_singleplayer))
66 setCurrentLeaderboard(rankings.rankings_singleplayer);
67 } else if (
68 currentLeaderboardType === RankingCategories.rankings_multiplayer
69 ) {
70 setCurrentLeaderboard(rankings.rankings_multiplayer);
71 } else {
72 setCurrentLeaderboard(rankings.rankings_overall);
73 }
74 setLeaderboardLoad(true);
75 } catch (e) {
76 console.log(e);
77 }
78 };
79
80 const _set_current_leaderboard = React.useCallback(
81 (ranking_cat: RankingCategories) => {
82 if (ranking_cat === RankingCategories.rankings_singleplayer) {
83 setCurrentLeaderboard(leaderboardData!.rankings_singleplayer);
84 } else if (ranking_cat === RankingCategories.rankings_multiplayer) {
85 setCurrentLeaderboard(leaderboardData!.rankings_multiplayer);
86 } else {
87 setCurrentLeaderboard(leaderboardData!.rankings_overall);
88 }
89
90 setCurrentLeaderboardType(ranking_cat);
91 },
92 [leaderboardData]
93 );
94
95 // unused func
96 // const _set_leaderboard_type = (leaderboard_type: LeaderboardTypes) => {
97 // if (leaderboard_type === LeaderboardTypes.official) {
98 // _fetch_rankings();
99 // } else {
100 // }
101 // };
102
103 useEffect(() => {
104 _fetch_rankings();
105 }, [_fetch_rankings]);
106
107 return (
108 <main className="*:text-foreground">
109 <Helmet>
110 <title>LPHUB | Rankings</title>
111 </Helmet>
112 <section className="nav-container nav-1">
113 <div>
114 <button
115 onClick={() => {
116 _fetch_rankings();
117 setCurrentRankingType(LeaderboardTypes.official);
118 }}
119 className={`nav-1-btn ${currentRankingType === LeaderboardTypes.official ? "selected" : ""}`}
120 >
121 <span>Official (LPHUB)</span>
122 </button>
123 <button
124 onClick={() => {
125 __dev_fetch_unofficial_rankings();
126 setCurrentRankingType(LeaderboardTypes.unofficial);
127 }}
128 className={`nav-1-btn ${currentRankingType === LeaderboardTypes.unofficial ? "selected" : ""}`}
129 >
130 <span>Unofficial (Steam)</span>
131 </button>
132 </div>
133 </section>
134 <section className="nav-container nav-2">
135 <div>
136 <button
137 onClick={() =>
138 _set_current_leaderboard(RankingCategories.rankings_singleplayer)
139 }
140 className={`nav-2-btn ${currentLeaderboardType === RankingCategories.rankings_singleplayer ? "selected" : ""}`}
141 >
142 <span>Singleplayer</span>
143 </button>
144 <button
145 onClick={() =>
146 _set_current_leaderboard(RankingCategories.rankings_multiplayer)
147 }
148 className={`nav-2-btn ${currentLeaderboardType === RankingCategories.rankings_multiplayer ? "selected" : ""}`}
149 >
150 <span>Cooperative</span>
151 </button>
152 <button
153 onClick={() =>
154 _set_current_leaderboard(RankingCategories.rankings_overall)
155 }
156 className={`nav-2-btn ${currentLeaderboardType === RankingCategories.rankings_overall ? "selected" : ""}`}
157 >
158 <span>Overall</span>
159 </button>
160 </div>
161 </section>
162
163 {load ? (
164 <section className="rankings-leaderboard">
165 <div className="ranks-container">
166 <div className="leaderboard-entry header">
167 <span>Rank</span>
168 <span>Player</span>
169 <span>Portals</span>
170 </div>
171
172 <div className="splitter"></div>
173
174 {leaderboardLoad &&
175 currentLeaderboard?.map((curRankingData, i) => {
176 return (
177 <RankingEntry
178 currentLeaderboardType={currentLeaderboardType}
179 curRankingData={curRankingData}
180 key={i}
181 ></RankingEntry>
182 );
183 })}
184
185 {leaderboardLoad ? null : (
186 <div
187 style={{
188 display: "flex",
189 justifyContent: "center",
190 margin: "30px 0px",
191 }}
192 >
193 <span className="loader"></span>
194 </div>
195 )}
196 </div>
197 </section>
198 ) : null}
199 </main>
200 );
201};
202
203export default Rankings;
diff --git a/frontend/src/pages/Rules/Rules.tsx b/frontend/src/pages/Rules/Rules.tsx
new file mode 100644
index 0000000..9c7885c
--- /dev/null
+++ b/frontend/src/pages/Rules/Rules.tsx
@@ -0,0 +1,37 @@
1import React from "react";
2import ReactMarkdown from "react-markdown";
3import { Helmet } from "react-helmet";
4
5const Rules: React.FC = () => {
6 const [rulesText, setRulesText] = React.useState<string>("");
7
8 React.useEffect(() => {
9 const fetchRules = async () => {
10 try {
11 const response = await fetch(
12 "https://raw.githubusercontent.com/pektezol/lphub/main/RULES.md"
13 );
14 if (!response.ok) {
15 throw new Error("Failed to fetch README");
16 }
17 const rulesText = await response.text();
18 setRulesText(rulesText);
19 } catch (error) {
20 console.error("Error fetching Rules:", error);
21 }
22 // setRulesText(rulesText)
23 };
24 fetchRules();
25 }, []);
26
27 return (
28 <main className="ml-16 p-8 text-foreground font-[--font-barlow-semicondensed-regular] prose prose-invert max-w-none">
29 <Helmet>
30 <title>LPHUB | Rules</title>
31 </Helmet>
32 <ReactMarkdown>{rulesText}</ReactMarkdown>
33 </main>
34 );
35};
36
37export default Rules;
diff --git a/frontend/src/pages/User/User.tsx b/frontend/src/pages/User/User.tsx
new file mode 100644
index 0000000..30c9e45
--- /dev/null
+++ b/frontend/src/pages/User/User.tsx
@@ -0,0 +1,410 @@
1import React from "react";
2import { Link, useLocation, useNavigate } from "react-router-dom";
3import { Helmet } from "react-helmet";
4
5import {
6 SteamIcon,
7 TwitchIcon,
8 YouTubeIcon,
9 PortalIcon,
10 FlagIcon,
11 StatisticsIcon,
12 SortIcon,
13 ThreedotIcon,
14 DownloadIcon,
15 HistoryIcon,
16} from "@images/Images";
17import { UserProfile } from "@customTypes/Profile.ts";
18import { Game, GameChapters } from "@customTypes/Game.ts";
19import { Map } from "@customTypes/Map.ts";
20import { API } from "@api/Api.ts";
21import { ticks_to_time } from "@utils/Time.ts";
22import useMessage from "@hooks/UseMessage.tsx";
23
24interface UserProps {
25 profile?: UserProfile;
26 token?: string;
27 gameData: Game[];
28}
29
30const User: React.FC<UserProps> = ({ token, profile, gameData }) => {
31 const { message, MessageDialogComponent } = useMessage();
32
33 const [user, setUser] = React.useState<UserProfile | undefined>(undefined);
34
35 const [navState, setNavState] = React.useState(0);
36 const [pageNumber, setPageNumber] = React.useState(1);
37 const [pageMax, setPageMax] = React.useState(0);
38
39 const [game, setGame] = React.useState("0");
40 const [chapter, setChapter] = React.useState("0");
41 const [chapterData, setChapterData] = React.useState<GameChapters | null>(
42 null
43 );
44 const [maps, setMaps] = React.useState<Map[]>([]);
45
46 const location = useLocation();
47 const navigate = useNavigate();
48
49 const _fetch_user = React.useCallback(async () => {
50 const userID = location.pathname.split("/")[2];
51 if (token && profile && profile.profile && profile.steam_id === userID) {
52 navigate("/profile");
53 return;
54 }
55 const userData = await API.get_user(userID);
56 setUser(userData);
57 }, [location.pathname, token, profile, navigate]);
58
59 const _get_game_chapters = React.useCallback(async () => {
60 if (game !== "0") {
61 const gameChapters = await API.get_games_chapters(game);
62 setChapterData(gameChapters);
63 } else {
64 setPageMax(Math.ceil(user!.records.length / 20));
65 setPageNumber(1);
66 }
67 }, [game, user]);
68
69 const _get_game_maps = React.useCallback(async () => {
70 if (chapter === "0") {
71 const gameMaps = await API.get_game_maps(game);
72 setMaps(gameMaps);
73 setPageMax(Math.ceil(gameMaps.length / 20));
74 setPageNumber(1);
75 } else {
76 const gameChapters = await API.get_chapters(chapter);
77 setMaps(gameChapters.maps);
78 setPageMax(Math.ceil(gameChapters.maps.length / 20));
79 setPageNumber(1);
80 }
81 }, [chapter, game]);
82
83 React.useEffect(() => {
84 _fetch_user();
85 }, [location, _fetch_user]);
86
87 React.useEffect(() => {
88 if (user) {
89 _get_game_chapters();
90 }
91 }, [user, game, location, _get_game_chapters]);
92
93 React.useEffect(() => {
94 if (user && game !== "0") {
95 _get_game_maps();
96 }
97 }, [user, game, chapter, location, _get_game_maps]);
98
99 if (!user) {
100 return (
101 <div className="flex justify-center items-center h-[50vh] text-lg text-foreground">
102 Loading...
103 </div>
104 );
105 }
106
107 return (
108 <main className="ml-20 overflow-auto overflow-x-hidden relative w-[calc(100%px)] h-screen font-[--font-barlow-semicondensed-regular] text-foreground text-xl">
109 <Helmet>
110 <title>LPHUB | {user.user_name}</title>
111 <meta name="description" content={user.user_name} />
112 </Helmet>
113
114 {MessageDialogComponent}
115
116 <section className="m-5 bg-gradient-to-t from-[#202232] from-50% to-[#2b2e46] to-50% rounded-3xl p-[30px] mb-[30px] text-foreground">
117 <div className="grid grid-cols-[200px_1fr_auto] items-center gap-[25px] mb-[25px]">
118 <img
119 src={user.avatar_link}
120 alt="Profile"
121 className="w-[120px] h-[120px] rounded-full border-[3px] border-[rgba(205,207,223,0.2)]"
122 />
123 <div>
124 <h1 className="m-0 mb-[10px] text-[50px] font-bold text-white font-[--font-barlow-semicondensed-regular]">
125 {user.user_name}
126 </h1>
127 {user.country_code !== "XX" && (
128 <div className="flex items-center gap-3 mb-[15px]">
129 <img
130 src={`https://flagcdn.com/w80/${user.country_code.toLowerCase()}.jpg`}
131 alt={user.country_code}
132 className="w-6 h-4 rounded-[10px]"
133 />
134 <span>{user.country_code}</span>
135 </div>
136 )}
137 <div className="flex flex-wrap gap-2">
138 {user.titles.map((title, index) => (
139 <span
140 key={index}
141 className="py-[6px] px-5 pt-[6px] rounded-[10px] text-lg font-normal text-white"
142 style={{ backgroundColor: `#${title.color}` }}
143 >
144 {title.name}
145 </span>
146 ))}
147 </div>
148 </div>
149 <div className="flex gap-[15px] items-center pr-[10px]">
150 {user.links.steam !== "-" && (
151 <a href={user.links.steam} className="flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5">
152 <img src={SteamIcon} alt="Steam" className="h-[50px] px-[5px] scale-90 brightness-200" />
153 </a>
154 )}
155 {user.links.twitch !== "-" && (
156 <a href={user.links.twitch} className="flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5">
157 <img src={TwitchIcon} alt="Twitch" className="h-[50px] px-[5px] scale-90 brightness-200" />
158 </a>
159 )}
160 {user.links.youtube !== "-" && (
161 <a href={user.links.youtube} className="flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5">
162 <img src={YouTubeIcon} alt="YouTube" className="h-[50px] px-[5px] scale-90 brightness-200" />
163 </a>
164 )}
165 {user.links.p2sr !== "-" && (
166 <a href={user.links.p2sr} className="flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5">
167 <img src={PortalIcon} alt="P2SR" className="h-[50px] px-[5px] scale-90 brightness-200" />
168 </a>
169 )}
170 </div>
171 </div>
172
173 <div className="grid grid-cols-3 gap-3 mt-24">
174 <div className="m-3 bg-[#2b2e46] rounded-[20px] p-5 text-center grid place-items-center grid-rows-[40%_50%]">
175 <div className="text-inherit text-lg">Overall</div>
176 <div className="text-white text-[40px]">
177 {user.rankings.overall.rank === 0 ? "N/A" : `#${user.rankings.overall.rank}`}
178 </div>
179 <div className="text-white text-xl">
180 {user.rankings.overall.completion_count}/{user.rankings.overall.completion_total}
181 </div>
182 </div>
183 <div className="m-3 bg-[#2b2e46] rounded-[20px] p-5 text-center grid place-items-center grid-rows-[40%_50%]">
184 <div className="text-inherit text-lg">Singleplayer</div>
185 <div className="text-white text-[40px]">
186 {user.rankings.singleplayer.rank === 0 ? "N/A" : `#${user.rankings.singleplayer.rank}`}
187 </div>
188 <div className="text-white text-xl">
189 {user.rankings.singleplayer.completion_count}/{user.rankings.singleplayer.completion_total}
190 </div>
191 </div>
192 <div className="m-3 bg-[#2b2e46] rounded-[20px] p-5 text-center grid place-items-center grid-rows-[40%_50%]">
193 <div className="text-inherit text-lg">Cooperative</div>
194 <div className="text-white text-[40px]">
195 {user.rankings.cooperative.rank === 0 ? "N/A" : `#${user.rankings.cooperative.rank}`}
196 </div>
197 <div className="text-white text-xl">
198 {user.rankings.cooperative.completion_count}/{user.rankings.cooperative.completion_total}
199 </div>
200 </div>
201 </div>
202 </section>
203
204 <section className="m-5 h-[60px] grid grid-cols-2">
205 <button
206 className={`flex justify-center items-center gap-2 bg-[#2b2e46] border-0 text-inherit font-inherit text-2xl cursor-pointer transition-colors duration-100 rounded-l-3xl hover:bg-[#202232] ${
207 navState === 0 ? 'bg-[#202232]' : ''
208 }`}
209 onClick={() => setNavState(0)}
210 >
211 <img src={FlagIcon} alt="" className="w-5 h-5 scale-[1.2]" />
212 Player Records
213 </button>
214 <button
215 className={`flex justify-center items-center gap-2 bg-[#2b2e46] border-0 text-inherit font-inherit text-2xl cursor-pointer transition-colors duration-100 rounded-r-3xl hover:bg-[#202232] ${
216 navState === 1 ? 'bg-[#202232]' : ''
217 }`}
218 onClick={() => setNavState(1)}
219 >
220 <img src={StatisticsIcon} alt="" className="w-5 h-5 scale-[1.2]" />
221 Statistics
222 </button>
223 </section>
224
225 {navState === 0 && (
226 <section className="m-5 block bg-[#202232] rounded-3xl overflow-hidden">
227 <div className="grid grid-cols-2 mx-5 my-5 mt-[10px] mb-5">
228 <select
229 className="h-[50px] rounded-3xl text-center text-inherit font-inherit text-2xl border-0 bg-[#2b2e46] mr-[10px]"
230 value={game}
231 onChange={(e) => {
232 setGame(e.target.value);
233 setChapter("0");
234 }}
235 >
236 <option value="0">All Games</option>
237 {gameData?.map((g) => (
238 <option key={g.id} value={g.id}>
239 {g.name}
240 </option>
241 ))}
242 </select>
243
244 <select
245 className="h-[50px] rounded-3xl text-center text-inherit font-inherit text-2xl border-0 bg-[#2b2e46] mr-[10px] disabled:opacity-50"
246 value={chapter}
247 onChange={(e) => setChapter(e.target.value)}
248 disabled={game === "0"}
249 >
250 <option value="0">All Chapters</option>
251 {chapterData?.chapters
252 .filter(c => !c.is_disabled)
253 .map((c) => (
254 <option key={c.id} value={c.id}>
255 {c.name}
256 </option>
257 ))}
258 </select>
259 </div>
260
261 <div className="h-[34px] grid text-xl pl-[60px] mx-5 my-0 grid-cols-[15%_15%_5%_15%_5%_15%_15%_15%]">
262 <div className="flex place-items-end cursor-pointer">
263 <span>Map Name</span>
264 <img src={SortIcon} alt="Sort" className="h-5 scale-[0.8]" />
265 </div>
266 <div className="flex place-items-end cursor-pointer">
267 <span>Portals</span>
268 <img src={SortIcon} alt="Sort" className="h-5 scale-[0.8]" />
269 </div>
270 <div className="flex place-items-end cursor-pointer">
271 <span>WRΔ</span>
272 <img src={SortIcon} alt="Sort" className="h-5 scale-[0.8]" />
273 </div>
274 <div className="flex place-items-end cursor-pointer">
275 <span>Time</span>
276 <img src={SortIcon} alt="Sort" className="h-5 scale-[0.8]" />
277 </div>
278 <div></div>
279 <div className="flex place-items-end cursor-pointer">
280 <span>Rank</span>
281 <img src={SortIcon} alt="Sort" className="h-5 scale-[0.8]" />
282 </div>
283 <div className="flex place-items-end cursor-pointer">
284 <span>Date</span>
285 <img src={SortIcon} alt="Sort" className="h-5 scale-[0.8]" />
286 </div>
287 <div className="flex items-center gap-[10px] justify-center">
288 <button
289 className="w-8 h-8 border border-[#2b2e46] bg-[#2b2e46] rounded cursor-pointer flex items-center justify-center text-foreground transition-colors duration-100 hover:bg-[#202232] disabled:opacity-50 disabled:cursor-not-allowed"
290 onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
291 disabled={pageNumber === 1}
292 >
293
294 </button>
295 <span className="text-sm text-foreground">{pageNumber}/{pageMax}</span>
296 <button
297 className="w-8 h-8 border border-[#2b2e46] bg-[#2b2e46] rounded cursor-pointer flex items-center justify-center text-foreground transition-colors duration-100 hover:bg-[#202232] disabled:opacity-50 disabled:cursor-not-allowed"
298 onClick={() => setPageNumber(Math.min(pageMax, pageNumber + 1))}
299 disabled={pageNumber === pageMax}
300 >
301
302 </button>
303 </div>
304 </div>
305
306 <div>
307 {game === "0" ? (
308 user.records
309 .sort((a, b) => a.map_id - b.map_id)
310 .map((record, index) =>
311 Math.ceil((index + 1) / 20) === pageNumber ? (
312 <div key={index} className="w-[calc(100%-40px)] mx-5 my-0 mt-[10px] h-11 rounded-[20px] pl-[40px] text-xl text-inherit font-inherit border-0 transition-colors duration-100 bg-[#2b2e46] grid grid-cols-[15%_15%_5%_15%_5%_15%_15%_15%] overflow-hidden whitespace-nowrap cursor-pointer hover:bg-[#202232]">
313 <Link to={`/maps/${record.map_id}`} className="text-[#3c91e6] no-underline font-inherit flex place-items-center h-11 hover:underline">
314 {record.map_name}
315 </Link>
316 <span className="flex place-items-center h-11">{record.scores[0]?.score_count || 'N/A'}</span>
317 <span className={`flex place-items-center h-11 ${record.scores[0]?.score_count - record.map_wr_count > 0 ? 'text-[#dc3545]' : ''}`}>
318 {record.scores[0]?.score_count - record.map_wr_count > 0
319 ? `+${record.scores[0].score_count - record.map_wr_count}`
320 : '–'}
321 </span>
322 <span className="flex place-items-center h-11">{record.scores[0] ? ticks_to_time(record.scores[0].score_time) : 'N/A'}</span>
323 <span className="flex place-items-center h-11"></span>
324 <span className="flex place-items-center h-11 font-semibold">#{record.placement}</span>
325 <span className="flex place-items-center h-11">{record.scores[0]?.date.split("T")[0] || 'N/A'}</span>
326 <div className="flex gap-[5px] justify-end flex-row-reverse place-items-center h-11">
327 <button
328 className="bg-transparent border-0 cursor-pointer transition-colors duration-100 p-0.5 hover:bg-[rgba(32,34,50,0.5)]"
329 onClick={() => message("Demo Information", `Demo ID: ${record.scores[0]?.demo_id}`)}
330 title="Demo Info"
331 >
332 <img src={ThreedotIcon} alt="Info" className="w-4 h-4" />
333 </button>
334 <button
335 className="bg-transparent border-0 cursor-pointer transition-colors duration-100 p-0.5 hover:bg-[rgba(32,34,50,0.5)]"
336 onClick={() => window.location.href = `/api/v1/demos?uuid=${record.scores[0]?.demo_id}`}
337 title="Download Demo"
338 >
339 <img src={DownloadIcon} alt="Download" className="w-4 h-4" />
340 </button>
341 {record.scores.length > 1 && (
342 <button className="bg-transparent border-0 cursor-pointer transition-colors duration-100 p-0.5 hover:bg-[rgba(32,34,50,0.5)]" title="View History">
343 <img src={HistoryIcon} alt="History" className="w-4 h-4" />
344 </button>
345 )}
346 </div>
347 </div>
348 ) : null
349 )
350 ) : (
351 maps
352 ?.filter(map => !map.is_disabled)
353 .sort((a, b) => a.id - b.id)
354 .map((map, index) => {
355 if (Math.ceil((index + 1) / 20) !== pageNumber) return null;
356
357 const record = user.records.find(r => r.map_id === map.id);
358
359 return (
360 <div key={index} className={`w-[calc(100%-40px)] mx-5 my-0 mt-[10px] h-11 rounded-[20px] pl-[40px] text-xl text-inherit font-inherit border-0 transition-colors duration-100 bg-[#2b2e46] grid grid-cols-[15%_15%_5%_15%_5%_15%_15%_15%] overflow-hidden whitespace-nowrap cursor-pointer hover:bg-[#202232] ${!record ? 'opacity-65' : ''}`}>
361 <Link to={`/maps/${map.id}`} className="text-[#3c91e6] no-underline font-inherit flex place-items-center h-11 hover:underline">
362 {map.name}
363 </Link>
364 <span className="flex place-items-center h-11">{record?.scores[0]?.score_count || 'N/A'}</span>
365 <span className={`flex place-items-center h-11 ${record?.scores[0]?.score_count && record.scores[0].score_count - record.map_wr_count > 0 ? 'text-[#dc3545]' : ''}`}>
366 {record?.scores[0]?.score_count && record.scores[0].score_count - record.map_wr_count > 0
367 ? `+${record.scores[0].score_count - record.map_wr_count}`
368 : '–'}
369 </span>
370 <span className="flex place-items-center h-11">{record?.scores[0] ? ticks_to_time(record.scores[0].score_time) : 'N/A'}</span>
371 <span className="flex place-items-center h-11"></span>
372 <span className="flex place-items-center h-11 font-semibold">{record ? `#${record.placement}` : 'N/A'}</span>
373 <span className="flex place-items-center h-11">{record?.scores[0]?.date.split("T")[0] || 'N/A'}</span>
374 <div className="flex gap-[5px] justify-end flex-row-reverse place-items-center h-11">
375 {record?.scores[0] && (
376 <>
377 <button
378 className="bg-transparent border-0 cursor-pointer transition-colors duration-100 p-0.5 hover:bg-[rgba(32,34,50,0.5)]"
379 onClick={() => message("Demo Information", `Demo ID: ${record.scores[0].demo_id}`)}
380 title="Demo Info"
381 >
382 <img src={ThreedotIcon} alt="Info" className="w-4 h-4" />
383 </button>
384 <button
385 className="bg-transparent border-0 cursor-pointer transition-colors duration-100 p-0.5 hover:bg-[rgba(32,34,50,0.5)]"
386 onClick={() => window.location.href = `/api/v1/demos?uuid=${record.scores[0].demo_id}`}
387 title="Download Demo"
388 >
389 <img src={DownloadIcon} alt="Download" className="w-4 h-4" />
390 </button>
391 {record.scores.length > 1 && (
392 <button className="bg-transparent border-0 cursor-pointer transition-colors duration-100 p-0.5 hover:bg-[rgba(32,34,50,0.5)]" title="View History">
393 <img src={HistoryIcon} alt="History" className="w-4 h-4" />
394 </button>
395 )}
396 </>
397 )}
398 </div>
399 </div>
400 );
401 })
402 )}
403 </div>
404 </section>
405 )}
406 </main>
407 );
408};
409
410export default User;