feat(ssr): server side render parts of the navbar
All checks were successful
deploy / deploy (push) Successful in 1m14s

This commit is contained in:
Lee 2023-11-08 09:15:42 +00:00
parent 9637993471
commit f2801f2ae7
9 changed files with 362 additions and 327 deletions

@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Footer from "./Footer"; import Footer from "./Footer";
import Navbar from "./Navbar"; import Navbar from "./navbar/Navbar";
export default function Container({ children }: { children: React.ReactNode }) { export default function Container({ children }: { children: React.ReactNode }) {
return ( return (

@ -1,162 +0,0 @@
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import useStore from "@/utils/useStore";
import {
CogIcon,
MagnifyingGlassIcon,
ServerIcon,
UserIcon,
} from "@heroicons/react/20/solid";
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import Avatar from "./Avatar";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface ButtonProps {
text: string;
icon?: JSX.Element;
href?: string;
ariaLabel: string;
}
function NavbarButton({ text, icon, href, ariaLabel }: ButtonProps) {
return (
<a
aria-label={ariaLabel}
className="flex h-full w-fit transform-gpu items-center justify-center gap-1 rounded-md p-[8px] transition-all hover:cursor-pointer hover:bg-blue-500"
href={href}
>
<>
{icon}
<p className="hidden md:block">{text}</p>
</>
</a>
);
}
function FriendsButton() {
const settingsStore = useStore(useSettingsStore, (state) => state);
return (
<Popover>
<PopoverTrigger>
<NavbarButton
ariaLabel="View your friends"
text="Friends"
icon={<UserIcon height={23} width={23} />}
/>
</PopoverTrigger>
<PopoverContent className="p-2">
{settingsStore?.friends.length == 0 ? (
<div className="flex flex-col gap-2">
<div>
<p className="text-md font-bold">No friends</p>
<p className="text-sm text-gray-400">
Add new friends by clicking below
</p>
</div>
<Link href={"/search"}>
<Button className="w-full" size={"sm"}>
Search
</Button>
</Link>
</div>
) : (
settingsStore?.friends.map((friend) => {
return (
<Link
key={friend.id}
href={`/player/${friend.id}/top/1`}
className="w-full"
>
<div className="flex transform-gpu gap-2 rounded-md p-2 text-left transition-all hover:bg-background">
<Avatar
url={friend.profilePicture}
label="Friend avatar"
size={48}
/>
<div>
<p className="text-sm text-gray-400">#{friend.rank}</p>
<p>{friend.name}</p>
</div>
</div>
</Link>
);
})
)}
</PopoverContent>
</Popover>
);
}
export default function Navbar() {
const settingsStore = useStore(useSettingsStore, (state) => state);
return (
<>
<Card className="flex h-fit w-full rounded-md">
{settingsStore !== undefined && settingsStore.player && (
<NavbarButton
ariaLabel="Your profile"
text="You"
icon={
<Avatar
url={settingsStore.player.profilePicture}
label="Your avatar"
size={23}
/>
}
href={`/player/${settingsStore.player.id}/top/1`}
/>
)}
<FriendsButton />
{/* TODO: fix hydration error? */}
{/* <Tooltip>
<TooltipTrigger>
<FriendsButton />
</TooltipTrigger>
<TooltipContent>Click to view your friends</TooltipContent>
</Tooltip> */}
<NavbarButton
ariaLabel="View the global ranking"
text="Ranking"
icon={<GlobeAltIcon height={23} width={23} />}
href="/ranking/global/1"
/>
<NavbarButton
ariaLabel="View the overlay builder"
text="Overlay"
icon={<TvIcon height={23} width={23} />}
href="/overlay/builder"
/>
<NavbarButton
ariaLabel="View analytics for Scoresaber"
text="Analytics"
icon={<ServerIcon height={23} width={23} />}
href="/analytics"
/>
<div className="m-auto" />
<NavbarButton
ariaLabel="Search for a player"
text="Search"
icon={<MagnifyingGlassIcon height={23} width={23} />}
href="/search"
/>
<NavbarButton
ariaLabel="View your settings"
text="Settings"
icon={<CogIcon height={23} width={23} />}
href="/settings"
/>
</Card>
</>
);
}

@ -0,0 +1,66 @@
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import useStore from "@/utils/useStore";
import { UserIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
import Avatar from "../Avatar";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import NavbarButton from "./NavbarButton";
export default function FriendsButton() {
const settingsStore = useStore(useSettingsStore, (state) => state);
return (
<Popover>
<PopoverTrigger>
<NavbarButton
ariaLabel="View your friends"
text="Friends"
icon={<UserIcon height={23} width={23} />}
/>
</PopoverTrigger>
<PopoverContent className="p-2">
{settingsStore?.friends.length == 0 ? (
<div className="flex flex-col gap-2">
<div>
<p className="text-md font-bold">No friends</p>
<p className="text-sm text-gray-400">
Add new friends by clicking below
</p>
</div>
<Link href={"/search"}>
<Button className="w-full" size={"sm"}>
Search
</Button>
</Link>
</div>
) : (
settingsStore?.friends.map((friend) => {
return (
<Link
key={friend.id}
href={`/player/${friend.id}/top/1`}
className="w-full"
>
<div className="flex transform-gpu gap-2 rounded-md p-2 text-left transition-all hover:bg-background">
<Avatar
url={friend.profilePicture}
label="Friend avatar"
size={48}
/>
<div>
<p className="text-sm text-gray-400">#{friend.rank}</p>
<p>{friend.name}</p>
</div>
</div>
</Link>
);
})
)}
</PopoverContent>
</Popover>
);
}

@ -0,0 +1,56 @@
import {
CogIcon,
MagnifyingGlassIcon,
ServerIcon,
} from "@heroicons/react/20/solid";
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
import { Card } from "../ui/card";
import FriendsButton from "./FriendsButton";
import NavbarButton from "./NavbarButton";
import YouButton from "./YouButton";
export default function Navbar() {
return (
<>
<Card className="flex h-fit w-full rounded-md">
<YouButton />
<FriendsButton />
<NavbarButton
ariaLabel="View the global ranking"
text="Ranking"
icon={<GlobeAltIcon height={23} width={23} />}
href="/ranking/global/1"
/>
<NavbarButton
ariaLabel="View the overlay builder"
text="Overlay"
icon={<TvIcon height={23} width={23} />}
href="/overlay/builder"
/>
<NavbarButton
ariaLabel="View analytics for Scoresaber"
text="Analytics"
icon={<ServerIcon height={23} width={23} />}
href="/analytics"
/>
<div className="m-auto" />
<NavbarButton
ariaLabel="Search for a player"
text="Search"
icon={<MagnifyingGlassIcon height={23} width={23} />}
href="/search"
/>
<NavbarButton
ariaLabel="View your settings"
text="Settings"
icon={<CogIcon height={23} width={23} />}
href="/settings"
/>
</Card>
</>
);
}

@ -0,0 +1,26 @@
interface ButtonProps {
text: string;
icon?: JSX.Element;
href?: string;
ariaLabel: string;
}
export default function NavbarButton({
text,
icon,
href,
ariaLabel,
}: ButtonProps) {
return (
<a
aria-label={ariaLabel}
className="flex h-full w-fit transform-gpu items-center justify-center gap-1 rounded-md p-[8px] transition-all hover:cursor-pointer hover:bg-blue-500"
href={href}
>
<>
{icon}
<p className="hidden md:block">{text}</p>
</>
</a>
);
}

@ -0,0 +1,29 @@
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { useStore } from "zustand";
import Avatar from "../Avatar";
import NavbarButton from "./NavbarButton";
export default function YouButton() {
const settingsStore = useStore(useSettingsStore, (state) => state);
if (!settingsStore || !settingsStore.player) {
return null;
}
return (
<NavbarButton
ariaLabel="Your profile"
text="You"
icon={
<Avatar
url={settingsStore.player.profilePicture}
label="Your avatar"
size={23}
/>
}
href={`/player/${settingsStore.player.id}/top/1`}
/>
);
}

@ -1,120 +1,19 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import { formatNumber } from "@/utils/numberUtils"; import { formatNumber } from "@/utils/numberUtils";
import { getAveragePp, getHighestPpPlay } from "@/utils/scoresaber/scores";
import { normalizedRegionName } from "@/utils/utils"; import { normalizedRegionName } from "@/utils/utils";
import { import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
GlobeAsiaAustraliaIcon,
HomeIcon,
UserIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import { useStore } from "zustand";
import Avatar from "../Avatar"; import Avatar from "../Avatar";
import Button from "../Button";
import Card from "../Card"; import Card from "../Card";
import CountyFlag from "../CountryFlag"; import CountyFlag from "../CountryFlag";
import Label from "../Label"; import Label from "../Label";
import PlayerInfoExtraLabels from "./PlayerInfoExtraLabels";
const PPGainLabel = dynamic(() => import("./PPGainLabel")); import PlayerSettingsButtons from "./PlayerSettingsButtons";
type PlayerInfoProps = { type PlayerInfoProps = {
playerData: ScoresaberPlayer; playerData: ScoresaberPlayer;
}; };
export default function PlayerInfo({ playerData }: PlayerInfoProps) { export default function PlayerInfo({ playerData }: PlayerInfoProps) {
const [mounted, setMounted] = useState(false);
const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
// Whether we have scores for this player in the local database
const hasLocalScores = playerScoreStore?.exists(playerId);
const toastId: any = useRef(null);
useEffect(() => {
setMounted(true);
}, []);
async function claimProfile() {
settingsStore?.setProfile(playerData);
addProfile(false);
}
async function addFriend() {
const friend = await settingsStore?.addFriend(playerData.id);
if (!friend) {
toast.error(`Failed to add ${playerData.name} as a friend`);
return;
}
addProfile(true);
}
async function removeFriend() {
settingsStore?.removeFriend(playerData.id);
toast.success(`Successfully removed ${playerData.name} as a friend`);
}
async function addProfile(isFriend: boolean) {
if (!useScoresaberScoresStore.getState().exists(playerId)) {
if (!isFriend) {
toast.success(`Successfully set ${playerData.name} as your profile`);
} else {
toast.success(`Successfully added ${playerData.name} as a friend`);
}
const reponse = await playerScoreStore?.addOrUpdatePlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
if (page != totalPages) {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
autoClose: autoClose,
});
} else {
toast.update(toastId.current, {
progress: 0,
render: `Successfully fetched scores for ${playerData.name}`,
autoClose: autoClose,
type: "success",
});
}
}
console.log(
`Fetching scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
}
}
}
const isOwnProfile = settingsStore.player?.id == playerId;
const scoreStats = playerData.scoreStats; const scoreStats = playerData.scoreStats;
return ( return (
@ -129,42 +28,7 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
{/* Settings Buttons */} {/* Settings Buttons */}
<div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center"> <div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center">
{mounted && ( <PlayerSettingsButtons playerData={playerData} />
<>
{!isOwnProfile && (
<Button
onClick={claimProfile}
tooltip={<p>Set as your Profile</p>}
icon={<HomeIcon width={24} height={24} />}
ariaLabel="Set as your Profile"
/>
)}
{!isOwnProfile && (
<>
{!settingsStore?.isFriend(playerId) && (
<Button
onClick={addFriend}
tooltip={<p>Add as Friend</p>}
icon={<UserIcon width={24} height={24} />}
color="bg-green-500"
ariaLabel="Add Friend"
/>
)}
{settingsStore.isFriend(playerId) && (
<Button
onClick={removeFriend}
tooltip={<p>Remove Friend</p>}
icon={<XMarkIcon width={24} height={24} />}
color="bg-red-500"
ariaLabel="Remove Friend"
/>
)}
</>
)}
</>
)}
</div> </div>
</div> </div>
@ -261,30 +125,7 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
value={formatNumber(scoreStats.replaysWatched)} value={formatNumber(scoreStats.replaysWatched)}
/> />
{hasLocalScores && ( <PlayerInfoExtraLabels playerId={playerData.id} />
<>
<Label
title="Top PP"
className="bg-pp-blue"
tooltip={<p>Their highest pp play</p>}
value={`${formatNumber(
getHighestPpPlay(playerId)?.toFixed(2),
)}pp`}
/>
<Label
title="Avg PP"
className="bg-pp-blue"
tooltip={
<p>Average amount of pp per play (best 50 scores)</p>
}
value={`${formatNumber(
getAveragePp(playerId)?.toFixed(2),
)}pp`}
/>
<PPGainLabel playerId={playerId} />
</>
)}
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,42 @@
"use client";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { formatNumber } from "@/utils/numberUtils";
import { getAveragePp, getHighestPpPlay } from "@/utils/scoresaber/scores";
import { useStore } from "zustand";
import Label from "../Label";
import PPGainLabel from "./PPGainLabel";
type PlayerInfoExtraLabelsProps = {
playerId: string;
};
export default function PlayerInfoExtraLabels({
playerId,
}: PlayerInfoExtraLabelsProps) {
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
const hasLocalScores = playerScoreStore.exists(playerId);
if (!hasLocalScores) {
return null;
}
return (
<>
<Label
title="Top PP"
className="bg-pp-blue"
tooltip={<p>Their highest pp play</p>}
value={`${formatNumber(getHighestPpPlay(playerId)?.toFixed(2))}pp`}
/>
<Label
title="Avg PP"
className="bg-pp-blue"
tooltip={<p>Average amount of pp per play (best 50 scores)</p>}
value={`${formatNumber(getAveragePp(playerId)?.toFixed(2))}pp`}
/>
<PPGainLabel playerId={playerId} />
</>
);
}

@ -0,0 +1,137 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import { HomeIcon, UserIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import { useStore } from "zustand";
import Button from "../Button";
type PlayerSettingsButtonsProps = {
playerData: ScoresaberPlayer;
};
export default function PlayerSettingsButtons({
playerData,
}: PlayerSettingsButtonsProps) {
const [mounted, setMounted] = useState(false);
const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
const isOwnProfile = settingsStore.player?.id == playerId;
const toastId: any = useRef(null);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || isOwnProfile) {
return null;
}
async function claimProfile() {
settingsStore?.setProfile(playerData);
addProfile(false);
}
async function addFriend() {
const friend = await settingsStore?.addFriend(playerData.id);
if (!friend) {
toast.error(`Failed to add ${playerData.name} as a friend`);
return;
}
addProfile(true);
}
async function removeFriend() {
settingsStore?.removeFriend(playerData.id);
toast.success(`Successfully removed ${playerData.name} as a friend`);
}
async function addProfile(isFriend: boolean) {
if (!useScoresaberScoresStore.getState().exists(playerId)) {
if (!isFriend) {
toast.success(`Successfully set ${playerData.name} as your profile`);
} else {
toast.success(`Successfully added ${playerData.name} as a friend`);
}
const reponse = await playerScoreStore?.addOrUpdatePlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
if (page != totalPages) {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
autoClose: autoClose,
});
} else {
toast.update(toastId.current, {
progress: 0,
render: `Successfully fetched scores for ${playerData.name}`,
autoClose: autoClose,
type: "success",
});
}
}
console.log(
`Fetching scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
}
}
}
return (
<>
<Button
onClick={claimProfile}
tooltip={<p>Set as your Profile</p>}
icon={<HomeIcon width={24} height={24} />}
ariaLabel="Set as your Profile"
/>
{!settingsStore?.isFriend(playerId) && (
<Button
onClick={addFriend}
tooltip={<p>Add as Friend</p>}
icon={<UserIcon width={24} height={24} />}
color="bg-green-500"
ariaLabel="Add Friend"
/>
)}
{settingsStore.isFriend(playerId) && (
<Button
onClick={removeFriend}
tooltip={<p>Remove Friend</p>}
icon={<XMarkIcon width={24} height={24} />}
color="bg-red-500"
ariaLabel="Remove Friend"
/>
)}
</>
);
}