Move "core" folder out of "features" into "base"

This commit is contained in:
schroda
2025-08-16 02:48:46 +02:00
parent f4c06d474d
commit 9e5af57a5f
311 changed files with 712 additions and 727 deletions

View 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>
);
}

View 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,
}}
/>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);

View 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>
);
},
),
);