impl friends system
All checks were successful
Deploy Backend / deploy (push) Successful in 2m43s
Deploy Website / deploy (push) Successful in 4m55s

This commit is contained in:
Lee 2024-10-16 11:23:28 +01:00
parent 2e2c03241e
commit 8ab81b1b27
9 changed files with 375 additions and 12 deletions

View 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`);
}
}
}

View File

@ -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 { clamp, lerp } from "../../utils/math-utils";
import { CurvePoint } from "../../utils/curve-point";
import { SSRCache } from "../../cache";
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 playerCache = new SSRCache({
ttl: 60 * 30, // 30 minutes
});
class ScoreSaberService extends Service {
private curvePoints = [
new CurvePoint(0, 0),
@ -98,9 +103,13 @@ class ScoreSaberService extends Service {
* Looks up a player by their ID.
*
* @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
*/
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();
this.log(`Looking up player "${playerId}"...`);
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
@ -108,6 +117,9 @@ class ScoreSaberService extends Service {
return undefined;
}
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
if (cache) {
playerCache.set(playerId, token);
}
return token;
}

View File

@ -2,8 +2,12 @@ import Dexie, { EntityTable } from "dexie";
import BeatSaverMap from "./types/beatsaver-map";
import Settings from "./types/settings";
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 FRIENDS_ID = "FRIENDS"; // DO NOT CHANGE
export default class Database extends Dexie {
/**
@ -16,6 +20,11 @@ export default class Database extends Dexie {
*/
beatSaverMaps!: EntityTable<BeatSaverMap, "hash">;
/**
* The added friends
*/
friends!: EntityTable<Friend, "id">;
constructor() {
super("ScoreSaberReloaded");
@ -23,6 +32,7 @@ export default class Database extends Dexie {
this.version(1).stores({
settings: "id",
beatSaverMaps: "hash",
friends: "id",
});
// Mapped tables
@ -68,6 +78,49 @@ export default class Database extends Dexie {
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
*/

View File

@ -0,0 +1,6 @@
export interface Friend {
/**
* The id of the friend
*/
id: string;
}

View 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>
);
}

View 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>
);
}

View 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&#39;t have any friends :(</p>
<Link href="/search">
<Button size="sm" onClick={() => setOpen(false)}>
Search Player
</Button>
</Link>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@ -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 Link from "next/link";
import React from "react";
import NavbarButton from "./navbar-button";
import ProfileButton from "./profile-button";
import { SwordIcon, TrendingUpIcon } from "lucide-react";
import FriendsButton from "@/components/navbar/friends-button";
type NavbarItem = {
name: string;
@ -14,12 +15,6 @@ type NavbarItem = {
};
const items: NavbarItem[] = [
{
name: "Home",
link: "/",
align: "left",
icon: <HomeIcon className="h-5 w-5" />,
},
{
name: "Ranking",
link: "/ranking",
@ -61,11 +56,25 @@ export default function Navbar() {
return (
<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">
{/* Left-aligned items */}
<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">
<NavbarButton key={index}>{renderNavbarItem(item)}</NavbarButton>
</Link>

View File

@ -11,6 +11,7 @@ import PlayerTrackedStatus from "@/components/player/player-tracked-status";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
import Link from "next/link";
import { capitalizeFirstLetter } from "@/common/string-utils";
import AddFriend from "@/components/friend/add-friend";
/**
* Renders the change for a stat.
@ -197,7 +198,8 @@ export default function PlayerHeader({ player }: Props) {
<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} />
</div>
</div>