From b205ecf997b73198cf07d90fa63717019d088291 Mon Sep 17 00:00:00 2001 From: LeonBlade Date: Fri, 13 Jun 2025 15:19:10 -0400 Subject: [PATCH 1/3] Implement client side sorting/filtering --- backend/decky_loader/locales/en-US.json | 7 +- frontend/src/components/store/Store.tsx | 170 +++++++++++++----------- frontend/src/store.tsx | 7 + 3 files changed, 107 insertions(+), 77 deletions(-) diff --git a/backend/decky_loader/locales/en-US.json b/backend/decky_loader/locales/en-US.json index 836f48781..43d36e9a8 100644 --- a/backend/decky_loader/locales/en-US.json +++ b/backend/decky_loader/locales/en-US.json @@ -237,7 +237,12 @@ }, "store_filter": { "label": "Filter", - "label_def": "All" + "label_def": "All", + "options": { + "all": "All", + "installed": "Installed", + "not_installed": "Not Installed" + } }, "store_search": { "label": "Search" diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx index 3209ba088..29a829824 100644 --- a/frontend/src/components/store/Store.tsx +++ b/frontend/src/components/store/Store.tsx @@ -1,8 +1,8 @@ import { Dropdown, - DropdownOption, Focusable, PanelSectionRow, + SingleDropdownOption, SteamSpinner, Tabs, TextField, @@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'; import logo from '../../../assets/plugin_store.png'; import Logger from '../../logger'; -import { SortDirections, SortOptions, Store, StorePlugin, getPluginList, getStore } from '../../store'; +import { Store, StorePlugin, getPluginList, getStore } from '../../store'; import { useDeckyState } from '../DeckyState'; import ExternalLink from '../ExternalLink'; import PluginCard from './PluginCard'; @@ -63,39 +63,107 @@ const StorePage: FC<{}> = () => { ); }; +type ArrayPredicate = Parameters['sort']>[0]; +type SortKeys = 'name_asc' | 'name_dec' | 'date_asc' | 'date_dec' | 'dl_asc' | 'dl_dec'; +enum StoreFilter { + All = 'all', + Installed = 'installed', + NotInstalled = 'not_installed' +} + +const sortOptions: Record = { + 'name_asc': (a, b) => a.name.localeCompare(b.name), + 'name_dec': (a, b) => b.name.localeCompare(a.name), + 'date_asc': (a, b) => new Date(a.updated).valueOf() - new Date(b.updated).valueOf(), + 'date_dec': (a, b) => new Date(b.updated).valueOf() - new Date(a.updated).valueOf(), + 'dl_asc': (a, b) => b.downloads - a.downloads, + 'dl_dec': (a, b) => a.downloads - b.downloads, +}; + +interface DropdownOptions extends SingleDropdownOption { + data: TData; +} + const BrowseTab: FC<{ setPluginCount: Dispatch> }> = ({ setPluginCount }) => { const { t } = useTranslation(); const dropdownSortOptions = useMemo( - (): DropdownOption[] => [ + (): DropdownOptions[] => [ // ascending and descending order are the wrong way around for the alphabetical sort // this is because it was initially done incorrectly for i18n and 'fixing' it would // make all the translations incorrect - { data: [SortOptions.name, SortDirections.ascending], label: t('Store.store_tabs.alph_desc') }, - { data: [SortOptions.name, SortDirections.descending], label: t('Store.store_tabs.alph_asce') }, - { data: [SortOptions.date, SortDirections.ascending], label: t('Store.store_tabs.date_asce') }, - { data: [SortOptions.date, SortDirections.descending], label: t('Store.store_tabs.date_desc') }, - { data: [SortOptions.downloads, SortDirections.descending], label: t('Store.store_tabs.downloads_desc') }, - { data: [SortOptions.downloads, SortDirections.ascending], label: t('Store.store_tabs.downloads_asce') }, + { data: 'name_asc', label: t('Store.store_tabs.alph_desc') }, + { data: 'name_dec', label: t('Store.store_tabs.alph_asce') }, + { data: 'date_asc', label: t('Store.store_tabs.date_asce') }, + { data: 'date_dec', label: t('Store.store_tabs.date_desc') }, + { data: 'dl_asc', label: t('Store.store_tabs.downloads_desc') }, + { data: 'dl_dec', label: t('Store.store_tabs.downloads_asce') }, ], [], ); - // const filterOptions = useMemo((): DropdownOption[] => [{ data: 1, label: 'All' }], []); - const [selectedSort, setSort] = useState<[SortOptions, SortDirections]>(dropdownSortOptions[0].data); - // const [selectedFilter, setFilter] = useState(filterOptions[0].data); + // Our list of filters + const filterOptions = useMemo(() => Object.keys(StoreFilter).map>((key) => ({ + data: StoreFilter[key as keyof typeof StoreFilter], + label: t(`Store.store_filter.options.${StoreFilter[key as keyof typeof StoreFilter]}`) + }), {}), []); + + const [selectedSort, setSort] = useState['data']>(dropdownSortOptions[0].data); + const [filter, setFilter] = useState(filterOptions[0].data); const [searchFieldValue, setSearchValue] = useState(''); const [pluginList, setPluginList] = useState(null); const [isTesting, setIsTesting] = useState(false); + const { plugins: installedPlugins } = useDeckyState(); + + // TODO: I recommend using the ID here instead of a name, we already have them in the plugins list + const hasInstalledPlugin = (plugin: StorePlugin) => installedPlugins?.find((installedPlugin) => installedPlugin.name === plugin.name); + + const filterPlugin = (plugin: StorePlugin): boolean => { + switch (filter) { + case StoreFilter.Installed: + return !!hasInstalledPlugin(plugin); + case StoreFilter.NotInstalled: + return !hasInstalledPlugin(plugin); + default: + return true; + } + } + + const renderedList = useMemo(() => { + // Use an empty array in case it's null + const plugins = pluginList || []; + return ( + <> + { + plugins + .filter(filterPlugin) + .filter((plugin) => ( + plugin.name.toLowerCase().includes(searchFieldValue.toLowerCase()) || + plugin.description.toLowerCase().includes(searchFieldValue.toLowerCase()) || + plugin.author.toLowerCase().includes(searchFieldValue.toLowerCase()) || + plugin.tags.some((tag) => tag.toLowerCase().includes(searchFieldValue.toLowerCase())) + )) + .sort(sortOptions[selectedSort]) + .map((plugin) => ( + + )) + } + + ) + }, [pluginList, filter, searchFieldValue, selectedSort, installedPlugins, sortOptions]); + useEffect(() => { (async () => { - const res = await getPluginList(selectedSort[0], selectedSort[1]); + const res = await getPluginList(); logger.debug('got data!', res); setPluginList(res); setPluginCount(res.length); })(); - }, [selectedSort]); + }, []); useEffect(() => { (async () => { @@ -105,31 +173,27 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> })(); }, []); - const { plugins: installedPlugins } = useDeckyState(); - return ( <> - {/* This should be used once filtering is added - + .deckyStoreCardInstallContainer > .Panel { + padding: 0; + } + `} - +
- {t("Store.store_sort.label")} + {t('Store.store_sort.label')} setSort(e.data)} /> @@ -138,8 +202,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> style={{ display: 'flex', flexDirection: 'column', - width: '47.5%', - marginLeft: 'auto', + width: '100%' }} > {t("Store.store_filter.label")} @@ -147,41 +210,12 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> menuLabel={t("Store.store_filter.label")} rgOptions={filterOptions} strDefaultLabel={t("Store.store_filter.label_def")} - selectedOption={selectedFilter} + selectedOption={filter} onChange={(e) => setFilter(e.data)} />
-
- -
- setSearchValue(e.target.value)} /> -
-
-
- */} - - -
- {t('Store.store_sort.label')} - setSort(e.data)} - /> -
-
-
@@ -228,23 +262,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }>
- ) : ( - pluginList - .filter((plugin: StorePlugin) => { - return ( - plugin.name.toLowerCase().includes(searchFieldValue.toLowerCase()) || - plugin.description.toLowerCase().includes(searchFieldValue.toLowerCase()) || - plugin.author.toLowerCase().includes(searchFieldValue.toLowerCase()) || - plugin.tags.some((tag: string) => tag.toLowerCase().includes(searchFieldValue.toLowerCase())) - ); - }) - .map((plugin: StorePlugin) => ( - installedPlugin.name === plugin.name)} - /> - )) - )} + ) : renderedList}
); diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx index dfd9b04bf..2f33c4be9 100644 --- a/frontend/src/store.tsx +++ b/frontend/src/store.tsx @@ -23,6 +23,9 @@ export enum SortDirections { export interface StorePluginVersion { name: string; hash: string; + created: Date; + downloads: number; + updates: number; artifact: string | undefined | null; } @@ -34,6 +37,10 @@ export interface StorePlugin { description: string; tags: string[]; image_url: string; + downloads: number; + updates: number; + created: Date; + updated: Date; } export interface PluginInstallRequest { From 2979cdd19ce26d17dfef98248f3c194a59964d38 Mon Sep 17 00:00:00 2001 From: LeonBlade Date: Tue, 17 Jun 2025 00:23:48 -0400 Subject: [PATCH 2/3] Some small clean up and refactors --- frontend/src/components/store/Store.tsx | 112 ++++++++++++------------ frontend/src/store.tsx | 8 ++ 2 files changed, 63 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx index 29a829824..6c8f5561c 100644 --- a/frontend/src/components/store/Store.tsx +++ b/frontend/src/components/store/Store.tsx @@ -13,11 +13,15 @@ import { useTranslation } from 'react-i18next'; import logo from '../../../assets/plugin_store.png'; import Logger from '../../logger'; -import { Store, StorePlugin, getPluginList, getStore } from '../../store'; +import { SortKeys, Store, StoreFilter, StorePlugin, getPluginList, getStore } from '../../store'; import { useDeckyState } from '../DeckyState'; import ExternalLink from '../ExternalLink'; import PluginCard from './PluginCard'; +interface DropdownOptions extends SingleDropdownOption { + data: TData; +} + const logger = new Logger('Store'); const StorePage: FC<{}> = () => { @@ -63,27 +67,16 @@ const StorePage: FC<{}> = () => { ); }; -type ArrayPredicate = Parameters['sort']>[0]; -type SortKeys = 'name_asc' | 'name_dec' | 'date_asc' | 'date_dec' | 'dl_asc' | 'dl_dec'; -enum StoreFilter { - All = 'all', - Installed = 'installed', - NotInstalled = 'not_installed' -} - -const sortOptions: Record = { - 'name_asc': (a, b) => a.name.localeCompare(b.name), - 'name_dec': (a, b) => b.name.localeCompare(a.name), - 'date_asc': (a, b) => new Date(a.updated).valueOf() - new Date(b.updated).valueOf(), - 'date_dec': (a, b) => new Date(b.updated).valueOf() - new Date(a.updated).valueOf(), - 'dl_asc': (a, b) => b.downloads - a.downloads, - 'dl_dec': (a, b) => a.downloads - b.downloads, +// Functions for each of the store sort options +const storeSortFunctions: Record['sort']>[0]> = { + 'name-ascending': (a, b) => a.name.localeCompare(b.name), + 'name-descending': (a, b) => b.name.localeCompare(a.name), + 'date-ascending': (a, b) => new Date(a.updated).valueOf() - new Date(b.updated).valueOf(), + 'date-descending': (a, b) => new Date(b.updated).valueOf() - new Date(a.updated).valueOf(), + 'downloads-ascending': (a, b) => b.downloads - a.downloads, + 'downloads-descending': (a, b) => a.downloads - b.downloads, }; -interface DropdownOptions extends SingleDropdownOption { - data: TData; -} - const BrowseTab: FC<{ setPluginCount: Dispatch> }> = ({ setPluginCount }) => { const { t } = useTranslation(); @@ -92,21 +85,28 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> // ascending and descending order are the wrong way around for the alphabetical sort // this is because it was initially done incorrectly for i18n and 'fixing' it would // make all the translations incorrect - { data: 'name_asc', label: t('Store.store_tabs.alph_desc') }, - { data: 'name_dec', label: t('Store.store_tabs.alph_asce') }, - { data: 'date_asc', label: t('Store.store_tabs.date_asce') }, - { data: 'date_dec', label: t('Store.store_tabs.date_desc') }, - { data: 'dl_asc', label: t('Store.store_tabs.downloads_desc') }, - { data: 'dl_dec', label: t('Store.store_tabs.downloads_asce') }, + { data: 'name-ascending', label: t('Store.store_tabs.alph_desc') }, + { data: 'name-descending', label: t('Store.store_tabs.alph_asce') }, + { data: 'date-ascending', label: t('Store.store_tabs.date_asce') }, + { data: 'date-descending', label: t('Store.store_tabs.date_desc') }, + { data: 'downloads-ascending', label: t('Store.store_tabs.downloads_desc') }, + { data: 'downloads-descending', label: t('Store.store_tabs.downloads_asce') }, ], [], ); - // Our list of filters - const filterOptions = useMemo(() => Object.keys(StoreFilter).map>((key) => ({ - data: StoreFilter[key as keyof typeof StoreFilter], - label: t(`Store.store_filter.options.${StoreFilter[key as keyof typeof StoreFilter]}`) - }), {}), []); + // Our list of filters populates automatically based on the enum and matches directly to locale strings + const filterOptions = useMemo( + () => + Object.keys(StoreFilter).map>( + (key) => ({ + data: StoreFilter[key as keyof typeof StoreFilter], + label: t(`Store.store_filter.options.${StoreFilter[key as keyof typeof StoreFilter]}`), + }), + {}, + ), + [], + ); const [selectedSort, setSort] = useState['data']>(dropdownSortOptions[0].data); const [filter, setFilter] = useState(filterOptions[0].data); @@ -116,8 +116,8 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> const { plugins: installedPlugins } = useDeckyState(); - // TODO: I recommend using the ID here instead of a name, we already have them in the plugins list - const hasInstalledPlugin = (plugin: StorePlugin) => installedPlugins?.find((installedPlugin) => installedPlugin.name === plugin.name); + const hasInstalledPlugin = (plugin: StorePlugin) => + installedPlugins?.find((installedPlugin) => installedPlugin.name === plugin.name); const filterPlugin = (plugin: StorePlugin): boolean => { switch (filter) { @@ -128,33 +128,29 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> default: return true; } - } + }; const renderedList = useMemo(() => { // Use an empty array in case it's null const plugins = pluginList || []; return ( <> - { - plugins - .filter(filterPlugin) - .filter((plugin) => ( + {plugins + .filter(filterPlugin) + .filter( + (plugin) => plugin.name.toLowerCase().includes(searchFieldValue.toLowerCase()) || plugin.description.toLowerCase().includes(searchFieldValue.toLowerCase()) || plugin.author.toLowerCase().includes(searchFieldValue.toLowerCase()) || - plugin.tags.some((tag) => tag.toLowerCase().includes(searchFieldValue.toLowerCase())) - )) - .sort(sortOptions[selectedSort]) - .map((plugin) => ( - - )) - } + plugin.tags.some((tag) => tag.toLowerCase().includes(searchFieldValue.toLowerCase())), + ) + .sort(storeSortFunctions[selectedSort]) + .map((plugin) => ( + + ))} - ) - }, [pluginList, filter, searchFieldValue, selectedSort, installedPlugins, sortOptions]); + ); + }, [pluginList, filter, searchFieldValue, selectedSort, installedPlugins, storeSortFunctions]); useEffect(() => { (async () => { @@ -181,12 +177,12 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> } `} - +
{t('Store.store_sort.label')} @@ -202,14 +198,14 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }> style={{ display: 'flex', flexDirection: 'column', - width: '100%' + width: '100%', }} > - {t("Store.store_filter.label")} + {t('Store.store_filter.label')} setFilter(e.data)} /> @@ -262,7 +258,9 @@ const BrowseTab: FC<{ setPluginCount: Dispatch> }>
- ) : renderedList} + ) : ( + renderedList + )}
); diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx index 2f33c4be9..361d91874 100644 --- a/frontend/src/store.tsx +++ b/frontend/src/store.tsx @@ -9,6 +9,8 @@ export enum Store { Custom, } +export type SortKeys = `${keyof typeof SortOptions}-${keyof typeof SortDirections}`; + export enum SortOptions { name = 'name', date = 'date', @@ -20,6 +22,12 @@ export enum SortDirections { descending = 'desc', } +export enum StoreFilter { + All = 'all', + Installed = 'installed', + NotInstalled = 'not_installed' +} + export interface StorePluginVersion { name: string; hash: string; From 148dd1aba0abf5e8c4a6f88e56ff9b2d8b4b4ca0 Mon Sep 17 00:00:00 2001 From: LeonBlade Date: Tue, 17 Jun 2025 01:06:30 -0400 Subject: [PATCH 3/3] Fix linter issues --- frontend/src/components/store/Store.tsx | 2 +- frontend/src/store.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx index 6c8f5561c..aefef4822 100644 --- a/frontend/src/components/store/Store.tsx +++ b/frontend/src/components/store/Store.tsx @@ -19,7 +19,7 @@ import ExternalLink from '../ExternalLink'; import PluginCard from './PluginCard'; interface DropdownOptions extends SingleDropdownOption { - data: TData; + data: TData; } const logger = new Logger('Store'); diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx index 361d91874..cf57984d1 100644 --- a/frontend/src/store.tsx +++ b/frontend/src/store.tsx @@ -25,7 +25,7 @@ export enum SortDirections { export enum StoreFilter { All = 'all', Installed = 'installed', - NotInstalled = 'not_installed' + NotInstalled = 'not_installed', } export interface StorePluginVersion {