diff --git a/public/locales/en/gamepage.json b/public/locales/en/gamepage.json index 06d56f21d8..962a2b99e6 100644 --- a/public/locales/en/gamepage.json +++ b/public/locales/en/gamepage.json @@ -228,6 +228,7 @@ "submenu": { "addShortcut": "Add shortcut", "addToSteam": "Add to Steam", + "categories": "Categories", "change": "Change install path", "disableEosOverlay": "Disable EOS Overlay", "enableEosOverlay": "Enable EOS Overlay", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 3de8b941a8..e9f08a5b02 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -170,6 +170,14 @@ "go_to_library": "Go to Library", "login": "Log in" }, + "category-settings": { + "add-new-category": "Add New Category", + "cancel": "Cancel", + "delete-question": "Proceeding will permanently remove this category and unassign it from all games. Continue?", + "new-category": "New Category", + "remove-category": "Remove Category", + "warning": "Warning" + }, "controller": { "hints": { "back": "Back", @@ -245,11 +253,13 @@ "GOG": "GOG", "gog-store": "GOG Store", "header": { + "all_categories": "All Categories", "filters": "Filters", "show_available_games": "Show non-Available games", "show_favourites_only": "Show Favourites only", "show_hidden": "Show Hidden", - "show_installed_only": "Show Installed only" + "show_installed_only": "Show Installed only", + "uncategorized": "Uncategorized" }, "help": { "amdfsr": "AMD's FSR helps boost framerate by upscaling lower resolutions in Fullscreen Mode. Image quality increases from 5 to 1 at the cost of a slight performance hit. Enabling may improve performance.", diff --git a/src/common/types/electron_store.ts b/src/common/types/electron_store.ts index 32d9847a69..2205fabf2e 100644 --- a/src/common/types/electron_store.ts +++ b/src/common/types/electron_store.ts @@ -27,6 +27,7 @@ export interface StoreStructure { recent: RecentGame[] hidden: HiddenGame[] favourites: FavouriteGame[] + customCategories: Record } theme: string zoomPercent: number diff --git a/src/frontend/components/UI/CategoryFilter/index.css b/src/frontend/components/UI/CategoryFilter/index.css new file mode 100644 index 0000000000..cfadb4794a --- /dev/null +++ b/src/frontend/components/UI/CategoryFilter/index.css @@ -0,0 +1,9 @@ +#custom-category-selector { + width: 324px; +} + +@media screen and (max-width: 1000px) { + #custom-category-selector { + width: auto; + } +} diff --git a/src/frontend/components/UI/CategoryFilter/index.tsx b/src/frontend/components/UI/CategoryFilter/index.tsx new file mode 100644 index 0000000000..f1319bccec --- /dev/null +++ b/src/frontend/components/UI/CategoryFilter/index.tsx @@ -0,0 +1,31 @@ +import SelectField from '../SelectField' +import ContextProvider from 'frontend/state/ContextProvider' +import React, { useContext } from 'react' +import { useTranslation } from 'react-i18next' +import './index.css' + +export default function CategoryFilter() { + const { customCategories, currentCustomCategory, setCurrentCustomCategory } = + useContext(ContextProvider) + const { t } = useTranslation() + + return ( + { + setCurrentCustomCategory(e.target.value) + }} + > + + + {customCategories.listCategories().map((category) => ( + + ))} + + ) +} diff --git a/src/frontend/components/UI/Header/index.css b/src/frontend/components/UI/Header/index.css index cc31a34e58..57527c3a57 100644 --- a/src/frontend/components/UI/Header/index.css +++ b/src/frontend/components/UI/Header/index.css @@ -23,6 +23,7 @@ display: flex; align-self: center; justify-self: flex-end; + gap: 1em; } .Header__filters .FormControl { diff --git a/src/frontend/components/UI/Header/index.tsx b/src/frontend/components/UI/Header/index.tsx index 68fa920877..f4645f2471 100644 --- a/src/frontend/components/UI/Header/index.tsx +++ b/src/frontend/components/UI/Header/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import LibrarySearchBar from '../LibrarySearchBar' +import CategoryFilter from '../CategoryFilter' import LibraryFilters from '../LibraryFilters' import './index.css' @@ -11,6 +12,7 @@ export default function Header() { + diff --git a/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx b/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx index ffa504ba02..9998535283 100644 --- a/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx +++ b/src/frontend/screens/Game/GamePage/components/DotsMenu.tsx @@ -54,6 +54,7 @@ const DotsMenu = ({ gameInfo, handleUpdate }: Props) => { hasRequirements ? () => setShowRequirements(true) : undefined } onShowDlcs={() => setShowDlcs(true)} + gameInfo={gameInfo} /> diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index d5dba79efc..8dd692fb59 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -85,8 +85,8 @@ export default React.memo(function GamePage(): JSX.Element | null { platform, showDialogModal, isSettingsModalOpen, - experimentalFeatures, - connectivity + connectivity, + experimentalFeatures } = useContext(ContextProvider) const [gameInfo, setGameInfo] = useState(locationGameInfo) diff --git a/src/frontend/screens/Game/GameSubMenu/index.tsx b/src/frontend/screens/Game/GameSubMenu/index.tsx index 86cf093d0b..23e8fc84f1 100644 --- a/src/frontend/screens/Game/GameSubMenu/index.tsx +++ b/src/frontend/screens/Game/GameSubMenu/index.tsx @@ -2,7 +2,7 @@ import './index.css' import React, { useContext, useEffect, useState } from 'react' -import { GameStatus, Runner, WikiInfo } from 'common/types' +import { GameInfo, GameStatus, Runner, WikiInfo } from 'common/types' import { createNewWindow, repair } from 'frontend/helpers' import { useTranslation } from 'react-i18next' @@ -23,6 +23,7 @@ interface Props { disableUpdate: boolean onShowRequirements?: () => void onShowDlcs?: () => void + gameInfo: GameInfo } export default function GamesSubmenu({ @@ -34,10 +35,16 @@ export default function GamesSubmenu({ handleUpdate, disableUpdate, onShowRequirements, - onShowDlcs + onShowDlcs, + gameInfo }: Props) { - const { refresh, platform, libraryStatus, showDialogModal } = - useContext(ContextProvider) + const { + refresh, + platform, + libraryStatus, + showDialogModal, + setIsSettingsModalOpen + } = useContext(ContextProvider) const isWin = platform === 'win32' const isLinux = platform === 'linux' @@ -314,6 +321,12 @@ export default function GamesSubmenu({ ))} )} + {!isSideloaded && storeUrl && ( favouriteGames.add(appName, title), show: !isFavouriteGame }, + { + label: t('submenu.categories', 'Categories'), + onclick: () => setIsSettingsModalOpen(true, 'category', gameInfo), + show: true + }, { label: t('button.remove_from_favourites', 'Remove From Favourites'), onclick: () => favouriteGames.remove(appName), diff --git a/src/frontend/screens/Library/index.tsx b/src/frontend/screens/Library/index.tsx index d3874e3074..d94e0e64fd 100644 --- a/src/frontend/screens/Library/index.tsx +++ b/src/frontend/screens/Library/index.tsx @@ -57,8 +57,10 @@ export default React.memo(function Library(): JSX.Element { sideloadedLibrary, favouriteGames, libraryTopSection, - hiddenGames, - platform + platform, + currentCustomCategory, + customCategories, + hiddenGames } = useContext(ContextProvider) const [layout, setLayout] = useState(storage.getItem('layout') || 'grid') @@ -321,10 +323,7 @@ export default React.memo(function Library(): JSX.Element { return favourites.map((game) => `${game.app_name}_${game.runner}`) }, [favourites]) - // select library - const libraryToShow = useMemo(() => { - let library: Array = [] - + const makeLibrary = () => { let displayedStores: string[] = [] if (storesFilters['gog'] && gog.username) { displayedStores.push('gog') @@ -353,29 +352,56 @@ export default React.memo(function Library(): JSX.Element { const sideloadedApps = showSideloaded ? sideloadedLibrary : [] const amazonLibrary = showAmazon ? amazon.library : [] - library = [ - ...sideloadedApps, - ...epicLibrary, - ...gogLibrary, - ...amazonLibrary - ] + return [...sideloadedApps, ...epicLibrary, ...gogLibrary, ...amazonLibrary] + } + + // select library + const libraryToShow = useMemo(() => { + let library: Array = makeLibrary() if (showFavouritesLibrary) { library = library.filter((game) => favouritesIds.includes(`${game.app_name}_${game.runner}`) ) - } + } else if (currentCustomCategory && currentCustomCategory.length > 0) { + if (currentCustomCategory === 'preset_uncategorized') { + // list of all games that have at least one category assigned to them + const categorizedGames = Array.from( + new Set(Object.values(customCategories.list).flat()) + ) + + library = library.filter( + (game) => + !categorizedGames.includes(`${game.app_name}_${game.runner}`) + ) + } else { + const gamesInCustomCategory = + customCategories.list[currentCustomCategory] - if (showInstalledOnly) { - library = library.filter((game) => game.is_installed) - } + library = library.filter((game) => + gamesInCustomCategory.includes(`${game.app_name}_${game.runner}`) + ) + } + } else { + if (!showNonAvailable) { + const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]' + const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames) + library = library.filter( + (game) => !nonAvailbleGamesArray.includes(game.app_name) + ) + } - if (!showNonAvailable) { - const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]' - const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames) - library = library.filter( - (game) => !nonAvailbleGamesArray.includes(game.app_name) - ) + if (showInstalledOnly) { + library = library.filter((game) => game.is_installed) + } + + if (!showNonAvailable) { + const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]' + const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames) + library = library.filter( + (game) => !nonAvailbleGamesArray.includes(game.app_name) + ) + } } // filter diff --git a/src/frontend/screens/Settings/components/SettingsModal/index.tsx b/src/frontend/screens/Settings/components/SettingsModal/index.tsx index 7096cc14a3..6872346ca9 100644 --- a/src/frontend/screens/Settings/components/SettingsModal/index.tsx +++ b/src/frontend/screens/Settings/components/SettingsModal/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import React, { useContext, useMemo } from 'react' import { GameInfo } from 'common/types' import { Dialog, @@ -13,10 +13,11 @@ import LogSettings from '../../sections/LogSettings' import './index.scss' import { useTranslation } from 'react-i18next' import { SettingsContextType } from 'frontend/types' +import CategorySettings from '../../sections/CategorySettings' type Props = { gameInfo: GameInfo - type: 'settings' | 'log' + type: 'settings' | 'log' | 'category' } function SettingsModal({ gameInfo, type }: Props) { @@ -32,6 +33,16 @@ function SettingsModal({ gameInfo, type }: Props) { runner }) + const titleType = useMemo(() => { + const titleTypeLiterals = { + settings: t('Settings', 'Settings'), + log: t('settings.navbar.log', 'Log'), + category: 'Categories' + } + + return titleTypeLiterals[type] + }, [type]) + if (!contextValues) { return null } @@ -43,15 +54,13 @@ function SettingsModal({ gameInfo, type }: Props) { className={'InstallModal__dialog'} > setIsSettingsModalOpen(false)}> - {`${title} (${ - type === 'settings' - ? t('Settings', 'Settings') - : t('settings.navbar.log', 'Log') - })`} + {`${title} (${titleType})`} - {type === 'settings' ? : } + {type === 'settings' && } + {type === 'log' && } + {type === 'category' && } diff --git a/src/frontend/screens/Settings/sections/CategorySettings/index.scss b/src/frontend/screens/Settings/sections/CategorySettings/index.scss new file mode 100644 index 0000000000..705cb731d2 --- /dev/null +++ b/src/frontend/screens/Settings/sections/CategorySettings/index.scss @@ -0,0 +1,7 @@ +.NewCategoryInput { + padding: 0 !important; + + > input[type='text'] { + height: 53px; + } +} diff --git a/src/frontend/screens/Settings/sections/CategorySettings/index.tsx b/src/frontend/screens/Settings/sections/CategorySettings/index.tsx new file mode 100644 index 0000000000..dad9559f67 --- /dev/null +++ b/src/frontend/screens/Settings/sections/CategorySettings/index.tsx @@ -0,0 +1,173 @@ +import './index.scss' +import ContextProvider from 'frontend/state/ContextProvider' +import React, { useContext, useEffect, useMemo, useState } from 'react' +import SettingsContext from '../../SettingsContext' +import { Box, Button, Divider, IconButton } from '@mui/material' +import { TextInputField, ToggleSwitch } from 'frontend/components/UI' +import { useTranslation } from 'react-i18next' +import { + Dialog, + DialogContent, + DialogHeader +} from 'frontend/components/UI/Dialog' +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' + +const CategorySettings = () => { + const { customCategories, currentCustomCategory, setCurrentCustomCategory } = + useContext(ContextProvider) + const { appName, runner } = useContext(SettingsContext) + + const [newCategory, setNewCategory] = useState('') + const [categoryToDelete, setCategoryToDelete] = useState('') + const [assignedCategories, setAssignedCategories] = useState([]) + + const appNameWithRunner = useMemo( + () => `${appName}_${runner}`, + [appName, runner] + ) + + const { t } = useTranslation() + + const updateCategories = () => { + setAssignedCategories( + customCategories + .listCategories() + .filter((cat) => customCategories.list[cat].includes(appNameWithRunner)) + ) + } + + useEffect(() => { + updateCategories() + }, [customCategories.list]) + + const isCategorySubmissionDisabled = useMemo( + () => + newCategory.trim().length <= 0 || + customCategories.listCategories().includes(newCategory.trim()), + [newCategory, customCategories.listCategories] + ) + + const handleSubmit = () => { + const formattedCategory = newCategory.trim() + customCategories.addCategory(formattedCategory) + handleAddGameToCategory(formattedCategory) + setNewCategory('') + } + + const handleRemoveGameFromCategory = (category: string) => { + customCategories.removeFromGame(category, appNameWithRunner) + } + + const handleAddGameToCategory = (category: string) => { + customCategories.addToGame(category, appNameWithRunner) + updateCategories() + } + + const handleShowRemoveCategoryConfirmation = (category: string) => { + setCategoryToDelete(category) + } + + const handleRemoveCategory = (category: string) => { + if (currentCustomCategory === category) setCurrentCustomCategory('') + customCategories.removeCategory(category) + updateCategories() + setCategoryToDelete('') + } + + const handleToggleSwitchChange = (category: string) => { + if (!assignedCategories.includes(category)) + handleAddGameToCategory(category) + else handleRemoveGameFromCategory(category) + } + + return ( + <> + {categoryToDelete.length > 0 && ( + setCategoryToDelete('')}> + setCategoryToDelete('')}> + {t('category-settings.warning', 'Warning')} + + + {t( + 'category-settings.delete-question', + `Proceeding will permanently remove this category and unassign it + from all games. Continue?` + )} + + + + + + + )} + + { + setNewCategory(e.target.value) + }} + value={newCategory} + maxLength={33} + extraClass="NewCategoryInput" + /> + + + + + {customCategories.listCategories().map((category) => ( + + { + handleToggleSwitchChange(category) + }} + /> + { + handleShowRemoveCategoryConfirmation(category) + }} + > + + + + ))} + + + ) +} + +export default CategorySettings diff --git a/src/frontend/state/ContextProvider.tsx b/src/frontend/state/ContextProvider.tsx index 327a87aad8..ad6c573885 100644 --- a/src/frontend/state/ContextProvider.tsx +++ b/src/frontend/state/ContextProvider.tsx @@ -48,11 +48,21 @@ const initialContext: ContextType = { add: () => null, remove: () => null }, + currentCustomCategory: null, + setCurrentCustomCategory: () => null, favouriteGames: { list: [], add: () => null, remove: () => null }, + customCategories: { + list: {}, + listCategories: () => [], + addToGame: () => null, + removeFromGame: () => null, + addCategory: () => null, + removeCategory: () => null + }, theme: 'midnightMirage', setTheme: () => null, zoomPercent: 100, diff --git a/src/frontend/state/GlobalState.tsx b/src/frontend/state/GlobalState.tsx index b1190cd793..6d44fac8b5 100644 --- a/src/frontend/state/GlobalState.tsx +++ b/src/frontend/state/GlobalState.tsx @@ -79,6 +79,8 @@ interface StateProps { refreshingInTheBackground: boolean hiddenGames: HiddenGame[] favouriteGames: FavouriteGame[] + customCategories: Record + currentCustomCategory: string | null theme: string isFullscreen: boolean isFrameless: boolean @@ -153,10 +155,12 @@ class GlobalState extends PureComponent { refreshing: false, refreshingInTheBackground: true, hiddenGames: configStore.get('games.hidden', []), + currentCustomCategory: storage.getItem('current_custom_category') || null, sidebarCollapsed: JSON.parse( storage.getItem('sidebar_collapsed') || 'false' ), favouriteGames: configStore.get('games.favourites', []), + customCategories: configStore.get('games.customCategories', {}), theme: configStore.get('theme', ''), isFullscreen: false, isFrameless: false, @@ -191,6 +195,10 @@ class GlobalState extends PureComponent { } } + setCurrentCustomCategory = (newCustomCategory: string) => { + this.setState({ currentCustomCategory: newCustomCategory }) + } + setLanguage = (newLanguage: string) => { this.setState({ language: newLanguage }) } @@ -294,6 +302,56 @@ class GlobalState extends PureComponent { configStore.set('games.favourites', newFavouriteGames) } + getCustomCategories = () => + Array.from(new Set(Object.keys(this.state.customCategories))) + + setCustomCategory = (newCategory: string) => { + const newCustomCategories = this.state.customCategories + newCustomCategories[newCategory] = [] + + this.setState({ + customCategories: newCustomCategories + }) + configStore.set('games.customCategories', newCustomCategories) + } + + removeCustomCategory = (category: string) => { + if (!this.state.customCategories[category]) return + const newCustomCategories = this.state.customCategories + delete newCustomCategories[category] + this.setState({ customCategories: newCustomCategories }) + configStore.set('games.customCategories', newCustomCategories) + } + + addGameToCustomCategory = (category: string, appName: string) => { + const newCustomCategories = this.state.customCategories + + if (!newCustomCategories[category]) newCustomCategories[category] = [] + + newCustomCategories[category].push(appName) + + this.setState({ + customCategories: newCustomCategories + }) + configStore.set('games.customCategories', newCustomCategories) + } + + removeGameFromCustomCategory = (category: string, appName: string) => { + if (!this.state.customCategories[category]) return + + const newCustomCategories: Record = {} + for (const [key, games] of Object.entries(this.state.customCategories)) { + if (key === category) + newCustomCategories[key] = games.filter((game) => game !== appName) + else newCustomCategories[key] = this.state.customCategories[key] + } + + this.setState({ + customCategories: newCustomCategories + }) + configStore.set('games.customCategories', newCustomCategories) + } + handleShowDialogModal = ({ showDialog = true, ...options @@ -436,7 +494,7 @@ class GlobalState extends PureComponent { handleSettingsModalOpen = ( value: boolean, - type?: 'settings' | 'log', + type?: 'settings' | 'log' | 'category', gameInfo?: GameInfo ) => { if (gameInfo) { @@ -821,6 +879,7 @@ class GlobalState extends PureComponent { gog, amazon, favouriteGames, + customCategories, hiddenGames, settingsModalOpen, hideChangelogsOnStartup, @@ -873,6 +932,14 @@ class GlobalState extends PureComponent { add: this.addGameToFavourites, remove: this.removeGameFromFavourites }, + customCategories: { + list: customCategories, + listCategories: this.getCustomCategories, + addToGame: this.addGameToCustomCategory, + removeFromGame: this.removeGameFromCustomCategory, + addCategory: this.setCustomCategory, + removeCategory: this.removeCustomCategory + }, handleLibraryTopSection: this.handleLibraryTopSection, handleExperimentalFeatures: this.handleExperimentalFeatures, setTheme: this.setTheme, @@ -889,7 +956,8 @@ class GlobalState extends PureComponent { lastChangelogShown: lastChangelogShown, setLastChangelogShown: this.setLastChangelogShown, isSettingsModalOpen: settingsModalOpen, - setIsSettingsModalOpen: this.handleSettingsModalOpen + setIsSettingsModalOpen: this.handleSettingsModalOpen, + setCurrentCustomCategory: this.setCurrentCustomCategory }} > {this.props.children} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 308b49c4af..3586035d83 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -49,6 +49,16 @@ export interface ContextType { add: (appNameToAdd: string, appTitle: string) => void remove: (appNameToRemove: string) => void } + customCategories: { + list: Record + listCategories: () => string[] + addToGame: (category: string, appName: string) => void + removeFromGame: (category: string, appName: string) => void + addCategory: (newCategory: string) => void + removeCategory: (category: string) => void + } + currentCustomCategory: string | null + setCurrentCustomCategory: (newCustomCategory: string) => void theme: string setTheme: (themeName: string) => void zoomPercent: number @@ -99,7 +109,7 @@ export interface ContextType { } setIsSettingsModalOpen: ( value: boolean, - type?: 'settings' | 'log', + type?: 'settings' | 'log' | 'category', gameInfo?: GameInfo ) => void experimentalFeatures: ExperimentalFeatures