2023-09-14 09:49:51 -04:00

312 lines
8.8 KiB
TypeScript

import { createTheme, ThemeProvider } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from 'react-router-dom';
import { ScrollSync } from 'react-scroll-sync';
import TopBarProgress from 'react-topbar-progress-indicator';
import { loginUser as loginUserAction } from '@staticcms/core/actions/auth';
import { discardDraft } from '@staticcms/core/actions/entries';
import { currentBackend } from '@staticcms/core/backend';
import { changeTheme } from '../actions/globalUI';
import { invokeEvent } from '../lib/registry';
import { getDefaultPath } from '../lib/util/collection.util';
import { generateClassNames } from '../lib/util/theming.util';
import { selectTheme } from '../reducers/selectors/globalUI';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import CollectionRoute from './collections/CollectionRoute';
import { Alert } from './common/alert/Alert';
import { Confirm } from './common/confirm/Confirm';
import Loader from './common/progress/Loader';
import EditorRoute from './entry-editor/EditorRoute';
import MediaPage from './media-library/MediaPage';
import NotFoundPage from './NotFoundPage';
import Page from './page/Page';
import Snackbars from './snackbar/Snackbars';
import type { Credentials, TranslatedProps } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import './App.css';
export const classes = generateClassNames('App', ['root', 'content']);
TopBarProgress.config({
barColors: {
0: '#000',
'1.0': '#000',
},
shadowBlur: 0,
barThickness: 2,
});
window.addEventListener('beforeunload', function (event) {
event.stopImmediatePropagation();
});
function CollectionSearchRedirect() {
const { name } = useParams();
return <Navigate to={`/collections/${name}`} />;
}
function EditEntityRedirect() {
const { name, ...params } = useParams();
return <Navigate to={`/collections/${name}/entries/${params['*']}`} />;
}
const App = ({
auth,
user,
config,
collections,
loginUser,
isFetching,
t,
scrollSyncEnabled,
}: TranslatedProps<AppProps>) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const mode = useAppSelector(selectTheme);
const theme = React.useMemo(
() =>
createTheme({
palette: {
mode,
primary: {
main: 'rgb(37 99 235)',
},
...(mode === 'dark' && {
background: {
paper: 'rgb(15 23 42)',
},
}),
},
}),
[mode],
);
const configError = useCallback(
(error?: string) => {
return (
<div>
<h1>{t('app.app.errorHeader')}</h1>
<div>
<strong>{t('app.app.configErrors')}:</strong>
<div>{error ?? config.error}</div>
<span>{t('app.app.checkConfigYml')}</span>
</div>
</div>
);
},
[config.error, t],
);
const handleLogin = useCallback(
(credentials: Credentials) => {
loginUser(credentials);
},
[loginUser],
);
const AuthComponent = useMemo(() => {
if (!config.config) {
return null;
}
const backend = currentBackend(config.config);
return backend?.authComponent();
}, [config.config]);
const authenticationPage = useMemo(() => {
if (!config.config) {
return null;
}
if (AuthComponent == null) {
return (
<div>
<h1>{t('app.app.waitingBackend')}</h1>
</div>
);
}
return (
<AuthComponent
key="auth-page"
onLogin={handleLogin}
error={auth.error}
inProgress={auth.isFetching}
siteId={config.config.backend.site_domain}
base_url={config.config.backend.base_url}
authEndpoint={config.config.backend.auth_endpoint}
config={config.config}
clearHash={() => navigate('/', { replace: true })}
t={t}
/>
);
}, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
useEffect(() => {
if (
/\/collections\/[a-zA-Z0-9_-]+\/entries\/[a-zA-Z0-9_-]+/g.test(pathname) ||
(/\/collections\/[a-zA-Z0-9_-]+\/new/g.test(pathname) &&
searchParams.get('duplicate') === 'true')
) {
return;
}
dispatch(discardDraft());
}, [dispatch, pathname, searchParams]);
useEffect(() => {
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (
localStorage.getItem('color-theme') === 'dark' ||
(!('color-theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
dispatch(changeTheme('dark'));
} else {
document.documentElement.classList.remove('dark');
dispatch(changeTheme('light'));
}
}, [dispatch]);
const [prevUser, setPrevUser] = useState(user);
useEffect(() => {
if (!prevUser && user) {
invokeEvent({
name: 'login',
data: {
login: user.login,
name: user.name ?? '',
},
});
}
setPrevUser(user);
}, [prevUser, user]);
const content = useMemo(() => {
if (!user) {
return authenticationPage;
}
return (
<>
{isFetching && <TopBarProgress />}
<Routes>
<Route path="/" element={<Navigate to={defaultPath} />} />
<Route path="/search" element={<Navigate to={defaultPath} />} />
<Route path="/collections/:name/search/" element={<CollectionSearchRedirect />} />
<Route
path="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
element={<Navigate to={defaultPath} />}
/>
<Route path="/collections" element={<CollectionRoute />} />
<Route path="/collections/:name" element={<CollectionRoute />} />
<Route
path="/collections/:name/new"
element={<EditorRoute collections={collections} newRecord />}
/>
<Route
path="/collections/:name/new/*"
element={<EditorRoute collections={collections} newRecord />}
/>
<Route
path="/collections/:name/entries/*"
element={<EditorRoute collections={collections} />}
/>
<Route
path="/collections/:name/search/:searchTerm"
element={<CollectionRoute isSearchResults isSingleSearchResult />}
/>
<Route path="/collections/:name/filter/*" element={<CollectionRoute />} />
<Route path="/search/:searchTerm" element={<CollectionRoute isSearchResults />} />
<Route path="/edit/:name/*" element={<EditEntityRedirect />} />
<Route path="/page/:id" element={<Page />} />
<Route path="/media" element={<MediaPage />} />
<Route element={<NotFoundPage />} />
</Routes>
</>
);
}, [authenticationPage, collections, defaultPath, isFetching, user]);
useEffect(() => {
setTimeout(() => {
invokeEvent({ name: 'mounted' });
});
}, []);
if (!config.config) {
return configError(t('app.app.configNotFound'));
}
if (config.error) {
return configError();
}
if (config.isFetching) {
return <Loader>{t('app.app.loadingConfig')}</Loader>;
}
return (
<ThemeProvider theme={theme}>
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
<>
<div key="back-to-top-anchor" id="back-to-top-anchor" />
<div key="cms-root" id="cms-root" className={classes.root}>
<div key="cms-wrapper" className={classes.content}>
<Snackbars key="snackbars" />
{content}
<Alert key="alert" />
<Confirm key="confirm" />
</div>
</div>
</>
</ScrollSync>
</ThemeProvider>
);
};
function mapStateToProps(state: RootState) {
const { auth, config, collections, globalUI, scroll } = state;
const user = auth.user;
const isFetching = globalUI.isFetching;
const scrollSyncEnabled = scroll.isScrolling;
return {
auth,
config,
collections,
user,
isFetching,
scrollSyncEnabled,
};
}
const mapDispatchToProps = {
loginUser: loginUserAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type AppProps = ConnectedProps<typeof connector>;
export default connector(translate()(App) as ComponentType<AppProps>);