Files
decky-loader/frontend/src/components/store/Store.tsx
T
Álvaro Cuesta f6144f9634 feat: sync with local plugin status in store (#733)
* fix: useDeckyState proper type and safety

* refactor: plugin list

Avoids unneeded re-renders. See https://react.dev/learn/you-might-not-need-an-effect#caching-expensive-calculations

* feat: sync with local plugin status in store

Adds some QoL changes to the plugin store browser:

- Add ✓ icon to currently installed plugin version in version selector
- Change install button label depending on the install type that the
  button would trigger
- Adds icon to install button for clarity

The goal is to make it clear to the user what the current state of the
installed plugin is, and what would be the impact of installing the
selected version.

Resolves #360

* lint: prettier

* fix: add missing translations

* refactor: safer translation strings on install

Prefer using `t(...)` instead of `TranslationHelper` since it ensures
that the translation keys are not missing in the locale files when
running the `extractext` task.

By adding comments with `t(...)` calls, `i18next-parser` will generate
the strings as if they were present as literals in the code (see
https://github.com/i18next/i18next-parser#caveats).

This does _not_ suppress the warnings (since `i18next-parser` does not
have access to TS types, so it cannot infer template literals) but it at
least makes it less likely that a translation will be missed by mistake,
have typos, etc.
2025-01-02 11:38:40 -08:00

300 lines
9.8 KiB
TypeScript

import {
Dropdown,
DropdownOption,
Focusable,
PanelSectionRow,
SteamSpinner,
Tabs,
TextField,
findModule,
} from '@decky/ui';
import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react';
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 { useDeckyState } from '../DeckyState';
import ExternalLink from '../ExternalLink';
import PluginCard from './PluginCard';
const logger = new Logger('Store');
const StorePage: FC<{}> = () => {
const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse');
const [pluginCount, setPluginCount] = useState<number | null>(null);
const { TabCount } = findModule((m) => {
if (m?.TabCount && m?.TabTitle) return true;
return false;
});
const { t } = useTranslation();
return (
<>
<div
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
background: '#0005',
}}
>
<Tabs
activeTab={currentTabRoute}
onShowTab={(tabId: string) => {
setCurrentTabRoute(tabId);
}}
tabs={[
{
title: t('Store.store_tabs.title'),
content: <BrowseTab setPluginCount={setPluginCount} />,
id: 'browse',
renderTabAddon: () => <span className={TabCount}>{pluginCount}</span>,
},
{
title: t('Store.store_tabs.about'),
content: <AboutTab />,
id: 'about',
},
]}
/>
</div>
</>
);
};
const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }> = ({ setPluginCount }) => {
const { t } = useTranslation();
const dropdownSortOptions = useMemo(
(): DropdownOption[] => [
// 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') },
],
[],
);
// const filterOptions = useMemo((): DropdownOption[] => [{ data: 1, label: 'All' }], []);
const [selectedSort, setSort] = useState<[SortOptions, SortDirections]>(dropdownSortOptions[0].data);
// const [selectedFilter, setFilter] = useState<number>(filterOptions[0].data);
const [searchFieldValue, setSearchValue] = useState<string>('');
const [pluginList, setPluginList] = useState<StorePlugin[] | null>(null);
const [isTesting, setIsTesting] = useState<boolean>(false);
useEffect(() => {
(async () => {
const res = await getPluginList(selectedSort[0], selectedSort[1]);
logger.debug('got data!', res);
setPluginList(res);
setPluginCount(res.length);
})();
}, [selectedSort]);
useEffect(() => {
(async () => {
const storeRes = await getStore();
logger.debug(`store is ${storeRes}, isTesting is ${storeRes === Store.Testing}`);
setIsTesting(storeRes === Store.Testing);
})();
}, []);
const { plugins: installedPlugins } = useDeckyState();
return (
<>
<style>{`
.deckyStoreCardInstallContainer > .Panel {
padding: 0;
}
`}</style>
{/* This should be used once filtering is added
<PanelSectionRow>
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '47.5%',
}}
>
<span className="DialogLabel">{t("Store.store_sort.label")}</span>
<Dropdown
menuLabel={t("Store.store_sort.label") as string}
rgOptions={dropdownSortOptions}
strDefaultLabel={t("Store.store_sort.label_def") as string}
selectedOption={selectedSort}
onChange={(e) => setSort(e.data)}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '47.5%',
marginLeft: 'auto',
}}
>
<span className="DialogLabel">{t("Store.store_filter.label")}</span>
<Dropdown
menuLabel={t("Store.store_filter.label")}
rgOptions={filterOptions}
strDefaultLabel={t("Store.store_filter.label_def")}
selectedOption={selectedFilter}
onChange={(e) => setFilter(e.data)}
/>
</div>
</Focusable>
</PanelSectionRow>
<div style={{ justifyContent: 'center', display: 'flex' }}>
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
<div style={{ width: '100%' }}>
<TextField label={t("Store.store_search.label")} value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
</div>
</Focusable>
</div>
*/}
<PanelSectionRow>
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
minWidth: '100%',
maxWidth: '100%',
}}
>
<span className="DialogLabel">{t('Store.store_sort.label')}</span>
<Dropdown
menuLabel={t('Store.store_sort.label') as string}
rgOptions={dropdownSortOptions}
strDefaultLabel={t('Store.store_sort.label_def') as string}
selectedOption={selectedSort}
onChange={(e) => setSort(e.data)}
/>
</div>
</Focusable>
</PanelSectionRow>
<div style={{ justifyContent: 'center', display: 'flex' }}>
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
<div style={{ width: '100%' }}>
<TextField
label={t('Store.store_search.label')}
value={searchFieldValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
</div>
</Focusable>
</div>
{isTesting && (
<div
style={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
marginLeft: '20px',
marginRight: '20px',
marginBottom: '20px',
padding: '8px 36px',
background: 'rgba(255, 255, 0, 0.067)',
textAlign: 'center',
border: '2px solid rgba(255, 255, 0, 0.467)',
}}
>
<h2 style={{ margin: 0 }}>{t('Store.store_testing_warning.label')}</h2>
<span>
{`${t('Store.store_testing_warning.desc')} `}
<ExternalLink
href="https://decky.xyz/testing"
target="_blank"
style={{
textDecoration: 'none',
}}
>
decky.xyz/testing
</ExternalLink>
</span>
</div>
)}
<div>
{!pluginList ? (
<div style={{ height: '100%' }}>
<SteamSpinner background="transparent" />
</div>
) : (
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) => (
<PluginCard
storePlugin={plugin}
installedPlugin={installedPlugins.find((installedPlugin) => installedPlugin.name === plugin.name)}
/>
))
)}
</div>
</>
);
};
const AboutTab: FC<{}> = () => {
const { t } = useTranslation();
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<style>{`
.deckyStoreAboutHeader {
font-size: 24px;
font-weight: 600;
margin-top: 20px;
}
`}</style>
<img
src={logo}
style={{
width: '256px',
height: 'auto',
alignSelf: 'center',
}}
/>
<span className="deckyStoreAboutHeader">Testing</span>
<span>
{t('Store.store_testing_cta')}{' '}
<ExternalLink
href="https://decky.xyz/testing"
target="_blank"
style={{
textDecoration: 'none',
}}
>
decky.xyz/testing
</ExternalLink>
</span>
<span className="deckyStoreAboutHeader">{t('Store.store_contrib.label')}</span>
<span>{t('Store.store_contrib.desc')}</span>
<span className="deckyStoreAboutHeader">{t('Store.store_source.label')}</span>
<span>{t('Store.store_source.desc')}</span>
</div>
);
};
export default StorePage;