Integrate extension api v1.6 changes
This commit is contained in:
@@ -116,14 +116,9 @@ const GroupHeader = ({
|
||||
export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const {
|
||||
data: serverSettingsData,
|
||||
loading: areServerSettingsLoading,
|
||||
error: serverSettingsError,
|
||||
refetch: refetchServerSettings,
|
||||
} = requestManager.useGetServerSettings();
|
||||
const [fetchExtensions, { data, loading: areExtensionsLoading, error: extensionsError }] =
|
||||
requestManager.useExtensionListFetch();
|
||||
const extensionStoresRequest = requestManager.useGetExtensionStores();
|
||||
|
||||
const {
|
||||
settings: { browseLanguages: shownLangs, showNsfw },
|
||||
@@ -137,11 +132,8 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
const [updatingExtensionIds, setUpdatingExtensionIds] = useState<string[]>([]);
|
||||
const [refetchExtensions, setRefetchExtensions] = useState({});
|
||||
|
||||
const isLoading = areServerSettingsLoading || areExtensionsLoading;
|
||||
const error = serverSettingsError ?? extensionsError;
|
||||
|
||||
const areReposDefined = !!serverSettingsData?.settings.extensionRepos.length;
|
||||
const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1;
|
||||
const isLoading = extensionStoresRequest.loading || areExtensionsLoading;
|
||||
const error = extensionStoresRequest.error ?? extensionsError;
|
||||
|
||||
const allExtensions = data?.fetchExtensions?.extensions ?? STABLE_EMPTY_ARRAY;
|
||||
const allLangs = useMemo(() => getLanguagesFromExtensions(allExtensions), [allExtensions]);
|
||||
@@ -160,6 +152,17 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
[groupedExtensions],
|
||||
);
|
||||
|
||||
const areReposDefined = !!extensionStoresRequest.data?.extensionStores.totalCount;
|
||||
const areMultipleReposInUse = useMemo(() => {
|
||||
if (!allExtensions.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = allExtensions[0].extensionStore?.indexUrl;
|
||||
|
||||
return allExtensions.slice(1).some((extension) => extension.extensionStore?.indexUrl !== store);
|
||||
}, [allExtensions]);
|
||||
|
||||
const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
||||
groupCounts,
|
||||
useCallback((index) => groupedExtensions[index][LANGUAGE], [groupedExtensions]),
|
||||
@@ -244,8 +247,10 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
message={t`Unable to load data`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => {
|
||||
if (serverSettingsError) {
|
||||
refetchServerSettings().catch(defaultPromiseErrorHandler('Extensions::refetchServerSettings'));
|
||||
if (extensionStoresRequest.error) {
|
||||
extensionStoresRequest
|
||||
.refetch()
|
||||
.catch(defaultPromiseErrorHandler('Extensions::refetchExtensionsStores'));
|
||||
}
|
||||
|
||||
if (extensionsError) {
|
||||
@@ -267,9 +272,13 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
paddingTop: '20px',
|
||||
}}
|
||||
>
|
||||
<Typography>{t`You have to add a extension repository to be able to install extensions`}</Typography>
|
||||
<Button component={Link} variant="contained" to={AppRoutes.settings.children.browse.path}>
|
||||
{t`Settings`}
|
||||
<Typography>{t`You have to add a extension store to be able to install extensions`}</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
variant="contained"
|
||||
to={AppRoutes.settings.children.browse.children.extensionStores.path}
|
||||
>
|
||||
{t`Add extension store`}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
@@ -306,7 +315,7 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
<ExtensionCard
|
||||
extension={item}
|
||||
handleUpdate={handleExtensionUpdate}
|
||||
showSourceRepo={areMultipleReposInUse}
|
||||
showSourceStore={areMultipleReposInUse}
|
||||
forcedState={
|
||||
updatingExtensionIds.includes(item.pkgName) ? ExtensionState.UPDATING : undefined
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
EXTENSION_ACTION_TO_STATE_MAP,
|
||||
INSTALLED_STATE_TO_TRANSLATION_MAP,
|
||||
} from '@/features/extension/Extensions.constants.ts';
|
||||
import { getInstalledState, updateExtension } from '@/features/extension/Extensions.utils.ts';
|
||||
import { getInstalledState, isNsfw, updateExtension } from '@/features/extension/Extensions.utils.ts';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
|
||||
@@ -35,7 +35,7 @@ import { languageCodeToName } from '@/base/utils/Languages.ts';
|
||||
interface IProps {
|
||||
extension: TExtension;
|
||||
handleUpdate: () => void;
|
||||
showSourceRepo: boolean;
|
||||
showSourceStore: boolean;
|
||||
forcedState?: ExtensionState;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,20 @@ export function ExtensionCard(props: IProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const {
|
||||
extension: { name, lang, versionName, isInstalled, hasUpdate, isObsolete, pkgName, iconUrl, isNsfw, repo },
|
||||
extension: {
|
||||
name,
|
||||
lang,
|
||||
versionName,
|
||||
isInstalled,
|
||||
hasUpdate,
|
||||
isObsolete,
|
||||
pkgName,
|
||||
iconUrl,
|
||||
contentWarning,
|
||||
extensionStore,
|
||||
},
|
||||
handleUpdate,
|
||||
showSourceRepo,
|
||||
showSourceStore,
|
||||
forcedState,
|
||||
} = props;
|
||||
const [localInstalledState, setInstalledState] = useState<InstalledStates>(
|
||||
@@ -117,11 +128,14 @@ export function ExtensionCard(props: IProps) {
|
||||
<Typography variant="h6" component="h3">
|
||||
{name}
|
||||
</Typography>
|
||||
{showSourceStore && !!extensionStore && (
|
||||
<Typography variant="caption">{extensionStore.name}</Typography>
|
||||
)}
|
||||
<Typography variant="caption">
|
||||
{isInstalled ? `${languageCodeToName(lang)}` : ''}
|
||||
{isInstalled ? (
|
||||
<Typography variant="caption" sx={{ px: 1 }}>
|
||||
•
|
||||
-
|
||||
</Typography>
|
||||
) : (
|
||||
''
|
||||
@@ -130,17 +144,17 @@ export function ExtensionCard(props: IProps) {
|
||||
{isObsolete && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ px: 1 }}>
|
||||
•
|
||||
-
|
||||
</Typography>
|
||||
<Typography variant="caption" color="warning" sx={{ textTransform: 'uppercase' }}>
|
||||
{t`Obsolete`}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{isNsfw && (
|
||||
{isNsfw(contentWarning) && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ px: 1 }}>
|
||||
•
|
||||
-
|
||||
</Typography>
|
||||
<Typography variant="caption" color="error">
|
||||
18+
|
||||
@@ -148,7 +162,6 @@ export function ExtensionCard(props: IProps) {
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
{showSourceRepo && <Typography variant="caption">{repo}</Typography>}
|
||||
</Stack>
|
||||
{isInstalled && (
|
||||
<CustomTooltip title={t`Settings`}>
|
||||
|
||||
@@ -10,11 +10,10 @@ import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { NumberSetting } from '@/base/components/settings/NumberSetting.tsx';
|
||||
import { MutableListSetting } from '@/base/components/settings/MutableListSetting.tsx';
|
||||
import { TextSetting } from '@/base/components/settings/text/TextSetting.tsx';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
@@ -32,8 +31,10 @@ import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import { ListItemLink } from '@/base/components/lists/ListItemLink.tsx';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
|
||||
type ExtensionsSettings = Pick<GqlServerSettings, 'maxSourcesInParallel' | 'localSourcePath' | 'extensionRepos'>;
|
||||
type ExtensionsSettings = Pick<GqlServerSettings, 'maxSourcesInParallel' | 'localSourcePath'>;
|
||||
|
||||
export const BrowseSettings = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -42,6 +43,9 @@ export const BrowseSettings = () => {
|
||||
|
||||
const { data, loading, error, refetch } = requestManager.useGetServerSettings();
|
||||
const [mutateSettings] = requestManager.useUpdateServerSettings();
|
||||
const extensionStoresRequest = requestManager.useGetExtensionStores();
|
||||
|
||||
const extensionStoreCount = extensionStoresRequest.data?.extensionStores.totalCount;
|
||||
|
||||
const updateSetting = <Setting extends keyof ExtensionsSettings>(
|
||||
setting: Setting,
|
||||
@@ -108,34 +112,18 @@ export const BrowseSettings = () => {
|
||||
stepSize={1}
|
||||
handleUpdate={(parallelSources) => updateSetting('maxSourcesInParallel', parallelSources)}
|
||||
/>
|
||||
<MutableListSetting
|
||||
settingName={t`Extension repositories`}
|
||||
description={t`Add repositories from which extensions can be installed`}
|
||||
dialogDisclaimer={
|
||||
<Trans>
|
||||
<strong>
|
||||
Suwayomi does not provide any support for 3rd party repositories or extensions!
|
||||
</strong>
|
||||
<br />
|
||||
Use with caution as there could be malicious actors making those repositories.
|
||||
<br />
|
||||
You as the user need to verify the security and that you trust any repository or extension.
|
||||
</Trans>
|
||||
}
|
||||
handleChange={(repos) => {
|
||||
updateSetting('extensionRepos', repos);
|
||||
requestManager.clearExtensionCache();
|
||||
}}
|
||||
valueInfos={serverSettings.extensionRepos.map((extensionRepo) => [extensionRepo])}
|
||||
addItemButtonTitle={t`Add repository`}
|
||||
placeholder="https://github.com/MY_ACCOUNT/MY_REPO/tree/repo"
|
||||
validateItem={(repo) =>
|
||||
!!repo.match(
|
||||
/https:\/\/(www\.|raw\.)?(github|githubusercontent)\.com\/([^/]+)\/([^/]+)((\/tree|\/blob)?\/([^/\n]*))?(\/([^/\n]*\.json)?)?/g,
|
||||
)
|
||||
}
|
||||
invalidItemError={t`Invalid repository url`}
|
||||
/>
|
||||
<ListItemLink to={AppRoutes.settings.children.browse.children.extensionStores.path}>
|
||||
<ListItemText
|
||||
primary={t`Extension stores`}
|
||||
secondary={
|
||||
extensionStoreCount &&
|
||||
plural(extensionStoreCount, {
|
||||
one: '# extension store',
|
||||
other: '# extension stores',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ListItemLink>
|
||||
<TextSetting
|
||||
settingName={t`Local source location`}
|
||||
dialogDescription={t`The path to the directory on the server where local source files are saved in`}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Sources({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
||||
[sources],
|
||||
);
|
||||
const areSourcesFromDifferentRepos = useMemo(
|
||||
() => SourceService.areFromMultipleRepos(filteredSources),
|
||||
() => SourceService.areFromMultipleStores(filteredSources),
|
||||
[filteredSources],
|
||||
);
|
||||
const visibleSources = useMemo(
|
||||
|
||||
@@ -30,6 +30,7 @@ import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { languageCodeToName } from '@/base/utils/Languages.ts';
|
||||
import { SourceContentType } from '@/features/source/Source.types.ts';
|
||||
import { isNsfw } from '@/features/extension/Extensions.utils.ts';
|
||||
|
||||
interface IProps {
|
||||
source: GetSourcesListQuery['sources']['nodes'][number];
|
||||
@@ -47,8 +48,8 @@ export const SourceCard: React.FC<IProps> = (props: IProps) => {
|
||||
lang,
|
||||
iconUrl,
|
||||
supportsLatest,
|
||||
isNsfw,
|
||||
extension: { repo },
|
||||
contentWarning,
|
||||
extension: { extensionStore },
|
||||
} = source;
|
||||
|
||||
const { isPinned } = useGetSourceMetadata(source);
|
||||
@@ -92,13 +93,15 @@ export const SourceCard: React.FC<IProps> = (props: IProps) => {
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{showLanguage && languageCodeToName(lang)}
|
||||
{isNsfw && (
|
||||
{isNsfw(contentWarning) && (
|
||||
<Typography variant="caption" color="error">
|
||||
{' 18+'}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
{showSourceRepo && <Typography variant="caption">{repo}</Typography>}
|
||||
{showSourceRepo && extensionStore && (
|
||||
<Typography variant="caption">{extensionStore.name}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
{supportsLatest && (
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { ExtensionType } from '@/lib/graphql/generated/graphql-base.types.ts';
|
||||
import type { ExtensionStoreType, ExtensionType } from '@/lib/graphql/generated/graphql-base.types.ts';
|
||||
|
||||
export enum ExtensionAction {
|
||||
UPDATE = 'UPDATE',
|
||||
@@ -36,15 +36,14 @@ export type TExtension = Pick<
|
||||
| 'pkgName'
|
||||
| 'name'
|
||||
| 'lang'
|
||||
| 'versionCode'
|
||||
| 'versionCodeLong'
|
||||
| 'versionName'
|
||||
| 'iconUrl'
|
||||
| 'repo'
|
||||
| 'isNsfw'
|
||||
| 'contentWarning'
|
||||
| 'isInstalled'
|
||||
| 'isObsolete'
|
||||
| 'hasUpdate'
|
||||
>;
|
||||
> & { extensionStore?: Pick<ExtensionStoreType, 'indexUrl' | 'name'> | null };
|
||||
|
||||
export type GroupedExtensionsResult<KEY extends string = string> = [KEY, TExtension[]][];
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { i18n } from '@/i18n';
|
||||
import { ContentWarning } from '@/lib/graphql/generated/graphql-base.types.ts';
|
||||
|
||||
export const isNsfw = (contentWarning: ContentWarning): boolean => contentWarning !== ContentWarning.Safe;
|
||||
|
||||
export const getInstalledState = (
|
||||
isInstalled: boolean,
|
||||
@@ -130,7 +133,7 @@ export const filterExtensions = (
|
||||
normalizedSelectedLanguages.includes(toComparableLanguage(extension.lang)) ||
|
||||
extension.isInstalled,
|
||||
)
|
||||
.filter((extension) => showNsfw === undefined || showNsfw || !extension.isNsfw)
|
||||
.filter((extension) => showNsfw === undefined || showNsfw || !isNsfw(extension.contentWarning))
|
||||
.filter((extension) => query == null || enhancedCleanup(extension.name).includes(enhancedCleanup(query)));
|
||||
};
|
||||
|
||||
|
||||
@@ -12,19 +12,19 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { SpinnerImage } from '@/base/components/SpinnerImage.tsx';
|
||||
import type { TExtension } from '@/features/extension/Extensions.types.ts';
|
||||
|
||||
export const Header = ({ name, pkgName, iconUrl, repo }: TExtension) => (
|
||||
export const Header = ({ name, pkgName, iconUrl, extensionStore }: TExtension) => (
|
||||
<Stack sx={{ alignItems: 'center' }}>
|
||||
<SpinnerImage alt={name} src={requestManager.getValidImgUrlFor(iconUrl)} ignoreQueue />
|
||||
<Typography variant="h5" component="h2">
|
||||
{name}
|
||||
</Typography>
|
||||
{extensionStore && (
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{extensionStore.name}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{pkgName.replace('eu.kanade.tachiyomi.extension.', '')}
|
||||
</Typography>
|
||||
{repo && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{repo}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -11,15 +11,18 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import type { TExtension } from '@/features/extension/Extensions.types.ts';
|
||||
import { ExtensionMetadata } from '@/features/extension/info/components/ExtensionMetadata.tsx';
|
||||
import { languageCodeToName } from '@/base/utils/Languages.ts';
|
||||
import { isNsfw } from '@/features/extension/Extensions.utils.ts';
|
||||
|
||||
export const Meta = ({ versionName, lang, isNsfw }: TExtension) => {
|
||||
export const Meta = ({ versionName, lang, contentWarning }: TExtension) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<Stack sx={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<ExtensionMetadata title={t`Version`} value={versionName} />
|
||||
<ExtensionMetadata title={t`Language`} value={languageCodeToName(lang)} />
|
||||
{isNsfw && <ExtensionMetadata title={t`Age rating`} value="18+" valueProps={{ color: 'error' }} />}
|
||||
{isNsfw(contentWarning) && (
|
||||
<ExtensionMetadata title={t`Age rating`} value="18+" valueProps={{ color: 'error' }} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
286
src/features/extension/store/screens/ExtensionStores.tsx
Normal file
286
src/features/extension/store/screens/ExtensionStores.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
|
||||
import type { ExtensionStoreFieldsFragment } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { VirtuosoPersisted } from '@/lib/virtuoso/Component/VirtuosoPersisted.tsx';
|
||||
import Card from '@mui/material/Card';
|
||||
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
|
||||
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { DiscordIcon } from '@/assets/icons/svg/DiscordIcon.tsx';
|
||||
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ContentWarning } from '@/lib/graphql/generated/graphql-base.types.ts';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT, StyledFab } from '@/base/components/buttons/StyledFab.tsx';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { AwaitableComponent, type AwaitableComponentProps } from 'awaitable-component';
|
||||
import { TextSettingDialog } from '@/base/components/settings/text/TextSettingDialog.tsx';
|
||||
import { useMemo } from 'react';
|
||||
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
|
||||
|
||||
const ExtensionStoreCard = ({
|
||||
indexUrl,
|
||||
name,
|
||||
contactDiscord,
|
||||
contactWebsite,
|
||||
extensions: { totalCount: extensionCount },
|
||||
}: ExtensionStoreFieldsFragment) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [removeExtensionStore, { loading }] = requestManager.useRemoveExtensionStore();
|
||||
const [fetchExtensions] = requestManager.useExtensionListFetch();
|
||||
|
||||
const nsfwExtensionsData = requestManager.useGetExtensionList({
|
||||
variables: {
|
||||
condition: { storeIndexUrl: indexUrl },
|
||||
filter: { contentWarning: { greaterThanOrEqualTo: ContentWarning.Mixed } },
|
||||
},
|
||||
});
|
||||
const installedExtensionsData = requestManager.useGetExtensionList({
|
||||
variables: { condition: { storeIndexUrl: indexUrl, isInstalled: true } },
|
||||
});
|
||||
|
||||
const isNSfw = nsfwExtensionsData.dataState === 'complete' && nsfwExtensionsData.data?.extensions.totalCount;
|
||||
const installedExtensionCount = installedExtensionsData.data?.extensions.totalCount;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1, pb: 0 }}>
|
||||
<Card>
|
||||
<ListCardContent sx={{ flexWrap: 'wrap', justifyContent: 'space-between' }}>
|
||||
<Stack>
|
||||
<TypographyMaxLines variant="h6" component="h3">
|
||||
{name}
|
||||
</TypographyMaxLines>
|
||||
<TypographyMaxLines variant="caption" color="textSecondary">
|
||||
{isNSfw ? (
|
||||
<>
|
||||
<TypographyMaxLines sx={{ display: 'inline' }} variant="caption" color="error">
|
||||
{' '}
|
||||
+18
|
||||
</TypographyMaxLines>
|
||||
{extensionCount && ' - '}
|
||||
</>
|
||||
) : null}
|
||||
{!!extensionCount &&
|
||||
plural(extensionCount, {
|
||||
one: '# extension',
|
||||
other: '# extensions',
|
||||
})}
|
||||
{installedExtensionCount
|
||||
? ` - ${plural(installedExtensionCount, {
|
||||
one: '# installed',
|
||||
other: '# installed',
|
||||
})}`
|
||||
: null}
|
||||
</TypographyMaxLines>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{contactWebsite && (
|
||||
<CustomTooltip title={t`Open website`} disabled={!contactWebsite}>
|
||||
<IconButton
|
||||
disabled={!contactWebsite}
|
||||
href={contactWebsite ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="inherit"
|
||||
>
|
||||
<IconBrowser />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
{contactDiscord && (
|
||||
<CustomTooltip title={t`Open discord`} disabled={!contactDiscord}>
|
||||
<IconButton
|
||||
href={contactDiscord ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="inherit"
|
||||
>
|
||||
<DiscordIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
<CustomTooltip title={t`Copy index url`}>
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(indexUrl);
|
||||
makeToast(t`Copied to clipboard`, 'info');
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip disabled={loading} title={t`Delete`}>
|
||||
<IconButton
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await Confirmation.show({
|
||||
title: t`Are you sure?`,
|
||||
message: t`You are about to remove "${name}" from the extension stores`,
|
||||
});
|
||||
|
||||
try {
|
||||
await removeExtensionStore({ variables: { input: { indexUrl } } });
|
||||
|
||||
requestManager.clearExtensionCache();
|
||||
fetchExtensions().catch(
|
||||
defaultPromiseErrorHandler(
|
||||
'ExtensionStoreCard::remove::fetchExtensions',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
makeToast(
|
||||
t`Could not remove extension store "${name}"`,
|
||||
'error',
|
||||
getErrorMessage(e),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
</ListCardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AddExtensionStoreDialog = ({
|
||||
onExitComplete,
|
||||
onDismiss,
|
||||
isVisible,
|
||||
indexUrl,
|
||||
processing = false,
|
||||
startCreation,
|
||||
}: AwaitableComponentProps<void> & {
|
||||
indexUrl?: string;
|
||||
processing?: boolean;
|
||||
startCreation: (indexUrl: string) => void;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<TextSettingDialog
|
||||
settingName={t`Add extension store`}
|
||||
handleChange={startCreation}
|
||||
isDialogOpen={isVisible}
|
||||
setIsDialogOpen={noOp}
|
||||
onDismiss={onDismiss}
|
||||
onExitComplete={onExitComplete}
|
||||
value={indexUrl}
|
||||
disabled={processing}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExtensionStores = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
useAppTitle(t`Extension stores`);
|
||||
|
||||
const [addExtensionStore] = requestManager.useAddExtensionStore();
|
||||
const { data, loading, error, refetch, dataState } = requestManager.useGetExtensionStores();
|
||||
const [fetchExtensions] = requestManager.useExtensionListFetch();
|
||||
|
||||
const extensionStores = useMemo(() => {
|
||||
if (!data?.extensionStores.nodes) {
|
||||
return STABLE_EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
return data.extensionStores.nodes.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}, [data?.extensionStores.nodes]);
|
||||
|
||||
if (loading && dataState !== 'complete') {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t`Unable to load extension stores`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => {
|
||||
refetch().catch(defaultPromiseErrorHandler('ExtensionStores::refetchServerMetadataSettings'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ pb: DEFAULT_FULL_FAB_HEIGHT }}>
|
||||
<VirtuosoPersisted
|
||||
persistKey="extension-store-list"
|
||||
useWindowScroll
|
||||
overscan={window.innerHeight * 0.5}
|
||||
totalCount={extensionStores.length}
|
||||
computeItemKey={(index) => extensionStores[index].indexUrl}
|
||||
itemContent={(index) => <ExtensionStoreCard {...extensionStores[index]} />}
|
||||
/>
|
||||
<StyledFab
|
||||
variant="extended"
|
||||
color="primary"
|
||||
sx={{ gap: 1 }}
|
||||
onClick={() => {
|
||||
const addStoreDialog = AwaitableComponent.showControlled(AddExtensionStoreDialog, {
|
||||
startCreation: async (indexUrl) => {
|
||||
const request = addExtensionStore({ variables: { input: { indexUrl } } });
|
||||
|
||||
addStoreDialog.update({ indexUrl, processing: true });
|
||||
|
||||
try {
|
||||
await request;
|
||||
|
||||
requestManager.clearExtensionCache();
|
||||
fetchExtensions().catch(
|
||||
defaultPromiseErrorHandler('ExtensionStores::add:fetchExtensions'),
|
||||
);
|
||||
|
||||
addStoreDialog.submit();
|
||||
} catch (e) {
|
||||
makeToast(t`Could not add extension store`, 'error', getErrorMessage(e));
|
||||
|
||||
addStoreDialog.update({ processing: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
{t`Add`}
|
||||
</StyledFab>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -70,8 +70,8 @@ export type SourceIdInfo = Pick<SourceType, 'id'>;
|
||||
export type SourceLanguageInfo = Pick<SourceType, 'lang'>;
|
||||
export type SourceNameInfo = Pick<SourceType, 'name'>;
|
||||
export type SourceDisplayNameInfo = Pick<SourceType, 'displayName'>;
|
||||
export type SourceNsfwInfo = Pick<SourceType, 'isNsfw'>;
|
||||
export type SourceRepoInfo = { extension: Pick<ExtensionType, 'repo'> };
|
||||
export type SourceNsfwInfo = Pick<SourceType, 'contentWarning'>;
|
||||
export type SourceStoreInfo = { extension: Pick<ExtensionType, 'storeIndexUrl'> };
|
||||
export type SourceMetaInfo = { meta: SourceMetaFieldsFragment[] };
|
||||
export type SourceConfigurableInfo = Pick<SourceType, 'isConfigurable'>;
|
||||
export type SourceIconInfo = Pick<SourceType, 'iconUrl'>;
|
||||
|
||||
@@ -423,10 +423,10 @@ export function SourceMangas() {
|
||||
<>
|
||||
<AppbarSearch />
|
||||
<SourceGridLayout />
|
||||
<CustomTooltip title={t`Open in WebView`} disabled={!source?.baseUrl}>
|
||||
<CustomTooltip title={t`Open in WebView`} disabled={!source?.homeUrl}>
|
||||
<IconButton
|
||||
disabled={!source?.baseUrl}
|
||||
href={source?.baseUrl ? requestManager.getWebviewUrl(source?.baseUrl) : ''}
|
||||
disabled={!source?.homeUrl}
|
||||
href={source?.homeUrl ? requestManager.getWebviewUrl(source?.homeUrl) : ''}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
color="inherit"
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
SourceLanguageInfo,
|
||||
SourceNsfwInfo,
|
||||
SourceMetaInfo,
|
||||
SourceRepoInfo,
|
||||
SourceStoreInfo,
|
||||
} from '@/features/source/Source.types.ts';
|
||||
import {
|
||||
DefaultLanguage,
|
||||
@@ -34,6 +34,7 @@ import type { SourceBaseFieldsFragment } from '@/lib/graphql/generated/graphql.t
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import type { DocumentNode, Unmasked } from '@apollo/client';
|
||||
import { SOURCE_BASE_FIELDS } from '@/lib/graphql/source/SourceFragments.ts';
|
||||
import { isNsfw as isNsfwFnc } from '@/features/extension/Extensions.utils.ts';
|
||||
|
||||
export class Sources {
|
||||
static readonly LOCAL_SOURCE_ID = '0';
|
||||
@@ -134,7 +135,7 @@ export class Sources {
|
||||
const normalizedLanguages = toComparableLanguages(toUniqueLanguageCodes(languages ?? []));
|
||||
|
||||
const filters: [Condition: any, CheckKeepLocalSource: boolean, Filter: (source: Source) => boolean][] = [
|
||||
[isNsfw, true, (source: Source) => source.isNsfw === isNsfw],
|
||||
[isNsfw, true, (source: Source) => isNsfwFnc(source.contentWarning) === isNsfw],
|
||||
[
|
||||
languages,
|
||||
true,
|
||||
@@ -157,14 +158,14 @@ export class Sources {
|
||||
}, sources);
|
||||
}
|
||||
|
||||
static areFromMultipleRepos<Source extends SourceIdInfo & SourceRepoInfo>(sources: Source[]): boolean {
|
||||
const repo = sources.find((source) => !!source.extension.repo)?.extension.repo;
|
||||
static areFromMultipleStores<Source extends SourceIdInfo & SourceStoreInfo>(sources: Source[]): boolean {
|
||||
const store = sources.find((source) => !!source.extension.storeIndexUrl)?.extension.storeIndexUrl;
|
||||
|
||||
if (!repo || !sources.length) {
|
||||
if (!store || !sources.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return sources.some((source) => source.extension.repo !== repo && !Sources.isLocalSource(source));
|
||||
return sources.some((source) => source.extension.storeIndexUrl !== store && !Sources.isLocalSource(source));
|
||||
}
|
||||
|
||||
static getLastUsedSource<Source extends SourceIdInfo & SourceMetaInfo>(
|
||||
|
||||
Reference in New Issue
Block a user