impl friends system
This commit is contained in:
parent
2e2c03241e
commit
8ab81b1b27
129
projects/common/src/cache.ts
Normal file
129
projects/common/src/cache.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
type CacheOptions = {
|
||||||
|
/**
|
||||||
|
* The time the cached object will be valid for
|
||||||
|
*/
|
||||||
|
ttl?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often to check for expired objects
|
||||||
|
*/
|
||||||
|
checkInterval?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable debug messages
|
||||||
|
*/
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CachedObject = {
|
||||||
|
/**
|
||||||
|
* The cached object
|
||||||
|
*/
|
||||||
|
value: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp the object was cached
|
||||||
|
*/
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SSRCache {
|
||||||
|
/**
|
||||||
|
* The time the cached object will be valid for
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly ttl: number | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often to check for expired objects
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly checkInterval: number | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable debug messages
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly debug: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The objects that have been cached
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private cache = new Map<string, CachedObject>();
|
||||||
|
|
||||||
|
constructor({ ttl, checkInterval, debug }: CacheOptions) {
|
||||||
|
this.ttl = ttl;
|
||||||
|
this.checkInterval = checkInterval || this.ttl ? 1000 * 60 : undefined; // 1 minute
|
||||||
|
this.debug = debug || false;
|
||||||
|
|
||||||
|
if (this.ttl !== undefined && this.checkInterval !== undefined) {
|
||||||
|
setInterval(() => {
|
||||||
|
for (const [key, value] of this.cache.entries()) {
|
||||||
|
if (value.timestamp + this.ttl! > Date.now()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.remove(key);
|
||||||
|
}
|
||||||
|
}, this.checkInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an object from the cache
|
||||||
|
*
|
||||||
|
* @param key the cache key for the object
|
||||||
|
*/
|
||||||
|
public get<T>(key: string): T | undefined {
|
||||||
|
const cachedObject = this.cache.get(key);
|
||||||
|
if (cachedObject === undefined) {
|
||||||
|
if (this.debug) {
|
||||||
|
console.log(`Cache miss for key: ${key}`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (this.debug) {
|
||||||
|
console.log(`Retrieved ${key} from cache, value: ${JSON.stringify(cachedObject)}`);
|
||||||
|
}
|
||||||
|
return cachedObject.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets an object in the cache
|
||||||
|
*
|
||||||
|
* @param key the cache key
|
||||||
|
* @param value the object
|
||||||
|
*/
|
||||||
|
public set<T>(key: string, value: T): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.debug) {
|
||||||
|
console.log(`Inserted ${key} into cache, value: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an object is in the cache
|
||||||
|
*
|
||||||
|
* @param key the cache key
|
||||||
|
*/
|
||||||
|
public has(key: string): boolean {
|
||||||
|
return this.cache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an object from the cache
|
||||||
|
*
|
||||||
|
* @param key the cache key
|
||||||
|
*/
|
||||||
|
public remove(key: string): void {
|
||||||
|
this.cache.delete(key);
|
||||||
|
|
||||||
|
if (this.debug) {
|
||||||
|
console.log(`Removed ${key} from cache`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber
|
|||||||
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
||||||
import { clamp, lerp } from "../../utils/math-utils";
|
import { clamp, lerp } from "../../utils/math-utils";
|
||||||
import { CurvePoint } from "../../utils/curve-point";
|
import { CurvePoint } from "../../utils/curve-point";
|
||||||
|
import { SSRCache } from "../../cache";
|
||||||
|
|
||||||
const API_BASE = "https://scoresaber.com/api";
|
const API_BASE = "https://scoresaber.com/api";
|
||||||
|
|
||||||
@ -28,6 +29,10 @@ const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/sc
|
|||||||
|
|
||||||
const STAR_MULTIPLIER = 42.117208413;
|
const STAR_MULTIPLIER = 42.117208413;
|
||||||
|
|
||||||
|
const playerCache = new SSRCache({
|
||||||
|
ttl: 60 * 30, // 30 minutes
|
||||||
|
});
|
||||||
|
|
||||||
class ScoreSaberService extends Service {
|
class ScoreSaberService extends Service {
|
||||||
private curvePoints = [
|
private curvePoints = [
|
||||||
new CurvePoint(0, 0),
|
new CurvePoint(0, 0),
|
||||||
@ -98,9 +103,13 @@ class ScoreSaberService extends Service {
|
|||||||
* Looks up a player by their ID.
|
* Looks up a player by their ID.
|
||||||
*
|
*
|
||||||
* @param playerId the ID of the player to look up
|
* @param playerId the ID of the player to look up
|
||||||
|
* @param cache whether to use the local cache
|
||||||
* @returns the player that matches the ID, or undefined
|
* @returns the player that matches the ID, or undefined
|
||||||
*/
|
*/
|
||||||
public async lookupPlayer(playerId: string): Promise<ScoreSaberPlayerToken | undefined> {
|
public async lookupPlayer(playerId: string, cache: boolean = false): Promise<ScoreSaberPlayerToken | undefined> {
|
||||||
|
if (cache && playerCache.has(playerId)) {
|
||||||
|
return playerCache.get(playerId);
|
||||||
|
}
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up player "${playerId}"...`);
|
this.log(`Looking up player "${playerId}"...`);
|
||||||
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
|
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
|
||||||
@ -108,6 +117,9 @@ class ScoreSaberService extends Service {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
|
if (cache) {
|
||||||
|
playerCache.set(playerId, token);
|
||||||
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,8 +2,12 @@ import Dexie, { EntityTable } from "dexie";
|
|||||||
import BeatSaverMap from "./types/beatsaver-map";
|
import BeatSaverMap from "./types/beatsaver-map";
|
||||||
import Settings from "./types/settings";
|
import Settings from "./types/settings";
|
||||||
import { setCookieValue } from "@/common/cookie-utils";
|
import { setCookieValue } from "@/common/cookie-utils";
|
||||||
|
import { Friend } from "@/common/database/types/friends";
|
||||||
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
|
|
||||||
const SETTINGS_ID = "SSR"; // DO NOT CHANGE
|
const SETTINGS_ID = "SSR"; // DO NOT CHANGE
|
||||||
|
const FRIENDS_ID = "FRIENDS"; // DO NOT CHANGE
|
||||||
|
|
||||||
export default class Database extends Dexie {
|
export default class Database extends Dexie {
|
||||||
/**
|
/**
|
||||||
@ -16,6 +20,11 @@ export default class Database extends Dexie {
|
|||||||
*/
|
*/
|
||||||
beatSaverMaps!: EntityTable<BeatSaverMap, "hash">;
|
beatSaverMaps!: EntityTable<BeatSaverMap, "hash">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The added friends
|
||||||
|
*/
|
||||||
|
friends!: EntityTable<Friend, "id">;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("ScoreSaberReloaded");
|
super("ScoreSaberReloaded");
|
||||||
|
|
||||||
@ -23,6 +32,7 @@ export default class Database extends Dexie {
|
|||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
settings: "id",
|
settings: "id",
|
||||||
beatSaverMaps: "hash",
|
beatSaverMaps: "hash",
|
||||||
|
friends: "id",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mapped tables
|
// Mapped tables
|
||||||
@ -68,6 +78,49 @@ export default class Database extends Dexie {
|
|||||||
return this.settings.update(SETTINGS_ID, settings);
|
return this.settings.update(SETTINGS_ID, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a friend
|
||||||
|
*
|
||||||
|
* @param id the id of the friend
|
||||||
|
*/
|
||||||
|
public async addFriend(id: string) {
|
||||||
|
await this.friends.add({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a friend
|
||||||
|
*
|
||||||
|
* @param id the id of the friend
|
||||||
|
*/
|
||||||
|
public async removeFriend(id: string) {
|
||||||
|
await this.friends.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this player is a friend
|
||||||
|
*
|
||||||
|
* @param id the id of the player
|
||||||
|
*/
|
||||||
|
public async isFriend(id: string): Promise<boolean> {
|
||||||
|
const friend = await this.friends.get(id);
|
||||||
|
return friend != undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all friends as {@link ScoreSaberPlayerToken}'s
|
||||||
|
*
|
||||||
|
* @returns the friends
|
||||||
|
*/
|
||||||
|
public async getFriends(): Promise<ScoreSaberPlayerToken[]> {
|
||||||
|
const friends = await this.friends.toArray();
|
||||||
|
const players = await Promise.all(
|
||||||
|
friends.map(async ({ id }) => {
|
||||||
|
return await scoresaberService.lookupPlayer(id, true);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return players.filter(player => player !== undefined) as ScoreSaberPlayerToken[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets the settings in the database
|
* Resets the settings in the database
|
||||||
*/
|
*/
|
||||||
|
6
projects/website/src/common/database/types/friends.ts
Normal file
6
projects/website/src/common/database/types/friends.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface Friend {
|
||||||
|
/**
|
||||||
|
* The id of the friend
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
}
|
49
projects/website/src/components/friend/add-friend.tsx
Normal file
49
projects/website/src/components/friend/add-friend.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import useDatabase from "../../hooks/use-database";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import Tooltip from "../tooltip";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { PersonIcon } from "@radix-ui/react-icons";
|
||||||
|
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The ID of the players profile to claim.
|
||||||
|
*/
|
||||||
|
player: ScoreSaberPlayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AddFriend({ player }: Props) {
|
||||||
|
const { id, name } = player;
|
||||||
|
|
||||||
|
const database = useDatabase();
|
||||||
|
const isFriend = useLiveQuery(() => database.isFriend(id));
|
||||||
|
const settings = useLiveQuery(() => database.getSettings());
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds this player as a friend
|
||||||
|
*/
|
||||||
|
async function addFriend() {
|
||||||
|
await database.addFriend(id);
|
||||||
|
toast({
|
||||||
|
title: "Friend Added",
|
||||||
|
description: `You have added ${name} as a friend.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the player is already a friend, don't show the button
|
||||||
|
if (isFriend || settings?.playerId == id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip display={<p>Add {name} as a friend!</p>} side={"bottom"}>
|
||||||
|
<Button variant={"outline"} onClick={addFriend}>
|
||||||
|
<PersonIcon className="size-6 text-green-500" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
56
projects/website/src/components/friend/friend.tsx
Normal file
56
projects/website/src/components/friend/friend.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
|
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import useDatabase from "@/hooks/use-database";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import Tooltip from "@/components/tooltip";
|
||||||
|
|
||||||
|
type FriendProps = {
|
||||||
|
/**
|
||||||
|
* The friend to display.
|
||||||
|
*/
|
||||||
|
player: ScoreSaberPlayerToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the friend is clicked
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Friend({ player, onClick }: FriendProps) {
|
||||||
|
const { id, name } = player;
|
||||||
|
const database = useDatabase();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds this player as a friend
|
||||||
|
*/
|
||||||
|
async function removeFriend() {
|
||||||
|
await database.removeFriend(id);
|
||||||
|
toast({
|
||||||
|
title: "Friend Removed",
|
||||||
|
description: `You have removed ${name} as a friend.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-2 hover:bg-accent transition-all transform-gpu p-2 rounded-md select-none">
|
||||||
|
<Link href={`/player/${player.id}`} onClick={() => onClick?.()} className="flex items-center gap-2 w-full">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={player.profilePicture} alt={player.name} />
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-lg font-semibold">{player.name}</p>
|
||||||
|
<p className="text-gray-400">#{formatNumberWithCommas(player.rank)}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Tooltip display={<p className="cursor-default pointer-events-none">Remove {name} from your friends</p>}>
|
||||||
|
<button onClick={() => removeFriend()}>
|
||||||
|
<XIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
47
projects/website/src/components/navbar/friends-button.tsx
Normal file
47
projects/website/src/components/navbar/friends-button.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useDatabase from "@/hooks/use-database";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import NavbarButton from "./navbar-button";
|
||||||
|
import { PersonIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import Friend from "@/components/friend/friend";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function FriendsButton() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const database = useDatabase();
|
||||||
|
const friends = useLiveQuery(() => database.getFriends());
|
||||||
|
if (friends == undefined) {
|
||||||
|
return; // Friends haven't loaded yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger className="h-full">
|
||||||
|
<NavbarButton>
|
||||||
|
<PersonIcon className="w-6 h-6" />
|
||||||
|
<p className="hidden lg:block">Friends</p>
|
||||||
|
</NavbarButton>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-2">
|
||||||
|
{friends.length > 0 ? (
|
||||||
|
friends.map(friend => <Friend player={friend} key={friend.id} onClick={() => setOpen(false)} />)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm flex flex-col gap-2 justify-center items-center">
|
||||||
|
<p>You don't have any friends :(</p>
|
||||||
|
|
||||||
|
<Link href="/search">
|
||||||
|
<Button size="sm" onClick={() => setOpen(false)}>
|
||||||
|
Search Player
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
@ -1,10 +1,11 @@
|
|||||||
import { CogIcon, HomeIcon } from "@heroicons/react/24/solid";
|
import { CogIcon } from "@heroicons/react/24/solid";
|
||||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import NavbarButton from "./navbar-button";
|
import NavbarButton from "./navbar-button";
|
||||||
import ProfileButton from "./profile-button";
|
import ProfileButton from "./profile-button";
|
||||||
import { SwordIcon, TrendingUpIcon } from "lucide-react";
|
import { SwordIcon, TrendingUpIcon } from "lucide-react";
|
||||||
|
import FriendsButton from "@/components/navbar/friends-button";
|
||||||
|
|
||||||
type NavbarItem = {
|
type NavbarItem = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -14,12 +15,6 @@ type NavbarItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const items: NavbarItem[] = [
|
const items: NavbarItem[] = [
|
||||||
{
|
|
||||||
name: "Home",
|
|
||||||
link: "/",
|
|
||||||
align: "left",
|
|
||||||
icon: <HomeIcon className="h-5 w-5" />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Ranking",
|
name: "Ranking",
|
||||||
link: "/ranking",
|
link: "/ranking",
|
||||||
@ -61,11 +56,25 @@ export default function Navbar() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full sticky top-0 z-[999] h-10 items-center flex justify-between bg-secondary/95 px-1">
|
<div className="w-full sticky top-0 z-[999] h-10 items-center flex justify-between bg-secondary/95 px-1">
|
||||||
<div className="md:max-w-[1600px] w-full h-full flex justify-between m-auto">
|
<div className="md:max-w-[1600px] w-full h-full flex justify-between m-auto">
|
||||||
{/* Left-aligned items */}
|
|
||||||
<div className="flex items-center h-full">
|
<div className="flex items-center h-full">
|
||||||
<ProfileButton />
|
{/* Home Button */}
|
||||||
|
<Link href="/" className="h-full">
|
||||||
|
<NavbarButton>
|
||||||
|
{renderNavbarItem({
|
||||||
|
name: "Home",
|
||||||
|
link: "/",
|
||||||
|
align: "left",
|
||||||
|
icon: <img src="/assets/logos/scoresaber.png" className="h-5 w-5" alt="Website Logo" />,
|
||||||
|
})}
|
||||||
|
</NavbarButton>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{leftItems.map((item, index) => (
|
{/* Player Buttons */}
|
||||||
|
<ProfileButton />
|
||||||
|
<FriendsButton />
|
||||||
|
|
||||||
|
{/* Left-aligned items */}
|
||||||
|
{leftItems.splice(0, leftItems.length).map((item, index) => (
|
||||||
<Link href={item.link} key={index} className="h-full">
|
<Link href={item.link} key={index} className="h-full">
|
||||||
<NavbarButton key={index}>{renderNavbarItem(item)}</NavbarButton>
|
<NavbarButton key={index}>{renderNavbarItem(item)}</NavbarButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -11,6 +11,7 @@ import PlayerTrackedStatus from "@/components/player/player-tracked-status";
|
|||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { capitalizeFirstLetter } from "@/common/string-utils";
|
import { capitalizeFirstLetter } from "@/common/string-utils";
|
||||||
|
import AddFriend from "@/components/friend/add-friend";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the change for a stat.
|
* Renders the change for a stat.
|
||||||
@ -197,7 +198,8 @@ export default function PlayerHeader({ player }: Props) {
|
|||||||
|
|
||||||
<PlayerStats player={player} />
|
<PlayerStats player={player} />
|
||||||
|
|
||||||
<div className="absolute top-0 right-0">
|
<div className="absolute top-0 right-0 gap-2 flex">
|
||||||
|
<AddFriend player={player} />
|
||||||
<ClaimProfile playerId={player.id} />
|
<ClaimProfile playerId={player.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user