diff --git a/package-lock.json b/package-lock.json index 25b1298..8a79e07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "clsx": "^2.0.0", "date-fns": "^2.30.0", "encoding": "^0.1.13", + "idb-keyval": "^6.2.1", "next": "13.5.6", "next-build-id": "^3.0.0", "node-fetch-cache": "^3.1.3", @@ -3010,6 +3011,11 @@ "node": ">=0.10.0" } }, + "node_modules/idb-keyval": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 5666679..7d8bc96 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "clsx": "^2.0.0", "date-fns": "^2.30.0", "encoding": "^0.1.13", + "idb-keyval": "^6.2.1", "next": "13.5.6", "next-build-id": "^3.0.0", "node-fetch-cache": "^3.1.3", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11a7c54..7d13e11 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import { AppProvider } from "@/components/AppProvider"; +import AppProvider from "@/components/AppProvider"; import { ssrSettings } from "@/ssrSettings"; import { Metadata } from "next"; import { Inter } from "next/font/google"; diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index f742acf..645b66e 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,18 +1,48 @@ "use client"; import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore"; - -type AppProviderProps = { - children: React.ReactNode; -}; - -export function AppProvider({ children }: AppProviderProps) { - return <>{children}; -} - +import { useSettingsStore } from "@/store/settingsStore"; +import React from "react"; const UPDATE_INTERVAL = 1000 * 60 * 15; // 15 minutes -useScoresaberScoresStore.getState().updatePlayerScores(); -setInterval(() => { - useScoresaberScoresStore.getState().updatePlayerScores(); -}, UPDATE_INTERVAL); +export default class AppProvider extends React.Component { + _state = { + mounted: false, // Whether the component has mounted + // Whether the data from the async storage has been loaded + dataLoaded: { + scores: false, + settings: false, + }, + }; + + async componentDidMount(): Promise { + if (this._state.mounted) { + return; + } + this._state.mounted = true; + + // Load data from async storage + await useSettingsStore.persist.rehydrate(); + await useScoresaberScoresStore.persist.rehydrate(); + + await useSettingsStore.getState().refreshProfiles(); + setInterval(() => { + useSettingsStore.getState().refreshProfiles(); + }, UPDATE_INTERVAL); + + await useScoresaberScoresStore.getState().updatePlayerScores(); + setInterval(() => { + useScoresaberScoresStore.getState().updatePlayerScores(); + }, UPDATE_INTERVAL); + } + + constructor(props: any) { + super(props); + } + + render(): React.ReactNode { + const props: any = this.props; + + return <>{props.children}; + } +} diff --git a/src/components/player/PlayerPage.tsx b/src/components/player/PlayerPage.tsx index da6c3fe..cff5d26 100644 --- a/src/components/player/PlayerPage.tsx +++ b/src/components/player/PlayerPage.tsx @@ -8,6 +8,7 @@ import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { useSettingsStore } from "@/store/settingsStore"; import { SortType, SortTypes } from "@/types/SortTypes"; import { ScoreSaberAPI } from "@/utils/scoresaber/api"; +import useStore from "@/utils/useStore"; import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -27,6 +28,7 @@ type PlayerPageProps = { const DEFAULT_SORT_TYPE = SortTypes.top; export default function PlayerPage({ id }: PlayerPageProps) { + const settingsStore = useStore(useSettingsStore, (store) => store); const searchParams = useSearchParams(); const [mounted, setMounted] = useState(false); @@ -49,8 +51,7 @@ export default function PlayerPage({ id }: PlayerPageProps) { let sortType: SortType; const sortTypeString = searchParams.get("sort"); if (sortTypeString == null) { - sortType = - useSettingsStore.getState().lastUsedSortType || DEFAULT_SORT_TYPE; + sortType = settingsStore?.lastUsedSortType || DEFAULT_SORT_TYPE; } else { sortType = SortTypes[sortTypeString] || DEFAULT_SORT_TYPE; } diff --git a/src/components/player/Scores.tsx b/src/components/player/Scores.tsx index 0b408be..f549544 100644 --- a/src/components/player/Scores.tsx +++ b/src/components/player/Scores.tsx @@ -3,6 +3,7 @@ import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; import { useSettingsStore } from "@/store/settingsStore"; import { SortType, SortTypes } from "@/types/SortTypes"; import { ScoreSaberAPI } from "@/utils/scoresaber/api"; +import useStore from "@/utils/useStore"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; @@ -27,6 +28,7 @@ type ScoresProps = { }; export default function Scores({ playerData, page, sortType }: ScoresProps) { + const settingsStore = useStore(useSettingsStore, (store) => store); const playerId = playerData.id; const router = useRouter(); @@ -61,9 +63,7 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) { page: page, sortType: sortType, }); - useSettingsStore.setState({ - lastUsedSortType: sortType, - }); + settingsStore?.setLastUsedSortType(sortType); if (page > 1) { router.push( @@ -80,7 +80,7 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) { }, ); }, - [playerId, router, scores], + [playerId, router, scores, settingsStore], ); useEffect(() => { diff --git a/src/store/IndexedDBStorage.ts b/src/store/IndexedDBStorage.ts new file mode 100644 index 0000000..c8b9fd9 --- /dev/null +++ b/src/store/IndexedDBStorage.ts @@ -0,0 +1,19 @@ +"use client"; + +import { del, get, set } from "idb-keyval"; +import { StateStorage } from "zustand/middleware"; + +export const IDBStorage: StateStorage = { + getItem: async (name: string): Promise => { + //console.log(name, "has been retrieved"); + return (await get(name)) || null; + }, + setItem: async (name: string, value: string): Promise => { + //console.log(name, "with value", value, "has been saved"); + await set(name, value); + }, + removeItem: async (name: string): Promise => { + //console.log(name, "has been deleted"); + await del(name); + }, +}; diff --git a/src/store/scoresaberScoresStore.ts b/src/store/scoresaberScoresStore.ts index 6a7c9fa..d86eea1 100644 --- a/src/store/scoresaberScoresStore.ts +++ b/src/store/scoresaberScoresStore.ts @@ -6,13 +6,12 @@ import { ScoreSaberAPI } from "@/utils/scoresaber/api"; import { toast } from "react-toastify"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { IDBStorage } from "./IndexedDBStorage"; import { useSettingsStore } from "./settingsStore"; type Player = { id: string; - scores: { - scoresaber: ScoresaberSmallerPlayerScore[]; - }; + scores: ScoresaberSmallerPlayerScore[]; }; interface ScoreSaberScoresStore { @@ -60,14 +59,14 @@ interface ScoreSaberScoresStore { /** * Refreshes the player scores and adds any new scores to the local database */ - updatePlayerScores: () => void; + updatePlayerScores: () => Promise; } const UPDATE_INTERVAL = 1000 * 60 * 30; // 30 minutes export const useScoresaberScoresStore = create()( persist( - (set) => ({ + (set, get) => ({ lastUpdated: 0, players: [], @@ -76,12 +75,12 @@ export const useScoresaberScoresStore = create()( }, exists: (playerId: string) => { - const players: Player[] = useScoresaberScoresStore.getState().players; + const players: Player[] = get().players; return players.some((player) => player.id == playerId); }, get: (playerId: string) => { - const players: Player[] = useScoresaberScoresStore.getState().players; + const players: Player[] = get().players; return players.find((player) => player.id == playerId); }, @@ -89,10 +88,10 @@ export const useScoresaberScoresStore = create()( playerId: string, callback?: (page: number, totalPages: number) => void, ) => { - const players = useScoresaberScoresStore.getState().players; + const players: Player[] = get().players; // Check if the player already exists - if (useScoresaberScoresStore.getState().exists(playerId)) { + if (get().exists(playerId)) { return { error: true, message: "Player already exists", @@ -146,13 +145,12 @@ export const useScoresaberScoresStore = create()( } // Remove scores that are already in the database - const player = useScoresaberScoresStore.getState().get(playerId); + const player = get().get(playerId); if (player) { scores = scores.filter( (score) => - player.scores.scoresaber.findIndex( - (s) => s.score.id == score.score.id, - ) == -1, + player.scores.findIndex((s) => s.score.id == score.score.id) == + -1, ); } @@ -161,9 +159,7 @@ export const useScoresaberScoresStore = create()( ...players, { id: playerId, - scores: { - scoresaber: scores, - }, + scores: scores, }, ], }); @@ -174,7 +170,7 @@ export const useScoresaberScoresStore = create()( }, updatePlayerScores: async () => { - const players = useScoresaberScoresStore.getState().players; + const players = get().players; const friends = useSettingsStore.getState().friends; let allPlayers = new Array(); @@ -188,7 +184,11 @@ export const useScoresaberScoresStore = create()( // add local player and friends if they don't exist for (const player of allPlayers) { - if (useScoresaberScoresStore.getState().get(player.id) == undefined) { + if (get().lastUpdated == 0) { + set({ lastUpdated: Date.now() }); + } + + if (get().get(player.id) == undefined) { toast.info( `${ player.id == localPlayer?.id @@ -196,7 +196,7 @@ export const useScoresaberScoresStore = create()( : `Friend ${player.name} was` } missing from the ScoreSaber scores database, adding...`, ); - await useScoresaberScoresStore.getState().addPlayer(player.id); + await get().addPlayer(player.id); toast.success( `${ player.id == useSettingsStore.getState().player?.id @@ -209,18 +209,14 @@ export const useScoresaberScoresStore = create()( // Skip if we refreshed the scores recently const timeUntilRefreshMs = - UPDATE_INTERVAL - - (Date.now() - useScoresaberScoresStore.getState().lastUpdated); + UPDATE_INTERVAL - (Date.now() - get().lastUpdated); if (timeUntilRefreshMs > 0) { console.log( "Waiting", timeUntilRefreshMs / 1000, "seconds to refresh scores for players", ); - setTimeout( - () => useScoresaberScoresStore.getState().updatePlayerScores(), - timeUntilRefreshMs, - ); + setTimeout(() => get().updatePlayerScores(), timeUntilRefreshMs); return; } @@ -229,7 +225,7 @@ export const useScoresaberScoresStore = create()( if (player == undefined) continue; console.log(`Updating scores for ${player.id}...`); - let oldScores = player.scores.scoresaber; + let oldScores: ScoresaberSmallerPlayerScore[] = player.scores; // Sort the scores by date (newset to oldest), so we know when to stop searching for new scores oldScores = oldScores.sort((a, b) => { @@ -296,13 +292,13 @@ export const useScoresaberScoresStore = create()( let newPlayers = players; // Remove the player if it already exists - newPlayers = newPlayers.filter((playerr) => playerr.id != player.id); + newPlayers = newPlayers.filter( + (playerr: Player) => playerr.id != player.id, + ); // Add the player newPlayers.push({ id: player.id, - scores: { - scoresaber: oldScores, - }, + scores: oldScores, }); if (newScoresCount > 0) { @@ -318,23 +314,8 @@ export const useScoresaberScoresStore = create()( }), { name: "scoresaberScores", - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => IDBStorage), version: 1, - - migrate: (state: any, version: number) => { - if (version == 1) { - state.players = state.players.map((player: any) => { - return { - id: player.id, - scores: { - scoresaber: player.scores, - }, - }; - }); - - return state; - } - }, }, ), ); diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index f01fa1e..8494ece 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -5,6 +5,7 @@ import { SortType, SortTypes } from "@/types/SortTypes"; import { ScoreSaberAPI } from "@/utils/scoresaber/api"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { IDBStorage } from "./IndexedDBStorage"; interface SettingsStore { player: ScoresaberPlayer | undefined; @@ -19,14 +20,14 @@ interface SettingsStore { clearFriends: () => void; setLastUsedSortType: (sortType: SortType) => void; setProfilesLastUpdated: (profilesLastUpdated: number) => void; - refreshProfiles: () => void; + refreshProfiles: () => Promise; } const UPDATE_INTERVAL = 1000 * 60 * 10; // 10 minutes export const useSettingsStore = create()( persist( - (set) => ({ + (set, get) => ({ player: undefined, lastUsedSortType: SortTypes.top, friends: [], @@ -39,7 +40,7 @@ export const useSettingsStore = create()( }, async addFriend(friendId: string) { - const friends = useSettingsStore.getState().friends; + const friends = this.friends; if (friends.some((friend) => friend.id == friendId)) { return false; } @@ -52,7 +53,7 @@ export const useSettingsStore = create()( }, removeFriend: (friendId: string) => { - const friends = useSettingsStore.getState().friends; + const friends = get().friends; set({ friends: friends.filter((friend) => friend.id != friendId) }); return friendId; @@ -61,7 +62,7 @@ export const useSettingsStore = create()( clearFriends: () => set({ friends: [] }), isFriend: (friendId: string) => { - const friends: ScoresaberPlayer[] = useSettingsStore.getState().friends; + const friends: ScoresaberPlayer[] = get().friends; return friends.some((friend) => friend.id == friendId); }, @@ -75,8 +76,7 @@ export const useSettingsStore = create()( async refreshProfiles() { const timeUntilRefreshMs = - UPDATE_INTERVAL - - (Date.now() - useSettingsStore.getState().profilesLastUpdated); + UPDATE_INTERVAL - (Date.now() - get().profilesLastUpdated); if (timeUntilRefreshMs > 0) { console.log( "Waiting", @@ -87,7 +87,7 @@ export const useSettingsStore = create()( return; } - const player = useSettingsStore.getState().player; + const player = get().player; if (player != undefined) { const newPlayer = await ScoreSaberAPI.fetchPlayerData(player.id); if (newPlayer != undefined && newPlayer != null) { @@ -96,7 +96,7 @@ export const useSettingsStore = create()( } } - const friends = useSettingsStore.getState().friends; + const friends = get().friends; const newFriends = await Promise.all( friends.map(async (friend) => { const newFriend = await ScoreSaberAPI.fetchPlayerData(friend.id); @@ -105,21 +105,12 @@ export const useSettingsStore = create()( return newFriend; }), ); - set({ friends: newFriends }); - - useSettingsStore.setState({ profilesLastUpdated: Date.now() }); + set({ profilesLastUpdated: Date.now(), friends: newFriends }); }, }), { name: "settings", - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => IDBStorage), }, ), ); - -// Refresh profiles every 10 minutes -useSettingsStore.getState().refreshProfiles(); -setInterval( - () => useSettingsStore.getState().refreshProfiles(), - UPDATE_INTERVAL, -); diff --git a/src/utils/scoresaber/scores.ts b/src/utils/scoresaber/scores.ts index 12059f9..0fc6aed 100644 --- a/src/utils/scoresaber/scores.ts +++ b/src/utils/scoresaber/scores.ts @@ -118,7 +118,7 @@ export function calcPpBoundary(playerId: string, expectedPp = 1) { const rankedScores = useScoresaberScoresStore .getState() .players.find((p) => p.id === playerId) - ?.scores?.scoresaber.filter((s) => s.score.pp !== undefined); + ?.scores?.filter((s) => s.score.pp !== undefined); if (!rankedScores) return null; const rankedScorePps = rankedScores @@ -159,7 +159,7 @@ export function getHighestPpPlay(playerId: string) { const rankedScores = useScoresaberScoresStore .getState() .players.find((p) => p.id === playerId) - ?.scores?.scoresaber.filter((s) => s.score.pp !== undefined); + ?.scores?.filter((s) => s.score.pp !== undefined); if (!rankedScores) return null; const rankedScorePps = rankedScores @@ -179,7 +179,7 @@ export function getAveragePp(playerId: string, limit: number = 20) { const rankedScores = useScoresaberScoresStore .getState() .players.find((p) => p.id === playerId) - ?.scores?.scoresaber.filter((s) => s.score.pp !== undefined); + ?.scores?.filter((s) => s.score.pp !== undefined); if (!rankedScores) return null; const rankedScorePps = rankedScores