Move "core" folder out of "features" into "base"
This commit is contained in:
123
src/base/components/feedback/EmptyView.tsx
Normal file
123
src/base/components/feedback/EmptyView.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt
|
||||
|
||||
import { type JSX, useMemo, useState } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
const ERROR_FACES = ['(・o・;)', 'Σ(ಠ_ಠ)', 'ಥ_ಥ', '(˘・_・˘)', '(; ̄Д ̄)', '(・Д・。'];
|
||||
|
||||
function getRandomErrorFace() {
|
||||
const randIndex = Math.floor(Math.random() * ERROR_FACES.length);
|
||||
return ERROR_FACES[randIndex];
|
||||
}
|
||||
|
||||
export interface EmptyViewProps {
|
||||
message: string;
|
||||
messageExtra?: JSX.Element | string;
|
||||
retry?: () => void;
|
||||
noFaces?: boolean;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const ExtraMessage = ({ messageExtra }: Pick<EmptyViewProps, 'messageExtra'>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showFullError, setShowFullError] = useState(false);
|
||||
|
||||
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(messageExtra);
|
||||
|
||||
if (!isGraphqlException) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
|
||||
color="textSecondary"
|
||||
>
|
||||
{messageExtra}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
|
||||
>
|
||||
{graphqlError}…
|
||||
</Typography>
|
||||
<Button variant="text" onClick={() => setShowFullError(!showFullError)} sx={{ pointerEvents: 'all' }}>
|
||||
{t(showFullError ? 'global.button.show_less' : 'global.button.show_more')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Collapse in={showFullError}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="textSecondary"
|
||||
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
|
||||
>
|
||||
{graphqlStackTrace}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function EmptyView({ message, messageExtra, retry, noFaces, sx }: EmptyViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorFace = useMemo(() => getRandomErrorFace(), []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '-webkit-fill-available',
|
||||
maxWidth: '100%',
|
||||
minHeight: '100%',
|
||||
pointerEvents: 'none',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{!noFaces && (
|
||||
<Typography variant="h3" gutterBottom sx={{ pointerEvents: 'all' }}>
|
||||
{errorFace}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h5" sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<ExtraMessage messageExtra={messageExtra} />
|
||||
{retry && (
|
||||
<Button onClick={retry} sx={{ pointerEvents: 'all' }}>
|
||||
{t('global.button.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
22
src/base/components/feedback/EmptyViewAbsoluteCentered.tsx
Normal file
22
src/base/components/feedback/EmptyViewAbsoluteCentered.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { EmptyView, EmptyViewProps } from '@/base/components/feedback/EmptyView.tsx';
|
||||
|
||||
export function EmptyViewAbsoluteCentered({ sx, ...emptyViewProps }: EmptyViewProps) {
|
||||
return (
|
||||
<EmptyView
|
||||
{...emptyViewProps}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
minHeight: '-webkit-fill-available',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
src/base/components/feedback/ErrorBoundary.tsx
Normal file
91
src/base/components/feedback/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { Component, ErrorInfo, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { t } from 'i18next';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
setTrackPathChange: (change: boolean) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: any;
|
||||
}
|
||||
|
||||
class RealErrorBoundary extends Component<Props, State> {
|
||||
// eslint-disable-next-line react/state-in-constructor
|
||||
public state: State = { error: null };
|
||||
|
||||
private prevPath: string = '';
|
||||
|
||||
public static getDerivedStateFromError(error: any): State {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.prevPath = window.location.pathname;
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
if (window.location.pathname !== this.prevPath) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
|
||||
this.prevPath = window.location.pathname;
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// eslint-disable-next-line
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
this.props.setTrackPathChange(true);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { error } = this.state;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyView
|
||||
message={t('global.error.label.unrecoverable_error')}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { children } = this.props;
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => {
|
||||
const [key, setKey] = useState(0);
|
||||
const { pathname } = useLocation();
|
||||
const previousPathnameRef = useRef(pathname);
|
||||
const [trackPathChange, setTrackPathChange] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackPathChange && previousPathnameRef.current !== pathname) {
|
||||
previousPathnameRef.current = pathname;
|
||||
setKey((currentKey) => (currentKey + 1) % 999999);
|
||||
setTrackPathChange(false);
|
||||
}
|
||||
}, [pathname, previousPathnameRef.current, trackPathChange]);
|
||||
|
||||
return (
|
||||
<RealErrorBoundary key={key} setTrackPathChange={setTrackPathChange}>
|
||||
{children}
|
||||
</RealErrorBoundary>
|
||||
);
|
||||
};
|
||||
53
src/base/components/feedback/LoadingPlaceholder.tsx
Normal file
53
src/base/components/feedback/LoadingPlaceholder.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from 'react';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface IProps {
|
||||
shouldRender?: boolean | (() => boolean);
|
||||
children?: React.ReactNode;
|
||||
component?: string | React.FunctionComponent<any> | React.ComponentClass<any, any>;
|
||||
componentProps?: any;
|
||||
usePadding?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingPlaceholder(props: IProps) {
|
||||
const { children, shouldRender, component, componentProps, usePadding } = props;
|
||||
|
||||
let condition = true;
|
||||
if (shouldRender !== undefined) {
|
||||
condition = shouldRender instanceof Function ? shouldRender() : shouldRender;
|
||||
}
|
||||
|
||||
if (condition) {
|
||||
if (component) {
|
||||
return React.createElement(component, componentProps);
|
||||
}
|
||||
|
||||
if (children) {
|
||||
return children as JSX.Element;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
margin: '0px auto',
|
||||
marginTop: usePadding ? 'unset' : '10px',
|
||||
marginBottom: usePadding ? 'unset' : '10px',
|
||||
padding: usePadding ? '10px 0' : 'unset',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress thickness={5} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
34
src/base/components/feedback/Progress.tsx
Normal file
34
src/base/components/feedback/Progress.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 Box from '@mui/material/Box';
|
||||
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export const Progress = ({
|
||||
progress,
|
||||
showText = true,
|
||||
progressProps = {},
|
||||
}: {
|
||||
progress: number;
|
||||
showText?: boolean;
|
||||
progressProps?: CircularProgressProps;
|
||||
}) => (
|
||||
<Box sx={{ display: 'grid', placeItems: 'center', position: 'relative' }}>
|
||||
<CircularProgress {...progressProps} variant="determinate" value={progress} />
|
||||
{showText && (
|
||||
<Box sx={{ position: 'absolute' }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>{`${Math.round(progress)}%`}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
117
src/base/components/feedback/SnackbarWithDescription.tsx
Normal file
117
src/base/components/feedback/SnackbarWithDescription.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 { closeSnackbar, CustomContentProps, SnackbarContent, VariantType } from 'notistack';
|
||||
import { ForwardedRef, forwardRef, Fragment, memo } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import AlertTitle from '@mui/material/AlertTitle';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const SNACKBAR_VARIANT_TO_TRANSLATION_KEY: Record<VariantType, TranslationKey> = {
|
||||
default: 'global.label.info',
|
||||
info: 'global.label.info',
|
||||
success: 'global.label.success',
|
||||
warning: 'global.label.warning',
|
||||
error: 'global.label.error',
|
||||
};
|
||||
|
||||
export const SnackbarWithDescription = memo(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
message,
|
||||
description,
|
||||
variant,
|
||||
action,
|
||||
}: CustomContentProps & {
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
description?: string;
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const severity = variant === 'default' ? 'info' : variant;
|
||||
const finalAction = typeof action === 'function' ? action(id) : action;
|
||||
|
||||
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(description);
|
||||
|
||||
const finalDescription = isGraphqlException ? graphqlError : description;
|
||||
const isDescriptionTooLong = (finalDescription?.length ?? 0) > MAX_DESCRIPTION_LENGTH;
|
||||
const actualDescription = isDescriptionTooLong
|
||||
? finalDescription?.slice(0, MAX_DESCRIPTION_LENGTH)
|
||||
: finalDescription;
|
||||
|
||||
const TitleComponent = actualDescription?.length ? AlertTitle : Fragment;
|
||||
|
||||
return (
|
||||
<SnackbarContent ref={ref}>
|
||||
<Alert
|
||||
elevation={1}
|
||||
severity={severity}
|
||||
action={finalAction}
|
||||
sx={{
|
||||
wordBreak: 'break-word',
|
||||
minWidth: '300px',
|
||||
[theme.breakpoints.down(MediaQuery.MOBILE_WIDTH)]: {
|
||||
maxWidth: '100vw',
|
||||
},
|
||||
[theme.breakpoints.between(MediaQuery.MOBILE_WIDTH, MediaQuery.TABLET_WIDTH)]: {
|
||||
maxWidth: '75vw',
|
||||
},
|
||||
[theme.breakpoints.up(MediaQuery.TABLET_WIDTH)]: {
|
||||
maxWidth: '50vw',
|
||||
},
|
||||
}}
|
||||
onClose={() => closeSnackbar(id)}
|
||||
>
|
||||
<TitleComponent>{message}</TitleComponent>
|
||||
{actualDescription}
|
||||
{isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
awaitConfirmation({
|
||||
title:
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: t(SNACKBAR_VARIANT_TO_TRANSLATION_KEY[variant]),
|
||||
message: description ?? '',
|
||||
actions: {
|
||||
cancel: { show: false },
|
||||
confirm: { title: t('global.label.close') },
|
||||
},
|
||||
}).catch(
|
||||
defaultPromiseErrorHandler(
|
||||
`SnackbarWithDescription: ${id} - ${message} - ${description}`,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('global.button.show_more')}
|
||||
</Button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Alert>
|
||||
</SnackbarContent>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user