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 { 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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
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 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>
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user