mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-10 22:22:14 +00:00
f6144f9634
* 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.
300 lines
9.8 KiB
TypeScript
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;
|