Skip to content

Commit b6281f9

Browse files
lgcavalheiroarielj
andauthored
[Feature] Add custom user-defined game categories #1428 (#3115)
* feat: created basic POC of this feature * feat: added dropdown selector for custom categories * feat: added translations * feat: added translations * fix: fixed issues raised during review + added uncategorized preset * chore: pushing en translation files * fix: runner is now relevant + added validation for new categories * fix: changed category settings layout to favor checkboxes * Group library filters in a dropdown. Add only installed filter * Favorites filter composable. Refresh libraries in background * Re-add translation strings * Fix checked status for store filters * Remove console.log * fix: category filter on small screens * Fix missing windows games when no platform is selected * fix: i18n errors on push --------- Co-authored-by: Ariel Juodziukynas <[email protected]>
1 parent f0cd114 commit b6281f9

File tree

18 files changed

+417
-40
lines changed

18 files changed

+417
-40
lines changed

public/locales/en/gamepage.json

+1
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@
228228
"submenu": {
229229
"addShortcut": "Add shortcut",
230230
"addToSteam": "Add to Steam",
231+
"categories": "Categories",
231232
"change": "Change install path",
232233
"disableEosOverlay": "Disable EOS Overlay",
233234
"enableEosOverlay": "Enable EOS Overlay",

public/locales/en/translation.json

+11-1
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,14 @@
170170
"go_to_library": "Go to Library",
171171
"login": "Log in"
172172
},
173+
"category-settings": {
174+
"add-new-category": "Add New Category",
175+
"cancel": "Cancel",
176+
"delete-question": "Proceeding will permanently remove this category and unassign it from all games. Continue?",
177+
"new-category": "New Category",
178+
"remove-category": "Remove Category",
179+
"warning": "Warning"
180+
},
173181
"controller": {
174182
"hints": {
175183
"back": "Back",
@@ -245,11 +253,13 @@
245253
"GOG": "GOG",
246254
"gog-store": "GOG Store",
247255
"header": {
256+
"all_categories": "All Categories",
248257
"filters": "Filters",
249258
"show_available_games": "Show non-Available games",
250259
"show_favourites_only": "Show Favourites only",
251260
"show_hidden": "Show Hidden",
252-
"show_installed_only": "Show Installed only"
261+
"show_installed_only": "Show Installed only",
262+
"uncategorized": "Uncategorized"
253263
},
254264
"help": {
255265
"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.",

src/common/types/electron_store.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface StoreStructure {
2727
recent: RecentGame[]
2828
hidden: HiddenGame[]
2929
favourites: FavouriteGame[]
30+
customCategories: Record<string, string[]>
3031
}
3132
theme: string
3233
zoomPercent: number
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#custom-category-selector {
2+
width: 324px;
3+
}
4+
5+
@media screen and (max-width: 1000px) {
6+
#custom-category-selector {
7+
width: auto;
8+
}
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import SelectField from '../SelectField'
2+
import ContextProvider from 'frontend/state/ContextProvider'
3+
import React, { useContext } from 'react'
4+
import { useTranslation } from 'react-i18next'
5+
import './index.css'
6+
7+
export default function CategoryFilter() {
8+
const { customCategories, currentCustomCategory, setCurrentCustomCategory } =
9+
useContext(ContextProvider)
10+
const { t } = useTranslation()
11+
12+
return (
13+
<SelectField
14+
htmlId="custom-category-selector"
15+
value={currentCustomCategory || ''}
16+
onChange={(e) => {
17+
setCurrentCustomCategory(e.target.value)
18+
}}
19+
>
20+
<option value="">{t('header.all_categories', 'All Categories')}</option>
21+
<option value="preset_uncategorized">
22+
{t('header.uncategorized', 'Uncategorized')}
23+
</option>
24+
{customCategories.listCategories().map((category) => (
25+
<option value={category} key={category}>
26+
{category}
27+
</option>
28+
))}
29+
</SelectField>
30+
)
31+
}

src/frontend/components/UI/Header/index.css

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
display: flex;
2424
align-self: center;
2525
justify-self: flex-end;
26+
gap: 1em;
2627
}
2728

2829
.Header__filters .FormControl {

src/frontend/components/UI/Header/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react'
22
import LibrarySearchBar from '../LibrarySearchBar'
3+
import CategoryFilter from '../CategoryFilter'
34
import LibraryFilters from '../LibraryFilters'
45
import './index.css'
56

@@ -11,6 +12,7 @@ export default function Header() {
1112
<LibrarySearchBar />
1213
</div>
1314
<span className="Header__filters">
15+
<CategoryFilter />
1416
<LibraryFilters />
1517
</span>
1618
</div>

src/frontend/screens/Game/GamePage/components/DotsMenu.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const DotsMenu = ({ gameInfo, handleUpdate }: Props) => {
5454
hasRequirements ? () => setShowRequirements(true) : undefined
5555
}
5656
onShowDlcs={() => setShowDlcs(true)}
57+
gameInfo={gameInfo}
5758
/>
5859
</div>
5960

src/frontend/screens/Game/GamePage/index.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ export default React.memo(function GamePage(): JSX.Element | null {
8585
platform,
8686
showDialogModal,
8787
isSettingsModalOpen,
88-
experimentalFeatures,
89-
connectivity
88+
connectivity,
89+
experimentalFeatures
9090
} = useContext(ContextProvider)
9191

9292
const [gameInfo, setGameInfo] = useState(locationGameInfo)

src/frontend/screens/Game/GameSubMenu/index.tsx

+17-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import './index.css'
22

33
import React, { useContext, useEffect, useState } from 'react'
44

5-
import { GameStatus, Runner, WikiInfo } from 'common/types'
5+
import { GameInfo, GameStatus, Runner, WikiInfo } from 'common/types'
66

77
import { createNewWindow, repair } from 'frontend/helpers'
88
import { useTranslation } from 'react-i18next'
@@ -23,6 +23,7 @@ interface Props {
2323
disableUpdate: boolean
2424
onShowRequirements?: () => void
2525
onShowDlcs?: () => void
26+
gameInfo: GameInfo
2627
}
2728

2829
export default function GamesSubmenu({
@@ -34,10 +35,16 @@ export default function GamesSubmenu({
3435
handleUpdate,
3536
disableUpdate,
3637
onShowRequirements,
37-
onShowDlcs
38+
onShowDlcs,
39+
gameInfo
3840
}: Props) {
39-
const { refresh, platform, libraryStatus, showDialogModal } =
40-
useContext(ContextProvider)
41+
const {
42+
refresh,
43+
platform,
44+
libraryStatus,
45+
showDialogModal,
46+
setIsSettingsModalOpen
47+
} = useContext(ContextProvider)
4148
const isWin = platform === 'win32'
4249
const isLinux = platform === 'linux'
4350

@@ -314,6 +321,12 @@ export default function GamesSubmenu({
314321
))}
315322
</>
316323
)}
324+
<button
325+
onClick={() => setIsSettingsModalOpen(true, 'category', gameInfo)}
326+
className="link button is-text is-link"
327+
>
328+
{t('submenu.categories', 'Categories')}
329+
</button>
317330
{!isSideloaded && storeUrl && (
318331
<NavLink
319332
className="link button is-text is-link"

src/frontend/screens/Library/components/GameCard/index.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,11 @@ const GameCard = ({
339339
onclick: () => favouriteGames.add(appName, title),
340340
show: !isFavouriteGame
341341
},
342+
{
343+
label: t('submenu.categories', 'Categories'),
344+
onclick: () => setIsSettingsModalOpen(true, 'category', gameInfo),
345+
show: true
346+
},
342347
{
343348
label: t('button.remove_from_favourites', 'Remove From Favourites'),
344349
onclick: () => favouriteGames.remove(appName),

src/frontend/screens/Library/index.tsx

+48-22
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ export default React.memo(function Library(): JSX.Element {
5757
sideloadedLibrary,
5858
favouriteGames,
5959
libraryTopSection,
60-
hiddenGames,
61-
platform
60+
platform,
61+
currentCustomCategory,
62+
customCategories,
63+
hiddenGames
6264
} = useContext(ContextProvider)
6365

6466
const [layout, setLayout] = useState(storage.getItem('layout') || 'grid')
@@ -321,10 +323,7 @@ export default React.memo(function Library(): JSX.Element {
321323
return favourites.map((game) => `${game.app_name}_${game.runner}`)
322324
}, [favourites])
323325

324-
// select library
325-
const libraryToShow = useMemo(() => {
326-
let library: Array<GameInfo> = []
327-
326+
const makeLibrary = () => {
328327
let displayedStores: string[] = []
329328
if (storesFilters['gog'] && gog.username) {
330329
displayedStores.push('gog')
@@ -353,29 +352,56 @@ export default React.memo(function Library(): JSX.Element {
353352
const sideloadedApps = showSideloaded ? sideloadedLibrary : []
354353
const amazonLibrary = showAmazon ? amazon.library : []
355354

356-
library = [
357-
...sideloadedApps,
358-
...epicLibrary,
359-
...gogLibrary,
360-
...amazonLibrary
361-
]
355+
return [...sideloadedApps, ...epicLibrary, ...gogLibrary, ...amazonLibrary]
356+
}
357+
358+
// select library
359+
const libraryToShow = useMemo(() => {
360+
let library: Array<GameInfo> = makeLibrary()
362361

363362
if (showFavouritesLibrary) {
364363
library = library.filter((game) =>
365364
favouritesIds.includes(`${game.app_name}_${game.runner}`)
366365
)
367-
}
366+
} else if (currentCustomCategory && currentCustomCategory.length > 0) {
367+
if (currentCustomCategory === 'preset_uncategorized') {
368+
// list of all games that have at least one category assigned to them
369+
const categorizedGames = Array.from(
370+
new Set(Object.values(customCategories.list).flat())
371+
)
372+
373+
library = library.filter(
374+
(game) =>
375+
!categorizedGames.includes(`${game.app_name}_${game.runner}`)
376+
)
377+
} else {
378+
const gamesInCustomCategory =
379+
customCategories.list[currentCustomCategory]
368380

369-
if (showInstalledOnly) {
370-
library = library.filter((game) => game.is_installed)
371-
}
381+
library = library.filter((game) =>
382+
gamesInCustomCategory.includes(`${game.app_name}_${game.runner}`)
383+
)
384+
}
385+
} else {
386+
if (!showNonAvailable) {
387+
const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]'
388+
const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames)
389+
library = library.filter(
390+
(game) => !nonAvailbleGamesArray.includes(game.app_name)
391+
)
392+
}
372393

373-
if (!showNonAvailable) {
374-
const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]'
375-
const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames)
376-
library = library.filter(
377-
(game) => !nonAvailbleGamesArray.includes(game.app_name)
378-
)
394+
if (showInstalledOnly) {
395+
library = library.filter((game) => game.is_installed)
396+
}
397+
398+
if (!showNonAvailable) {
399+
const nonAvailbleGames = storage.getItem('nonAvailableGames') || '[]'
400+
const nonAvailbleGamesArray = JSON.parse(nonAvailbleGames)
401+
library = library.filter(
402+
(game) => !nonAvailbleGamesArray.includes(game.app_name)
403+
)
404+
}
379405
}
380406

381407
// filter

src/frontend/screens/Settings/components/SettingsModal/index.tsx

+17-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext } from 'react'
1+
import React, { useContext, useMemo } from 'react'
22
import { GameInfo } from 'common/types'
33
import {
44
Dialog,
@@ -13,10 +13,11 @@ import LogSettings from '../../sections/LogSettings'
1313
import './index.scss'
1414
import { useTranslation } from 'react-i18next'
1515
import { SettingsContextType } from 'frontend/types'
16+
import CategorySettings from '../../sections/CategorySettings'
1617

1718
type Props = {
1819
gameInfo: GameInfo
19-
type: 'settings' | 'log'
20+
type: 'settings' | 'log' | 'category'
2021
}
2122

2223
function SettingsModal({ gameInfo, type }: Props) {
@@ -32,6 +33,16 @@ function SettingsModal({ gameInfo, type }: Props) {
3233
runner
3334
})
3435

36+
const titleType = useMemo(() => {
37+
const titleTypeLiterals = {
38+
settings: t('Settings', 'Settings'),
39+
log: t('settings.navbar.log', 'Log'),
40+
category: 'Categories'
41+
}
42+
43+
return titleTypeLiterals[type]
44+
}, [type])
45+
3546
if (!contextValues) {
3647
return null
3748
}
@@ -43,15 +54,13 @@ function SettingsModal({ gameInfo, type }: Props) {
4354
className={'InstallModal__dialog'}
4455
>
4556
<DialogHeader onClose={() => setIsSettingsModalOpen(false)}>
46-
{`${title} (${
47-
type === 'settings'
48-
? t('Settings', 'Settings')
49-
: t('settings.navbar.log', 'Log')
50-
})`}
57+
{`${title} (${titleType})`}
5158
</DialogHeader>
5259
<DialogContent className="settingsDialogContent">
5360
<SettingsContext.Provider value={contextValues}>
54-
{type === 'settings' ? <GamesSettings /> : <LogSettings />}
61+
{type === 'settings' && <GamesSettings />}
62+
{type === 'log' && <LogSettings />}
63+
{type === 'category' && <CategorySettings />}
5564
</SettingsContext.Provider>
5665
</DialogContent>
5766
</Dialog>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.NewCategoryInput {
2+
padding: 0 !important;
3+
4+
> input[type='text'] {
5+
height: 53px;
6+
}
7+
}

0 commit comments

Comments
 (0)