diff --git a/apps/playnite-web/src/api/client/state/authSlice.ts b/apps/playnite-web/src/api/client/state/authSlice.ts index 813fccea5..2c40a8cd7 100644 --- a/apps/playnite-web/src/api/client/state/authSlice.ts +++ b/apps/playnite-web/src/api/client/state/authSlice.ts @@ -15,10 +15,10 @@ const slice = createSlice({ }, reducers: { signedIn(state, action) { - return merge(state, { isAuthenticated: true }) + return merge({}, state, { isAuthenticated: true }) }, signedOut(state, action) { - return merge(state, { isAuthenticated: false }) + return merge({}, state, { isAuthenticated: false }) }, }, }) diff --git a/apps/playnite-web/src/api/client/state/deviceFeaturesSlice.ts b/apps/playnite-web/src/api/client/state/deviceFeaturesSlice.ts index b6364a789..c180b5691 100644 --- a/apps/playnite-web/src/api/client/state/deviceFeaturesSlice.ts +++ b/apps/playnite-web/src/api/client/state/deviceFeaturesSlice.ts @@ -23,7 +23,7 @@ const slice = createSlice({ name: 'deviceFeatures', initialState, selectors: { - getDeviceFeatures: (state) => ({ + getDeviceFeatures: memoize((state) => ({ device: { type: state.device?.type, vendor: state.device?.vendor, @@ -32,7 +32,7 @@ const slice = createSlice({ isTouchEnabled: state.isTouchEnabled, isPwa: state.isPwa, orientation: state.orientation, - }), + })), }, reducers: { setDeviceFeatures( @@ -46,7 +46,7 @@ const slice = createSlice({ } }, ) { - return merge(state, action.payload) + return merge({}, state, action.payload) }, }, }) diff --git a/apps/playnite-web/src/api/client/state/index.ts b/apps/playnite-web/src/api/client/state/index.ts index d05f7251b..1deb9d396 100644 --- a/apps/playnite-web/src/api/client/state/index.ts +++ b/apps/playnite-web/src/api/client/state/index.ts @@ -2,11 +2,13 @@ import { combineReducers } from '@reduxjs/toolkit' import * as authSlice from './authSlice' import * as deviceFeaturesSlice from './deviceFeaturesSlice' import * as layoutSlice from './layoutSlice' +import * as librarySlice from './librarySlice' const reducer = combineReducers({ auth: authSlice.reducer, - layout: layoutSlice.reducer, deviceFeatures: deviceFeaturesSlice.reducer, + layout: layoutSlice.reducer, + library: librarySlice.reducer, }) export { reducer } diff --git a/apps/playnite-web/src/api/client/state/layoutSlice.ts b/apps/playnite-web/src/api/client/state/layoutSlice.ts index 7ad34f130..17f5aa333 100644 --- a/apps/playnite-web/src/api/client/state/layoutSlice.ts +++ b/apps/playnite-web/src/api/client/state/layoutSlice.ts @@ -17,7 +17,7 @@ const slice = createSlice({ }, reducers: { setDeviceType(state, action) { - return merge(state, { deviceType: action.payload }) + return merge({}, state, { deviceType: action.payload }) }, }, }) diff --git a/apps/playnite-web/src/api/client/state/librarySlice.ts b/apps/playnite-web/src/api/client/state/librarySlice.ts new file mode 100644 index 000000000..1a9220f6c --- /dev/null +++ b/apps/playnite-web/src/api/client/state/librarySlice.ts @@ -0,0 +1,36 @@ +import { createSelector, createSlice } from '@reduxjs/toolkit' +import NoFilter from '../../../domain/filters/NoFilter' +import MatchName from '../../../domain/filters/playnite/MatchName' + +import _ from 'lodash' + +const { memoize, merge } = _ + +const initialState: { + nameFilter: string | null +} = { + nameFilter: null, +} + +const noFilter = new NoFilter() + +const getNameFilter = memoize((state: typeof initialState) => + !state.nameFilter ? noFilter : new MatchName(state.nameFilter), +) + +const slice = createSlice({ + name: 'library', + initialState, + selectors: { + getFilter: createSelector(getNameFilter, (filter) => filter), + }, + reducers: { + setNameFilter(state, action) { + return merge({}, state, { nameFilter: action.payload }) + }, + }, +}) + +export const { reducer } = slice +export const { setNameFilter } = slice.actions +export const { getFilter } = slice.selectors diff --git a/apps/playnite-web/src/api/client/state/store.ts b/apps/playnite-web/src/api/client/state/store.ts new file mode 100644 index 000000000..2e038f0a2 --- /dev/null +++ b/apps/playnite-web/src/api/client/state/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from '@reduxjs/toolkit' +import { reducer } from '.' + +let store +const getStore = () => { + if (!store) { + store = configureStore({ reducer }) + } + + return store +} + +export default getStore diff --git a/apps/playnite-web/src/components/GameFigure.tsx b/apps/playnite-web/src/components/GameFigure.tsx index 27e5ad2db..ead183cb4 100644 --- a/apps/playnite-web/src/components/GameFigure.tsx +++ b/apps/playnite-web/src/components/GameFigure.tsx @@ -10,9 +10,11 @@ const Figure = styled('figure')(({ theme }) => ({ const Image = styled('img', { shouldForwardProp: (prop) => prop !== 'width', })<{ width: string }>(({ width, theme }) => ({ + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[3], + height: `calc(${width} - 16px)`, objectFit: 'cover', width: `calc(${width} - 16px)`, - height: `calc(${width} - 16px)`, })) const GameFigure: FC< diff --git a/apps/playnite-web/src/components/GameGrid.tsx b/apps/playnite-web/src/components/GameGrid.tsx index 904f528df..9ce5f61a5 100644 --- a/apps/playnite-web/src/components/GameGrid.tsx +++ b/apps/playnite-web/src/components/GameGrid.tsx @@ -58,7 +58,10 @@ const GameGrid: FC<{ <> {games.items.map((game, gameIndex) => ( - + void - noDefer?: boolean - } & HTMLAttributes -> = ({ game, height, noDefer = false, onActivate, ...rest }) => { - const [inView, setInView] = useState(false) - const handleChange = useCallback((inView) => { - setInView(true) - }, []) - const { ref } = useInView({ onChange: handleChange }) - - return ( - - {inView || noDefer - ? [ - {game.name}, - - } - />, - ] - : []} - - ) -} - -export default GameImage diff --git a/apps/playnite-web/src/components/Header.tsx b/apps/playnite-web/src/components/Header.tsx new file mode 100644 index 000000000..a1bb75bc0 --- /dev/null +++ b/apps/playnite-web/src/components/Header.tsx @@ -0,0 +1,55 @@ +import { Divider, TextField, styled } from '@mui/material' +import _ from 'lodash' +import { ChangeEvent, FC, PropsWithChildren, useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { setNameFilter } from '../api/client/state/librarySlice' + +const { debounce } = _ + +const HeaderContainer = styled('header')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', +})) + +const Filters = styled('section')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + flex: 1, + justifyContent: 'flex-end', + alignItems: 'flex-end', +})) + +const Header: FC> = ({ + children, + showFilters, +}) => { + const dispatch = useDispatch() + const handleSearch = useCallback( + debounce((event: ChangeEvent) => { + dispatch(setNameFilter(event.target.value)) + }, 400), + [], + ) + + return ( + <> + + {children} + {showFilters && ( + + + + )} + + + + ) +} + +export default Header diff --git a/apps/playnite-web/src/components/Layout.tsx b/apps/playnite-web/src/components/Layout.tsx index 554f7b5fb..a4c07280e 100644 --- a/apps/playnite-web/src/components/Layout.tsx +++ b/apps/playnite-web/src/components/Layout.tsx @@ -1,14 +1,10 @@ import { Box, useMediaQuery, useTheme } from '@mui/material' import { FC, PropsWithChildren } from 'react' -import { useSelector } from 'react-redux' -import { getDeviceFeatures } from '../api/client/state/deviceFeaturesSlice' import DrawerNavigation from './Navigation/DrawerNavigation' import MobileDrawerNavigation from './Navigation/MobileDrawerNavigation' import useThemeWidth from './useThemeWidth' const Layout: FC = ({ children }) => { - const deviceFeatures = useSelector(getDeviceFeatures) - const theme = useTheme() const shouldUseMobileDrawer = useMediaQuery(theme.breakpoints.down('lg')) const Drawer = shouldUseMobileDrawer @@ -39,7 +35,7 @@ const Layout: FC = ({ children }) => { padding: '80px 24px', }, [theme.breakpoints.up('xl')]: { - padding: '120px 48px', + padding: '80px 48px', }, [theme.breakpoints.down('xs')]: { padding: '80px 24px', diff --git a/apps/playnite-web/src/components/MyLibrary.tsx b/apps/playnite-web/src/components/MyLibrary.tsx new file mode 100644 index 000000000..8725f1c7a --- /dev/null +++ b/apps/playnite-web/src/components/MyLibrary.tsx @@ -0,0 +1,54 @@ +import { Typography } from '@mui/material' +import { FC, useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { useSelector } from 'react-redux' +import { getFilter } from '../api/client/state/librarySlice' +import GameGrid from '../components/GameGrid' +import Header from '../components/Header' +import FilteredGameList from '../domain/FilteredGameList' +import GameList from '../domain/GameList' +import type { GameOnPlatform } from '../domain/types' + +const MyLibrary: FC<{ gamesOnPlatforms: GameOnPlatform[] }> = ({ + gamesOnPlatforms = [] as GameOnPlatform[], +}) => { + const gameList = useMemo(() => { + return new GameList(gamesOnPlatforms) + }, [gamesOnPlatforms]) + + const filter = useSelector(getFilter) + const filteredGames = useMemo( + () => new FilteredGameList(gameList, filter), + [gameList, filter], + ) + + const noDeferCount = 25 + + return ( + <> + + {gameList.items + .filter((game, index) => index <= noDeferCount) + .map((game) => ( + + ))} + +
+
+ My Games + + {gameList.items.length} games in my library + +
+
+ + + ) +} + +export default MyLibrary diff --git a/apps/playnite-web/src/components/Search.tsx b/apps/playnite-web/src/components/Search.tsx index ffe300eb9..36fd772e8 100644 --- a/apps/playnite-web/src/components/Search.tsx +++ b/apps/playnite-web/src/components/Search.tsx @@ -1,36 +1,24 @@ -import styled from '@emotion/styled' -import { forwardRef, useCallback, useState } from 'react' - -const SearchInput = styled.input<{ height: number }>` - display: flex; - justify-content: center; - border-radius: ${({ height }) => height / 2}px; - padding: 0; - color: darkslategray; -` +import { TextField } from '@mui/material' +import { forwardRef, useCallback } from 'react' const Search = forwardRef< HTMLInputElement, { onSearch: (search: string) => void - defaultValue: string - height?: number + defaultValue?: string } ->(({ onSearch, defaultValue = '', height }, ref) => { - const [value, setValue] = useState(defaultValue) +>(({ onSearch, defaultValue = '' }) => { const handleOnChange = useCallback((e) => { - setValue(e.target.value) onSearch(e.target.value) }, []) return ( - ) }) diff --git a/apps/playnite-web/src/domain/filters/playnite/MatchName.ts b/apps/playnite-web/src/domain/filters/playnite/MatchName.ts index a68da6620..7a53384b5 100644 --- a/apps/playnite-web/src/domain/filters/playnite/MatchName.ts +++ b/apps/playnite-web/src/domain/filters/playnite/MatchName.ts @@ -3,7 +3,7 @@ import { IGame, IMatchA } from '../../types' class MatchName implements IMatchA { private nameMatcher: RegExp constructor(name: string) { - this.nameMatcher = new RegExp(name, 'i') + this.nameMatcher = new RegExp(`\\b${name}\\b`, 'i') } matches(item: IGame): boolean { diff --git a/apps/playnite-web/src/entry.client.tsx b/apps/playnite-web/src/entry.client.tsx index 793907947..96c8bda92 100644 --- a/apps/playnite-web/src/entry.client.tsx +++ b/apps/playnite-web/src/entry.client.tsx @@ -1,18 +1,24 @@ import { CacheProvider } from '@emotion/react' +import { configureStore } from '@reduxjs/toolkit' import { loadServiceWorker } from '@remix-pwa/sw' import { RemixBrowser } from '@remix-run/react' import { startTransition, StrictMode } from 'react' import { hydrateRoot } from 'react-dom/client' +import { Provider } from 'react-redux' +import { reducer } from './api/client/state' import createEmotionCache from './createEmotionCache' const clientSideCache = createEmotionCache() startTransition(() => { + const store = configureStore({ reducer }) hydrateRoot( document.getElementById('root')!, - + + + , ) diff --git a/apps/playnite-web/src/entry.server.tsx b/apps/playnite-web/src/entry.server.tsx index 0224d5ff2..a1302e4ae 100644 --- a/apps/playnite-web/src/entry.server.tsx +++ b/apps/playnite-web/src/entry.server.tsx @@ -1,4 +1,5 @@ import { CacheProvider } from '@emotion/react' +import { configureStore } from '@reduxjs/toolkit' import type { AppLoadContext, EntryContext } from '@remix-run/node' import { createReadableStreamFromReadable } from '@remix-run/node' import { RemixServer } from '@remix-run/react' @@ -6,8 +7,10 @@ import isbot from 'isbot' import { PassThrough } from 'node:stream' import { renderToPipeableStream } from 'react-dom/server' import { Helmet } from 'react-helmet' +import { Provider } from 'react-redux' import { renderHeadToString } from 'remix-island' import { preloadRouteAssets } from 'remix-utils/preload-route-assets' +import { reducer } from './api/client/state' import createEmotionCache from './createEmotionCache' import { Head } from './root' @@ -44,13 +47,16 @@ function handleBotRequest( return new Promise((resolve, reject) => { let shellRendered = false const clientSideCache = createEmotionCache() + const store = configureStore({ reducer }) const { pipe, abort } = renderToPipeableStream( - + + + , { onAllReady() { @@ -69,7 +75,7 @@ function handleBotRequest( ) body.write( - `${head}
`, + `${head}
`, ) pipe(body) body.write(`
`) @@ -102,13 +108,16 @@ function handleBrowserRequest( return new Promise((resolve, reject) => { let shellRendered = false const clientSideCache = createEmotionCache() + const store = configureStore({ reducer }) const { pipe, abort } = renderToPipeableStream( - + + + , { onAllReady() { @@ -130,7 +139,7 @@ function handleBrowserRequest( }), ) body.write( - `${head}${helmet.link.toString()}
`, + `${head}${helmet.link.toString()}
`, ) pipe(body) body.write(`
`) diff --git a/apps/playnite-web/src/root.tsx b/apps/playnite-web/src/root.tsx index 9e6a723b3..93e0d65ea 100644 --- a/apps/playnite-web/src/root.tsx +++ b/apps/playnite-web/src/root.tsx @@ -1,5 +1,4 @@ import { CssBaseline, ThemeProvider } from '@mui/material' -import { configureStore } from '@reduxjs/toolkit' import { LiveReload, useSWEffect } from '@remix-pwa/sw' import { LinksFunction, LoaderFunctionArgs, json } from '@remix-run/node' import { @@ -14,10 +13,9 @@ import { } from '@remix-run/react' import { AnimatePresence, motion } from 'framer-motion' import { FC, useEffect } from 'react' -import { Provider } from 'react-redux' +import { useStore } from 'react-redux' import { createHead } from 'remix-island' import { authenticator } from './api/auth/auth.server' -import { reducer } from './api/client/state' import { signedIn, signedOut } from './api/client/state/authSlice' import { setDeviceFeatures } from './api/client/state/deviceFeaturesSlice' import { UAParser } from './api/layout.server' @@ -86,8 +84,6 @@ const Head = createHead(() => ( const App: FC<{}> = () => { useSWEffect() - const store = configureStore({ reducer }) - const { device, user } = useLoaderData<{ device: { type: 'desktop' | 'tablet' | 'mobile' @@ -97,6 +93,7 @@ const App: FC<{}> = () => { user?: any }>() + const store = useStore() if (!!user) { store.dispatch(signedIn({ payload: null })) } else { @@ -155,24 +152,22 @@ const App: FC<{}> = () => { - - - - - {outlet} - - - - + + + + {outlet} + + + diff --git a/apps/playnite-web/src/routes/_index.tsx b/apps/playnite-web/src/routes/_index.tsx index 58e370951..3ed6f33df 100644 --- a/apps/playnite-web/src/routes/_index.tsx +++ b/apps/playnite-web/src/routes/_index.tsx @@ -4,6 +4,7 @@ import { json } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' import { useMemo } from 'react' import PlayniteApi from '../api/playnite/index.server' +import Header from '../components/Header' import HorizontalGameList from '../components/HorizontalGameList' import GameList from '../domain/GameList' import { Playlist } from '../domain/types' @@ -28,7 +29,9 @@ function Index() { return ( <> - Library +
+ Library +
{playingPlaylist?.name} diff --git a/apps/playnite-web/src/routes/browse.tsx b/apps/playnite-web/src/routes/browse.tsx index fe421a9d0..a2e3f1e8b 100644 --- a/apps/playnite-web/src/routes/browse.tsx +++ b/apps/playnite-web/src/routes/browse.tsx @@ -1,14 +1,8 @@ -import { Typography } from '@mui/material' import type { LoaderFunctionArgs } from '@remix-run/node' import { json } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' -import { useCallback, useMemo, useState } from 'react' -import { Helmet } from 'react-helmet' import PlayniteApi from '../api/playnite/index.server' -import GameGrid from '../components/GameGrid' -import FilteredGameList from '../domain/FilteredGameList' -import GameList from '../domain/GameList' -import MatchName from '../domain/filters/playnite/MatchName' +import MyLibrary from '../components/MyLibrary' import type { GameOnPlatform } from '../domain/types' async function loader({ request }: LoaderFunctionArgs) { @@ -37,40 +31,7 @@ function Browse() { gamesOnPlatforms?: GameOnPlatform[] } - const gameList = useMemo(() => { - return new GameList(gamesOnPlatforms ?? []) - }, [gamesOnPlatforms]) - - const [nameQuery, setNameQuery] = useState('') - const handleFilter = useCallback((evt, userNameQuery) => { - setNameQuery(userNameQuery) - }, []) - const filteredGames = useMemo( - () => new FilteredGameList(gameList, new MatchName(nameQuery)), - [gamesOnPlatforms, nameQuery], - ) - - const noDeferCount = 25 - - return ( - <> - - {gameList.items - .filter((game, index) => index <= noDeferCount) - .map((game) => ( - - ))} - - My Games - - - - ) + return } export default Browse