refactor: monorepo setup with lerna (#243)
This commit is contained in:
committed by
GitHub
parent
dac29fbf3c
commit
504d95c34f
286
packages/core/src/components/App/App.tsx
Normal file
286
packages/core/src/components/App/App.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } 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 as discardDraftAction } from '@staticcms/core/actions/entries';
|
||||
import { currentBackend } from '@staticcms/core/backend';
|
||||
import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles';
|
||||
import { history } from '@staticcms/core/routing/history';
|
||||
import CollectionRoute from '../Collection/CollectionRoute';
|
||||
import EditorRoute from '../Editor/EditorRoute';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Page from '../page/Page';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import ScrollTop from '../UI/ScrollTop';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
import type { Collections, Credentials, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const AppRoot = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const AppWrapper = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled('div')`
|
||||
margin: 20px;
|
||||
`;
|
||||
|
||||
const ErrorCodeBlock = styled('pre')`
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const options = Object.values(collections).filter(
|
||||
collection =>
|
||||
collection.hide !== true && (!('files' in collection) || (collection.files?.length ?? 0) > 1),
|
||||
);
|
||||
|
||||
if (options.length > 0) {
|
||||
return `/collections/${options[0].name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
function CollectionSearchRedirect() {
|
||||
const { name } = useParams();
|
||||
return <Navigate to={`/collections/${name}`} />;
|
||||
}
|
||||
|
||||
function EditEntityRedirect() {
|
||||
const { name, entryName } = useParams();
|
||||
return <Navigate to={`/collections/${name}/entries/${entryName}`} />;
|
||||
}
|
||||
|
||||
const App = ({
|
||||
auth,
|
||||
user,
|
||||
config,
|
||||
collections,
|
||||
loginUser,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
t,
|
||||
scrollSyncEnabled,
|
||||
discardDraft,
|
||||
}: TranslatedProps<AppProps>) => {
|
||||
const configError = useCallback(
|
||||
(error?: string) => {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{error ?? config.error}</ErrorCodeBlock>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
);
|
||||
},
|
||||
[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 (
|
||||
<div key="auth-page-wrapper">
|
||||
<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={() => history.replace('/')}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, t]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
if (!/\/collections\/[a-zA-Z0-9_-]+\/entries\/[a-zA-Z0-9_-]+/g.test(pathname)) {
|
||||
discardDraft();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
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 collections={collections} />} />
|
||||
<Route
|
||||
path="/collections/:name"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/new"
|
||||
element={<EditorRoute collections={collections} newRecord />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/entries/:slug"
|
||||
element={<EditorRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/search/:searchTerm"
|
||||
element={
|
||||
<CollectionRoute collections={collections} isSearchResults isSingleSearchResult />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/filter/:filterTerm"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
element={<CollectionRoute collections={collections} isSearchResults />}
|
||||
/>
|
||||
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
|
||||
<Route path="/page/:id" element={<Page />} />
|
||||
<Route element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
</>
|
||||
);
|
||||
}, [authenticationPage, collections, defaultPath, isFetching, useMediaLibrary, user]);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<GlobalStyles key="global-styles" />
|
||||
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
|
||||
<>
|
||||
<div key="back-to-top-anchor" id="back-to-top-anchor" />
|
||||
<AppRoot key="cms-root" id="cms-root">
|
||||
<AppWrapper key="cms-wrapper" className="cms-wrapper">
|
||||
<Snackbars key="snackbars" />
|
||||
{content}
|
||||
<Alert key="alert" />
|
||||
<Confirm key="confirm" />
|
||||
</AppWrapper>
|
||||
</AppRoot>
|
||||
<ScrollTop key="scroll-to-top">
|
||||
<Fab size="small" aria-label="scroll back to top">
|
||||
<KeyboardArrowUpIcon />
|
||||
</Fab>
|
||||
</ScrollTop>
|
||||
</>
|
||||
</ScrollSync>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const useMediaLibrary = !mediaLibrary.externalLibrary;
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
config,
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loginUser: loginUserAction,
|
||||
discardDraft: discardDraftAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type AppProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(App) as ComponentType<AppProps>);
|
215
packages/core/src/components/App/Header.tsx
Normal file
215
packages/core/src/components/App/Header.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Link from '@mui/material/Link';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '@staticcms/core/actions/auth';
|
||||
import { createNewEntry } from '@staticcms/core/actions/collections';
|
||||
import { openMediaLibrary as openMediaLibraryAction } from '@staticcms/core/actions/mediaLibrary';
|
||||
import { checkBackendStatus as checkBackendStatusAction } from '@staticcms/core/actions/status';
|
||||
import { buttons, colors } from '@staticcms/core/components/UI/styles';
|
||||
import { stripProtocol } from '@staticcms/core/lib/urlHelper';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import SettingsDropdown from '../UI/SettingsDropdown';
|
||||
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType, MouseEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
${buttons.button};
|
||||
background: none;
|
||||
color: #7b8290;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
gap: 2px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSpacer = styled('div')`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledAppHeaderActions = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Header = ({
|
||||
user,
|
||||
collections,
|
||||
logoutUser,
|
||||
openMediaLibrary,
|
||||
displayUrl,
|
||||
isTestRepo,
|
||||
t,
|
||||
showMediaButton,
|
||||
checkBackendStatus,
|
||||
}: TranslatedProps<HeaderProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleCreatePostClick = useCallback((collectionName: string) => {
|
||||
createNewEntry(collectionName);
|
||||
}, []);
|
||||
|
||||
const createableCollections = useMemo(
|
||||
() =>
|
||||
Object.values(collections).filter(collection =>
|
||||
'folder' in collection ? collection.create ?? false : false,
|
||||
),
|
||||
[collections],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [checkBackendStatus]);
|
||||
|
||||
const handleMediaClick = useCallback(() => {
|
||||
openMediaLibrary();
|
||||
}, [openMediaLibrary]);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="sticky">
|
||||
<StyledToolbar>
|
||||
<Link to="/collections" component={NavLink} activeClassName={'header-link-active'}>
|
||||
<DescriptionIcon />
|
||||
{t('app.header.content')}
|
||||
</Link>
|
||||
{showMediaButton ? (
|
||||
<StyledButton onClick={handleMediaClick}>
|
||||
<ImageIcon />
|
||||
{t('app.header.media')}
|
||||
</StyledButton>
|
||||
) : null}
|
||||
<StyledSpacer />
|
||||
<StyledAppHeaderActions>
|
||||
{createableCollections.length > 0 && (
|
||||
<div key="quick-create">
|
||||
<Button
|
||||
id="quick-create-button"
|
||||
aria-controls={open ? 'quick-create-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('app.header.quickAdd')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="quick-create-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'quick-create-button',
|
||||
}}
|
||||
>
|
||||
{createableCollections.map(collection => (
|
||||
<MenuItem
|
||||
key={collection.name}
|
||||
onClick={() => handleCreatePostClick(collection.name)}
|
||||
>
|
||||
{collection.label_singular || collection.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{isTestRepo && (
|
||||
<Button
|
||||
href="https://staticjscms.netlify.app/docs/test-backend"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
Test Backend
|
||||
</Button>
|
||||
)}
|
||||
{displayUrl ? (
|
||||
<Button
|
||||
href={displayUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
{stripProtocol(displayUrl)}
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
isTestRepo={isTestRepo}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={logoutUser}
|
||||
/>
|
||||
</StyledAppHeaderActions>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, mediaLibrary } = state;
|
||||
const user = auth.user;
|
||||
const showMediaButton = mediaLibrary.showMediaButton;
|
||||
return {
|
||||
user,
|
||||
collections,
|
||||
displayUrl: config.config?.display_url,
|
||||
isTestRepo: config.config?.backend.name === 'test-repo',
|
||||
showMediaButton,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus: checkBackendStatusAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
logoutUser: logoutUserAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type HeaderProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Header) as ComponentType<HeaderProps>);
|
49
packages/core/src/components/App/MainView.tsx
Normal file
49
packages/core/src/components/App/MainView.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import Header from './Header';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const StyledMainContainerWrapper = styled('div')`
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const StyledMainContainer = styled('div')`
|
||||
min-width: 1152px;
|
||||
max-width: 1392px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface MainViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MainView = ({ children }: MainViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<StyledMainContainerWrapper>
|
||||
<StyledMainContainer>{children}</StyledMainContainer>
|
||||
</StyledMainContainerWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainView;
|
22
packages/core/src/components/App/NotFoundPage.tsx
Normal file
22
packages/core/src/components/App/NotFoundPage.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { lengths } from '@staticcms/core/components/UI/styles';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
const NotFoundContainer = styled('div')`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const NotFoundPage = ({ t }: TranslateProps) => {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(NotFoundPage) as ComponentType<{}>;
|
312
packages/core/src/components/Collection/Collection.tsx
Normal file
312
packages/core/src/components/Collection/Collection.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeViewStyle as changeViewStyleAction,
|
||||
filterByField as filterByFieldAction,
|
||||
groupByField as groupByFieldAction,
|
||||
sortByField as sortByFieldAction,
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import { components } from '@staticcms/core/components/UI/styles';
|
||||
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
|
||||
import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
|
||||
import {
|
||||
selectSortableFields,
|
||||
selectViewFilters,
|
||||
selectViewGroups,
|
||||
} from '@staticcms/core/lib/util/collection.util';
|
||||
import {
|
||||
selectEntriesFilter,
|
||||
selectEntriesGroup,
|
||||
selectEntriesSort,
|
||||
selectViewStyle,
|
||||
} from '@staticcms/core/reducers/entries';
|
||||
import CollectionControls from './CollectionControls';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type {
|
||||
Collection,
|
||||
SortDirection,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
const CollectionMain = styled('main')`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SearchResultContainer = styled('div')`
|
||||
${components.cardTop};
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const SearchResultHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionView = ({
|
||||
collection,
|
||||
collections,
|
||||
collectionName,
|
||||
isSearchEnabled,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
sortableFields,
|
||||
sortByField,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filterTerm,
|
||||
t,
|
||||
filterByField,
|
||||
groupByField,
|
||||
filter,
|
||||
group,
|
||||
changeViewStyle,
|
||||
viewStyle,
|
||||
}: TranslatedProps<CollectionViewProps>) => {
|
||||
const [readyToLoad, setReadyToLoad] = useState(false);
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>();
|
||||
|
||||
useEffect(() => {
|
||||
setPrevCollection(collection);
|
||||
}, [collection]);
|
||||
|
||||
const newEntryUrl = useMemo(() => {
|
||||
let url = 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : '';
|
||||
if (url && filterTerm) {
|
||||
url = getNewEntryUrl(collectionName);
|
||||
if (filterTerm) {
|
||||
url = `${newEntryUrl}?path=${filterTerm}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}, [collection, collectionName, filterTerm]);
|
||||
|
||||
const searchResultKey = useMemo(
|
||||
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
|
||||
[isSingleSearchResult],
|
||||
);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
if (isSearchResults) {
|
||||
let searchCollections = collections;
|
||||
if (isSingleSearchResult) {
|
||||
const searchCollection = Object.values(collections).filter(c => c === collection);
|
||||
if (searchCollection.length === 1) {
|
||||
searchCollections = {
|
||||
[searchCollection[0].name]: searchCollection[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EntriesSearch
|
||||
key="search"
|
||||
collections={searchCollections}
|
||||
searchTerm={searchTerm}
|
||||
viewStyle={viewStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntriesCollection
|
||||
collection={collection}
|
||||
viewStyle={viewStyle}
|
||||
filterTerm={filterTerm}
|
||||
readyToLoad={readyToLoad && collection === prevCollection}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
collection,
|
||||
collections,
|
||||
filterTerm,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
prevCollection,
|
||||
readyToLoad,
|
||||
searchTerm,
|
||||
viewStyle,
|
||||
]);
|
||||
|
||||
const onSortClick = useCallback(
|
||||
async (key: string, direction?: SortDirection) => {
|
||||
await sortByField(collection, key, direction);
|
||||
},
|
||||
[collection, sortByField],
|
||||
);
|
||||
|
||||
const onFilterClick = useCallback(
|
||||
async (filter: ViewFilter) => {
|
||||
await filterByField(collection, filter);
|
||||
},
|
||||
[collection, filterByField],
|
||||
);
|
||||
|
||||
const onGroupClick = useCallback(
|
||||
async (group: ViewGroup) => {
|
||||
await groupByField(collection, group);
|
||||
},
|
||||
[collection, groupByField],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCollection === collection) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort?.[0]?.key) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSort = collection.sortable_fields?.default;
|
||||
if (!defaultSort || !defaultSort.field) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setReadyToLoad(false);
|
||||
|
||||
let alive = true;
|
||||
|
||||
const sortEntries = () => {
|
||||
setTimeout(async () => {
|
||||
await onSortClick(defaultSort.field, defaultSort.direction ?? SORT_DIRECTION_ASCENDING);
|
||||
|
||||
if (alive) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sortEntries();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={(!isSearchResults || isSingleSearchResult) && collection}
|
||||
isSearchEnabled={isSearchEnabled}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
<CollectionMain>
|
||||
<>
|
||||
{isSearchResults ? (
|
||||
<>
|
||||
<SearchResultContainer>
|
||||
<SearchResultHeading>
|
||||
{t(searchResultKey, { searchTerm, collection: collection.label })}
|
||||
</SearchResultHeading>
|
||||
</SearchResultContainer>
|
||||
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={changeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters ?? []}
|
||||
viewGroups={viewGroups ?? []}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{entries}
|
||||
</>
|
||||
</CollectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollectionViewOwnProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
name: string;
|
||||
searchTerm?: string;
|
||||
filterTerm?: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionViewOwnProps>) {
|
||||
const { collections } = state;
|
||||
const isSearchEnabled = state.config.config && state.config.config.search != false;
|
||||
const {
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
name,
|
||||
searchTerm = '',
|
||||
filterTerm = '',
|
||||
t,
|
||||
} = ownProps;
|
||||
const collection: Collection = name ? collections[name] : collections[0];
|
||||
const sort = selectEntriesSort(state.entries, collection.name);
|
||||
const sortableFields = selectSortableFields(collection, t);
|
||||
const viewFilters = selectViewFilters(collection);
|
||||
const viewGroups = selectViewGroups(collection);
|
||||
const filter = selectEntriesFilter(state.entries, collection.name);
|
||||
const group = selectEntriesGroup(state.entries, collection.name);
|
||||
const viewStyle = selectViewStyle(state.entries);
|
||||
|
||||
return {
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
name,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
collection,
|
||||
collections,
|
||||
collectionName: name,
|
||||
isSearchEnabled,
|
||||
sort,
|
||||
sortableFields,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filter,
|
||||
group,
|
||||
viewStyle,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
sortByField: sortByFieldAction,
|
||||
filterByField: filterByFieldAction,
|
||||
changeViewStyle: changeViewStyleAction,
|
||||
groupByField: groupByFieldAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type CollectionViewProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default translate()(connector(CollectionView)) as ComponentType<CollectionViewOwnProps>;
|
@ -0,0 +1,88 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import FilterControl from './FilterControl';
|
||||
import GroupControl from './GroupControl';
|
||||
import SortControl from './SortControl';
|
||||
import ViewStyleControl from './ViewStyleControl';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type {
|
||||
FilterMap,
|
||||
GroupMap,
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const CollectionControlsContainer = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 22px;
|
||||
max-width: 100%;
|
||||
|
||||
& > div {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface CollectionControlsProps {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
sortableFields?: SortableField[];
|
||||
onSortClick?: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort?: SortMap | undefined;
|
||||
filter?: Record<string, FilterMap>;
|
||||
viewFilters?: ViewFilter[];
|
||||
onFilterClick?: (filter: ViewFilter) => void;
|
||||
group?: Record<string, GroupMap>;
|
||||
viewGroups?: ViewGroup[];
|
||||
onGroupClick?: (filter: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const CollectionControls = ({
|
||||
viewStyle,
|
||||
onChangeViewStyle,
|
||||
sortableFields,
|
||||
onSortClick,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
onFilterClick,
|
||||
onGroupClick,
|
||||
t,
|
||||
filter,
|
||||
group,
|
||||
}: TranslatedProps<CollectionControlsProps>) => {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{viewGroups && onGroupClick && group
|
||||
? viewGroups.length > 0 && (
|
||||
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
|
||||
)
|
||||
: null}
|
||||
{viewFilters && onFilterClick && filter
|
||||
? viewFilters.length > 0 && (
|
||||
<FilterControl
|
||||
viewFilters={viewFilters}
|
||||
onFilterClick={onFilterClick}
|
||||
t={t}
|
||||
filter={filter}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{sortableFields && onSortClick && sort
|
||||
? sortableFields.length > 0 && (
|
||||
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
|
||||
)
|
||||
: null}
|
||||
</CollectionControlsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionControls;
|
60
packages/core/src/components/Collection/CollectionRoute.tsx
Normal file
60
packages/core/src/components/Collection/CollectionRoute.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import MainView from '../App/MainView';
|
||||
import Collection from './Collection';
|
||||
|
||||
import type { Collections } from '@staticcms/core/interface';
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const first = Object.values(collections).filter(collection => collection.hide !== true)[0];
|
||||
if (first) {
|
||||
return `/collections/${first.name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
interface CollectionRouteProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const CollectionRoute = ({
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
collections,
|
||||
}: CollectionRouteProps) => {
|
||||
const { name, searchTerm, filterTerm } = useParams();
|
||||
const collection = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (!name || !collection) {
|
||||
return <Navigate to={defaultPath} />;
|
||||
}
|
||||
|
||||
if ('files' in collection && collection.files?.length === 1) {
|
||||
return <Navigate to={`/collections/${collection.name}/entries/${collection.files[0].name}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MainView>
|
||||
<Collection
|
||||
name={name}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
isSearchResults={isSearchResults}
|
||||
isSingleSearchResult={isSingleSearchResult}
|
||||
/>
|
||||
</MainView>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionRoute;
|
254
packages/core/src/components/Collection/CollectionSearch.tsx
Normal file
254
packages/core/src/components/Collection/CollectionSearch.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { colors, colorsRaw, lengths } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
|
||||
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const SearchContainer = styled('div')`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Suggestions = styled('ul')`
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
width: 240px;
|
||||
`;
|
||||
|
||||
const SuggestionHeader = styled('li')`
|
||||
padding: 0 6px 6px 32px;
|
||||
font-size: 12px;
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
interface SuggestionItemProps {
|
||||
$isActive: boolean;
|
||||
}
|
||||
|
||||
const SuggestionItem = styled(
|
||||
'li',
|
||||
transientOptions,
|
||||
)<SuggestionItemProps>(
|
||||
({ $isActive }) => `
|
||||
color: ${$isActive ? colors.active : colorsRaw.grayDark};
|
||||
background-color: ${$isActive ? colors.activeBackground : 'inherit'};
|
||||
padding: 6px 6px 6px 32px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const SuggestionDivider = styled('div')`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
margin-left: -44px;
|
||||
`;
|
||||
|
||||
interface CollectionSearchProps {
|
||||
collections: Collections;
|
||||
collection?: Collection;
|
||||
searchTerm: string;
|
||||
onSubmit: (query: string, collection?: string) => void;
|
||||
}
|
||||
|
||||
const CollectionSearch = ({
|
||||
collections: collectionsMap,
|
||||
collection,
|
||||
searchTerm,
|
||||
onSubmit,
|
||||
t,
|
||||
}: TranslatedProps<CollectionSearchProps>) => {
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
const [query, setQuery] = useState(searchTerm);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const collections = useMemo(() => Object.values(collectionsMap), [collectionsMap]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
inputRef.current?.blur();
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback((event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const getSelectedSelectionBasedOnProps = useCallback(() => {
|
||||
return collection ? collections.findIndex(c => c.name === collection.name) : -1;
|
||||
}, [collection, collections]);
|
||||
|
||||
const [selectedCollectionIdx, setSelectedCollectionIdx] = useState(
|
||||
getSelectedSelectionBasedOnProps(),
|
||||
);
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCollection !== collection) {
|
||||
setSelectedCollectionIdx(getSelectedSelectionBasedOnProps());
|
||||
}
|
||||
setPrevCollection(collection);
|
||||
}, [collection, getSelectedSelectionBasedOnProps, prevCollection]);
|
||||
|
||||
const selectNextSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(Math.min(selectedCollectionIdx + 1, collections.length - 1));
|
||||
}, [collections, selectedCollectionIdx]);
|
||||
|
||||
const selectPreviousSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(Math.max(selectedCollectionIdx - 1, -1));
|
||||
}, [selectedCollectionIdx]);
|
||||
|
||||
const resetSelectedSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(-1);
|
||||
}, []);
|
||||
|
||||
const submitSearch = useCallback(
|
||||
(index: number) => {
|
||||
if (index !== -1) {
|
||||
onSubmit(query, collections[index]?.name);
|
||||
} else {
|
||||
onSubmit(query);
|
||||
}
|
||||
handleClose();
|
||||
},
|
||||
[collections, handleClose, onSubmit, query],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
submitSearch(selectedCollectionIdx);
|
||||
}
|
||||
|
||||
if (open) {
|
||||
// allow closing of suggestions with escape key
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
selectNextSuggestion();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
selectPreviousSuggestion();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
handleClose,
|
||||
open,
|
||||
selectNextSuggestion,
|
||||
selectPreviousSuggestion,
|
||||
selectedCollectionIdx,
|
||||
submitSearch,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const newQuery = event.target.value;
|
||||
setQuery(newQuery);
|
||||
|
||||
if (newQuery !== '') {
|
||||
setAnchorEl(event.currentTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
resetSelectedSuggestion();
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, resetSelectedSuggestion],
|
||||
);
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(event: MouseEvent, idx: number) => {
|
||||
event.preventDefault();
|
||||
setSelectedCollectionIdx(idx);
|
||||
submitSearch(idx);
|
||||
},
|
||||
[submitSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchContainer>
|
||||
<TextField
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputRef,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<StyledPopover
|
||||
id="search-popover"
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
disableAutoFocus
|
||||
disableEnforceFocus
|
||||
disableScrollLock
|
||||
hideBackdrop
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
$isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
{collections.map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
key={idx}
|
||||
$isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.label}
|
||||
</SuggestionItem>
|
||||
))}
|
||||
</Suggestions>
|
||||
</StyledPopover>
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionSearch);
|
78
packages/core/src/components/Collection/CollectionTop.tsx
Normal file
78
packages/core/src/components/Collection/CollectionTop.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import React, { useCallback } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { components } from '@staticcms/core/components/UI/styles';
|
||||
|
||||
import type { Collection, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const CollectionTopRow = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled('p')`
|
||||
${components.cardTopDescription};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
function getCollectionProps(collection: Collection) {
|
||||
const collectionLabel = collection.label;
|
||||
const collectionLabelSingular = collection.label_singular;
|
||||
const collectionDescription = collection.description;
|
||||
|
||||
return {
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionTopProps {
|
||||
collection: Collection;
|
||||
newEntryUrl?: string;
|
||||
}
|
||||
|
||||
const CollectionTop = ({ collection, newEntryUrl, t }: TranslatedProps<CollectionTopProps>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { collectionLabel, collectionLabelSingular, collectionDescription } =
|
||||
getCollectionProps(collection);
|
||||
|
||||
const onNewClick = useCallback(() => {
|
||||
if (newEntryUrl) {
|
||||
navigate(newEntryUrl);
|
||||
}
|
||||
}, [navigate, newEntryUrl]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{newEntryUrl ? (
|
||||
<Button onClick={onNewClick} variant="contained">
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionTop);
|
93
packages/core/src/components/Collection/Entries/Entries.tsx
Normal file
93
packages/core/src/components/Collection/Entries/Entries.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import Loader from '@staticcms/core/components/UI/Loader';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type Cursor from '@staticcms/core/lib/util/Cursor';
|
||||
|
||||
const PaginationMessage = styled('div')`
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const NoEntriesMessage = styled(PaginationMessage)`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
export interface BaseEntriesProps {
|
||||
entries: Entry[];
|
||||
page?: number;
|
||||
isFetching: boolean;
|
||||
viewStyle: CollectionViewStyle;
|
||||
cursor: Cursor;
|
||||
handleCursorActions?: (action: string) => void;
|
||||
}
|
||||
|
||||
export interface SingleCollectionEntriesProps extends BaseEntriesProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
export interface MultipleCollectionEntriesProps extends BaseEntriesProps {
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps;
|
||||
|
||||
const Entries = ({
|
||||
entries,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
t,
|
||||
page,
|
||||
...otherProps
|
||||
}: TranslatedProps<EntriesProps>) => {
|
||||
const loadingMessages = [
|
||||
t('collection.entries.loadingEntries'),
|
||||
t('collection.entries.cachingEntries'),
|
||||
t('collection.entries.longerLoading'),
|
||||
];
|
||||
|
||||
if (isFetching && page === undefined) {
|
||||
return <Loader>{loadingMessages}</Loader>;
|
||||
}
|
||||
|
||||
const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next');
|
||||
if (hasEntries) {
|
||||
return (
|
||||
<>
|
||||
{'collection' in otherProps ? (
|
||||
<EntryListing
|
||||
collection={otherProps.collection}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
) : (
|
||||
<EntryListing
|
||||
collections={otherProps.collections}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
)}
|
||||
{isFetching && page !== undefined && entries.length > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
@ -0,0 +1,191 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
loadEntries as loadEntriesAction,
|
||||
traverseCollectionCursor as traverseCollectionCursorAction,
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import { Cursor } from '@staticcms/core/lib/util';
|
||||
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/cursors';
|
||||
import {
|
||||
selectEntries,
|
||||
selectEntriesLoaded,
|
||||
selectGroups,
|
||||
selectIsFetching,
|
||||
} from '@staticcms/core/reducers/entries';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { t } from 'react-polyglot';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
const GroupHeading = styled('h2')`
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const GroupContainer = styled('div')``;
|
||||
|
||||
function getGroupEntries(entries: Entry[], paths: Set<string>) {
|
||||
return entries.filter(entry => paths.has(entry.path));
|
||||
}
|
||||
|
||||
function getGroupTitle(group: GroupOfEntries, t: t) {
|
||||
const { label, value } = group;
|
||||
if (value === undefined) {
|
||||
return t('collection.groups.other');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? label : t('collection.groups.negateLabel', { label });
|
||||
}
|
||||
return `${label} ${value}`.trim();
|
||||
}
|
||||
|
||||
function withGroups(
|
||||
groups: GroupOfEntries[],
|
||||
entries: Entry[],
|
||||
EntriesToRender: ComponentType<EntriesToRenderProps>,
|
||||
t: t,
|
||||
) {
|
||||
return groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
|
||||
</GroupContainer>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface EntriesToRenderProps {
|
||||
entries: Entry[];
|
||||
}
|
||||
|
||||
const EntriesCollection = ({
|
||||
collection,
|
||||
entries,
|
||||
groups,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
page,
|
||||
traverseCollectionCursor,
|
||||
t,
|
||||
entriesLoaded,
|
||||
readyToLoad,
|
||||
loadEntries,
|
||||
}: TranslatedProps<EntriesCollectionProps>) => {
|
||||
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
collection &&
|
||||
!entriesLoaded &&
|
||||
readyToLoad &&
|
||||
(!prevReadyToLoad || prevCollection !== collection)
|
||||
) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
|
||||
setPrevReadyToLoad(readyToLoad);
|
||||
setPrevCollection(collection);
|
||||
}, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]);
|
||||
|
||||
const handleCursorActions = useCallback(
|
||||
(action: string) => {
|
||||
traverseCollectionCursor(collection, action);
|
||||
},
|
||||
[collection, traverseCollectionCursor],
|
||||
);
|
||||
|
||||
const EntriesToRender = useCallback(
|
||||
({ entries }: EntriesToRenderProps) => {
|
||||
return (
|
||||
<Entries
|
||||
collection={collection}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.label}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[collection, cursor, handleCursorActions, isFetching, page, viewStyle],
|
||||
);
|
||||
|
||||
if (groups && groups.length > 0) {
|
||||
return <>{withGroups(groups, entries, EntriesToRender, t)}</>;
|
||||
}
|
||||
|
||||
return <EntriesToRender entries={entries} />;
|
||||
};
|
||||
|
||||
export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) {
|
||||
const filtered = entries.filter(e => {
|
||||
const entryPath = e.path.slice(collectionFolder.length + 1);
|
||||
if (!entryPath.startsWith(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// only show immediate children
|
||||
if (path) {
|
||||
// non root path
|
||||
const trimmed = entryPath.slice(path.length + 1);
|
||||
return trimmed.split('/').length === 2;
|
||||
} else {
|
||||
// root path
|
||||
return entryPath.split('/').length <= 2;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
interface EntriesCollectionOwnProps {
|
||||
collection: Collection;
|
||||
viewStyle: CollectionViewStyle;
|
||||
readyToLoad: boolean;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps) {
|
||||
const { collection, viewStyle, filterTerm } = ownProps;
|
||||
const page = state.entries.pages[collection.name]?.page;
|
||||
|
||||
let entries = selectEntries(state.entries, collection);
|
||||
const groups = selectGroups(state.entries, collection);
|
||||
|
||||
if ('nested' in collection) {
|
||||
const collectionFolder = collection.folder ?? '';
|
||||
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
|
||||
}
|
||||
|
||||
const entriesLoaded = selectEntriesLoaded(state.entries, collection.name);
|
||||
const isFetching = selectIsFetching(state.entries, collection.name);
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.name);
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntries: loadEntriesAction,
|
||||
traverseCollectionCursor: traverseCollectionCursorAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EntriesCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(EntriesCollection) as ComponentType<EntriesCollectionProps>);
|
@ -0,0 +1,94 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
clearSearch as clearSearchAction,
|
||||
searchEntries as searchEntriesAction,
|
||||
} from '@staticcms/core/actions/search';
|
||||
import { Cursor } from '@staticcms/core/lib/util';
|
||||
import { selectSearchedEntries } from '@staticcms/core/reducers';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { Collections } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
const EntriesSearch = ({
|
||||
collections,
|
||||
entries,
|
||||
isFetching,
|
||||
page,
|
||||
searchTerm,
|
||||
viewStyle,
|
||||
searchEntries,
|
||||
clearSearch,
|
||||
}: EntriesSearchProps) => {
|
||||
const collectionNames = useMemo(() => Object.keys(collections), [collections]);
|
||||
|
||||
const getCursor = useCallback(() => {
|
||||
return Cursor.create({
|
||||
actions: Number.isNaN(page) ? [] : ['append_next'],
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSearch();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [prevSearch, setPrevSearch] = useState('');
|
||||
const [prevCollectionNames, setPrevCollectionNames] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
// check if the search parameters are the same
|
||||
if (prevSearch === searchTerm && isEqual(prevCollectionNames, collectionNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPrevSearch(searchTerm);
|
||||
setPrevCollectionNames(collectionNames);
|
||||
|
||||
setTimeout(() => {
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collectionNames, prevCollectionNames, prevSearch, searchTerm]);
|
||||
|
||||
return (
|
||||
<Entries
|
||||
cursor={getCursor()}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
viewStyle={viewStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface EntriesSearchOwnProps {
|
||||
searchTerm: string;
|
||||
collections: Collections;
|
||||
viewStyle: CollectionViewStyle;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
const { searchTerm, collections, viewStyle } = ownProps;
|
||||
const collectionNames = Object.keys(collections);
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
return { isFetching, page, collections, viewStyle, entries, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
searchEntries: searchEntriesAction,
|
||||
clearSearch: clearSearchAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EntriesSearchProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EntriesSearch);
|
112
packages/core/src/components/Collection/Entries/EntryCard.tsx
Normal file
112
packages/core/src/components/Collection/Entries/EntryCard.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
|
||||
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
|
||||
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { Field, Collection, Entry } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
path,
|
||||
image,
|
||||
imageField,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
getAsset,
|
||||
}: NestedCollectionProps) => {
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
|
||||
const [imageUrl, setImageUrl] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getImage = async () => {
|
||||
setImageUrl((await getAsset(collection, entry, image, imageField)).toString());
|
||||
};
|
||||
|
||||
getImage();
|
||||
}, [collection, entry, getAsset, image, imageField]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea component={Link} to={path}>
|
||||
{viewStyle === VIEW_STYLE_GRID && image && imageField ? (
|
||||
<CardMedia component="img" height="140" image={imageUrl} />
|
||||
) : null}
|
||||
<CardContent>
|
||||
{collectionLabel ? (
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{collectionLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography gutterBottom variant="h6" component="div" sx={{ margin: 0 }}>
|
||||
{summary}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface EntryCardOwnProps {
|
||||
entry: Entry;
|
||||
inferedFields: {
|
||||
titleField?: string | null | undefined;
|
||||
descriptionField?: string | null | undefined;
|
||||
imageField?: string | null | undefined;
|
||||
remainingFields?: Field[] | undefined;
|
||||
};
|
||||
collection: Collection;
|
||||
imageField?: Field;
|
||||
collectionLabel?: string;
|
||||
viewStyle?: CollectionViewStyle;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
|
||||
const { entry, inferedFields, collection } = ownProps;
|
||||
const entryData = entry.data;
|
||||
|
||||
let image = inferedFields.imageField
|
||||
? (entryData?.[inferedFields.imageField] as string | undefined)
|
||||
: undefined;
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
path: `/collections/${collection.name}/entries/${entry.slug}`,
|
||||
image,
|
||||
imageField:
|
||||
'fields' in collection
|
||||
? collection.fields?.find(f => f.name === inferedFields.imageField && f.widget === 'image')
|
||||
: undefined,
|
||||
isLoadingAsset,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type NestedCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EntryCard);
|
144
packages/core/src/components/Collection/Entries/EntryListing.tsx
Normal file
144
packages/core/src/components/Collection/Entries/EntryListing.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
|
||||
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import { selectFields, selectInferedField } from '@staticcms/core/lib/util/collection.util';
|
||||
import EntryCard from './EntryCard';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { Field, Collection, Collections, Entry } from '@staticcms/core/interface';
|
||||
import type Cursor from '@staticcms/core/lib/util/Cursor';
|
||||
|
||||
interface CardsGridProps {
|
||||
$layout: CollectionViewStyle;
|
||||
}
|
||||
|
||||
const CardsGrid = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<CardsGridProps>(
|
||||
({ $layout }) => `
|
||||
${
|
||||
$layout === VIEW_STYLE_LIST
|
||||
? `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
: `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
`
|
||||
}
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
gap: 16px;
|
||||
`,
|
||||
);
|
||||
|
||||
export interface BaseEntryListingProps {
|
||||
entries: Entry[];
|
||||
viewStyle: CollectionViewStyle;
|
||||
cursor?: Cursor;
|
||||
handleCursorActions?: (action: string) => void;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface SingleCollectionEntryListingProps extends BaseEntryListingProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps {
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
export type EntryListingProps =
|
||||
| SingleCollectionEntryListingProps
|
||||
| MultipleCollectionEntryListingProps;
|
||||
|
||||
const EntryListing = ({
|
||||
entries,
|
||||
page,
|
||||
cursor,
|
||||
viewStyle,
|
||||
handleCursorActions,
|
||||
...otherProps
|
||||
}: EntryListingProps) => {
|
||||
const hasMore = useMemo(() => cursor?.actions?.has('append_next'), [cursor?.actions]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasMore) {
|
||||
handleCursorActions?.('append_next');
|
||||
}
|
||||
}, [handleCursorActions, hasMore]);
|
||||
|
||||
const inferFields = useCallback(
|
||||
(
|
||||
collection?: Collection,
|
||||
): {
|
||||
titleField?: string | null;
|
||||
descriptionField?: string | null;
|
||||
imageField?: string | null;
|
||||
remainingFields?: Field[];
|
||||
} => {
|
||||
if (!collection) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const descriptionField = selectInferedField(collection, 'description');
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.name) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderedCards = useMemo(() => {
|
||||
if ('collection' in otherProps) {
|
||||
const inferedFields = inferFields(otherProps.collection);
|
||||
return entries.map((entry, idx) => (
|
||||
<EntryCard
|
||||
collection={otherProps.collection}
|
||||
inferedFields={inferedFields}
|
||||
viewStyle={viewStyle}
|
||||
entry={entry}
|
||||
key={idx}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.collection;
|
||||
const collection = Object.values(otherProps.collections).find(
|
||||
coll => coll.name === collectionName,
|
||||
);
|
||||
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
|
||||
const inferedFields = inferFields(collection);
|
||||
return collection ? (
|
||||
<EntryCard
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
inferedFields={inferedFields}
|
||||
collectionLabel={collectionLabel}
|
||||
key={idx}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}, [entries, inferFields, otherProps, viewStyle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid $layout={viewStyle}>
|
||||
{renderedCards}
|
||||
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
|
||||
</CardsGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryListing;
|
86
packages/core/src/components/Collection/FilterControl.tsx
Normal file
86
packages/core/src/components/Collection/FilterControl.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
interface FilterControlProps {
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (viewFilter: ViewFilter) => void;
|
||||
}
|
||||
|
||||
const FilterControl = ({
|
||||
viewFilters,
|
||||
t,
|
||||
onFilterClick,
|
||||
filter,
|
||||
}: TranslatedProps<FilterControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={anyActive ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.filterBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewFilters.map(viewFilter => {
|
||||
const checked = filter[viewFilter.id]?.active ?? false;
|
||||
const labelId = `filter-list-label-${viewFilter.label}`;
|
||||
return (
|
||||
<MenuItem
|
||||
key={viewFilter.id}
|
||||
onClick={() => onFilterClick(viewFilter)}
|
||||
sx={{ height: '36px' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={checked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={viewFilter.label} />
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(FilterControl);
|
80
packages/core/src/components/Collection/GroupControl.tsx
Normal file
80
packages/core/src/components/Collection/GroupControl.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface GroupControlProps {
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (viewGroup: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const GroupControl = ({
|
||||
viewGroups,
|
||||
group,
|
||||
t,
|
||||
onGroupClick,
|
||||
}: TranslatedProps<GroupControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={activeGroup ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.groupBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewGroups.map(viewGroup => (
|
||||
<MenuItem key={viewGroup.id} onClick={() => onGroupClick(viewGroup)}>
|
||||
<ListItemText>{viewGroup.label}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{viewGroup.id === activeGroup?.id ? <CheckIcon fontSize="small" /> : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(GroupControl);
|
354
packages/core/src/components/Collection/NestedCollection.tsx
Normal file
354
packages/core/src/components/Collection/NestedCollection.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { dirname, sep } from 'path';
|
||||
import React, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { colors, components } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
|
||||
import { stringTemplate } from '@staticcms/core/lib/widgets';
|
||||
import { selectEntries } from '@staticcms/core/reducers/entries';
|
||||
|
||||
import type { Collection, Entry } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
const { addFileTemplateFields } = stringTemplate;
|
||||
|
||||
const NodeTitleContainer = styled('div')`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const NodeTitle = styled('div')`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const Caret = styled('div')`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
`;
|
||||
|
||||
const CaretDown = styled(Caret)`
|
||||
${components.caretDown};
|
||||
color: currentColor;
|
||||
`;
|
||||
|
||||
const CaretRight = styled(Caret)`
|
||||
${components.caretRight};
|
||||
color: currentColor;
|
||||
left: 2px;
|
||||
`;
|
||||
|
||||
interface TreeNavLinkProps {
|
||||
$activeClassName: string;
|
||||
$depth: number;
|
||||
}
|
||||
|
||||
const TreeNavLink = styled(
|
||||
NavLink,
|
||||
transientOptions,
|
||||
)<TreeNavLinkProps>(
|
||||
({ $activeClassName, $depth }) => `
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: ${$depth * 20 + 12}px;
|
||||
border-left: 2px solid #fff;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.${$activeClassName} {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
border-left-color: #4863c6;
|
||||
|
||||
.MuiListItemIcon-root {
|
||||
color: ${colors.active};
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
interface BaseTreeNodeData {
|
||||
title: string | undefined;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
isRoot: boolean;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
type SingleTreeNodeData = BaseTreeNodeData | (Entry & BaseTreeNodeData);
|
||||
|
||||
type TreeNodeData = SingleTreeNodeData & {
|
||||
children: TreeNodeData[];
|
||||
};
|
||||
|
||||
function getNodeTitle(node: TreeNodeData) {
|
||||
const title = node.isRoot
|
||||
? node.title
|
||||
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
|
||||
return title;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
collection: Collection;
|
||||
treeData: TreeNodeData[];
|
||||
depth?: number;
|
||||
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void;
|
||||
}
|
||||
|
||||
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
|
||||
const collectionName = collection.name;
|
||||
|
||||
const sortedData = sortBy(treeData, getNodeTitle);
|
||||
return (
|
||||
<>
|
||||
{sortedData.map(node => {
|
||||
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
|
||||
if (leaf) {
|
||||
return null;
|
||||
}
|
||||
let to = `/collections/${collectionName}`;
|
||||
if (depth > 0) {
|
||||
to = `${to}/filter${node.path}`;
|
||||
}
|
||||
const title = getNodeTitle(node);
|
||||
|
||||
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
|
||||
|
||||
return (
|
||||
<Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
to={to}
|
||||
$activeClassName="sidebar-active"
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
$depth={depth}
|
||||
data-testid={node.path}
|
||||
>
|
||||
<ArticleIcon />
|
||||
<NodeTitleContainer>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
|
||||
</NodeTitleContainer>
|
||||
</TreeNavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
||||
depth={depth + 1}
|
||||
treeData={node.children}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function walk(treeData: TreeNodeData[], callback: (node: TreeNodeData) => void) {
|
||||
function traverse(children: TreeNodeData[]) {
|
||||
for (const child of children) {
|
||||
callback(child);
|
||||
traverse(child.children);
|
||||
}
|
||||
}
|
||||
|
||||
return traverse(treeData);
|
||||
}
|
||||
|
||||
export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeData[] {
|
||||
const collectionFolder = 'folder' in collection ? collection.folder : '';
|
||||
const rootFolder = '/';
|
||||
const entriesObj = entries.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
|
||||
|
||||
const dirs = entriesObj.reduce((acc, entry) => {
|
||||
let dir: string | undefined = dirname(entry.path);
|
||||
while (dir && !acc[dir] && dir !== rootFolder) {
|
||||
const parts: string[] = dir.split(sep);
|
||||
acc[dir] = parts.pop();
|
||||
dir = parts.length ? parts.join(sep) : undefined;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
|
||||
if ('nested' in collection && collection.nested?.summary) {
|
||||
collection = {
|
||||
...collection,
|
||||
summary: collection.nested.summary,
|
||||
};
|
||||
} else {
|
||||
collection = {
|
||||
...collection,
|
||||
};
|
||||
delete collection.summary;
|
||||
}
|
||||
|
||||
const flatData = [
|
||||
{
|
||||
title: collection.label,
|
||||
path: rootFolder,
|
||||
isDir: true,
|
||||
isRoot: true,
|
||||
},
|
||||
...Object.entries(dirs).map(([key, value]) => ({
|
||||
title: value,
|
||||
path: key,
|
||||
isDir: true,
|
||||
isRoot: false,
|
||||
})),
|
||||
...entriesObj.map((e, index) => {
|
||||
let entryMap = entries[index];
|
||||
entryMap = {
|
||||
...entryMap,
|
||||
data: addFileTemplateFields(entryMap.path, entryMap.data as Record<string, string>),
|
||||
};
|
||||
const title = selectEntryCollectionTitle(collection, entryMap);
|
||||
return {
|
||||
...e,
|
||||
title,
|
||||
isDir: false,
|
||||
isRoot: false,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const parentsToChildren = flatData.reduce((acc, node) => {
|
||||
const parent = node.path === rootFolder ? '' : dirname(node.path);
|
||||
if (acc[parent]) {
|
||||
acc[parent].push(node);
|
||||
} else {
|
||||
acc[parent] = [node];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, BaseTreeNodeData[]>);
|
||||
|
||||
function reducer(acc: TreeNodeData[], value: BaseTreeNodeData) {
|
||||
const node = value;
|
||||
let children: TreeNodeData[] = [];
|
||||
if (parentsToChildren[node.path]) {
|
||||
children = parentsToChildren[node.path].reduce(reducer, []);
|
||||
}
|
||||
|
||||
acc.push({ ...node, children });
|
||||
return acc;
|
||||
}
|
||||
|
||||
const treeData = parentsToChildren[''].reduce(reducer, []);
|
||||
|
||||
return treeData;
|
||||
}
|
||||
|
||||
export function updateNode(
|
||||
treeData: TreeNodeData[],
|
||||
node: TreeNodeData,
|
||||
callback: (node: TreeNodeData) => TreeNodeData,
|
||||
) {
|
||||
let stop = false;
|
||||
|
||||
function updater(nodes: TreeNodeData[]) {
|
||||
if (stop) {
|
||||
return nodes;
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === node.path) {
|
||||
nodes[i] = callback(node);
|
||||
stop = true;
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
nodes.forEach(node => updater(node.children));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return updater([...treeData]);
|
||||
}
|
||||
|
||||
const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => {
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
|
||||
const [selected, setSelected] = useState<TreeNodeData | null>(null);
|
||||
const [useFilter, setUseFilter] = useState(true);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
const [prevEntries, setPrevEntries] = useState(entries);
|
||||
const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
|
||||
const expanded: Record<string, boolean> = {};
|
||||
walk(treeData, node => {
|
||||
if (node.expanded) {
|
||||
expanded[node.path] = true;
|
||||
}
|
||||
});
|
||||
const newTreeData = getTreeData(collection, entries);
|
||||
|
||||
const path = `/${filterTerm}`;
|
||||
walk(newTreeData, node => {
|
||||
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
|
||||
node.expanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
|
||||
setPrevCollection(collection);
|
||||
setPrevEntries(entries);
|
||||
setPrevFilterTerm(filterTerm);
|
||||
}, [
|
||||
collection,
|
||||
entries,
|
||||
filterTerm,
|
||||
prevCollection,
|
||||
prevEntries,
|
||||
prevFilterTerm,
|
||||
treeData,
|
||||
useFilter,
|
||||
]);
|
||||
|
||||
const onToggle = useCallback(
|
||||
({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => {
|
||||
if (!selected || selected.path === node.path || expanded) {
|
||||
setTreeData(
|
||||
updateNode(treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
})),
|
||||
);
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
} else {
|
||||
// don't collapse non selected nodes when clicked
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
}
|
||||
},
|
||||
[selected, treeData],
|
||||
);
|
||||
|
||||
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
|
||||
};
|
||||
|
||||
interface NestedCollectionOwnProps {
|
||||
collection: Collection;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) {
|
||||
const { collection } = ownProps;
|
||||
const entries = selectEntries(state.entries, collection) ?? [];
|
||||
return { ...ownProps, entries };
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, {});
|
||||
export type NestedCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(NestedCollection);
|
179
packages/core/src/components/Collection/Sidebar.tsx
Normal file
179
packages/core/src/components/Collection/Sidebar.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { searchCollections } from '@staticcms/core/actions/collections';
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import { getAdditionalLinks, getIcon } from '@staticcms/core/lib/registry';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import CollectionSearch from './CollectionSearch';
|
||||
import NestedCollection from './NestedCollection';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const StyledSidebar = styled('div')`
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
const StyledListItemIcon = styled(ListItemIcon)`
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collections;
|
||||
collection: Collection;
|
||||
isSearchEnabled: boolean;
|
||||
searchTerm: string;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
const Sidebar = ({
|
||||
collections,
|
||||
collection,
|
||||
isSearchEnabled,
|
||||
searchTerm,
|
||||
t,
|
||||
filterTerm,
|
||||
}: TranslatedProps<SidebarProps>) => {
|
||||
const collectionLinks = useMemo(
|
||||
() =>
|
||||
Object.values(collections)
|
||||
.filter(collection => collection.hide !== true)
|
||||
.map(collection => {
|
||||
const collectionName = collection.name;
|
||||
const iconName = collection.icon;
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const StoredIcon = getIcon(iconName);
|
||||
if (StoredIcon) {
|
||||
icon = <StoredIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
if ('nested' in collection) {
|
||||
return (
|
||||
<li key={`nested-${collectionName}`}>
|
||||
<NestedCollection
|
||||
collection={collection}
|
||||
filterTerm={filterTerm}
|
||||
data-testid={collectionName}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={collectionName}
|
||||
to={`/collections/${collectionName}`}
|
||||
component={NavLink}
|
||||
disablePadding
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<ListItemButton>
|
||||
<StyledListItemIcon>{icon}</StyledListItemIcon>
|
||||
<ListItemText primary={collection.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}),
|
||||
[collections, filterTerm],
|
||||
);
|
||||
|
||||
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
|
||||
const links = useMemo(
|
||||
() =>
|
||||
Object.values(additionalLinks).map(
|
||||
({ id, title, data, options: { icon: iconName } = {} }) => {
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const StoredIcon = getIcon(iconName);
|
||||
if (StoredIcon) {
|
||||
icon = <StoredIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StyledListItemIcon>{icon}</StyledListItemIcon>
|
||||
<ListItemText primary={title} />
|
||||
</>
|
||||
);
|
||||
|
||||
return typeof data === 'string' ? (
|
||||
<ListItem
|
||||
key={title}
|
||||
href={data}
|
||||
component="a"
|
||||
disablePadding
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
color: colors.inactive,
|
||||
'&:hover': {
|
||||
color: colors.active,
|
||||
'.MuiListItemIcon-root': {
|
||||
color: colors.active,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemButton>{content}</ListItemButton>
|
||||
</ListItem>
|
||||
) : (
|
||||
<ListItem
|
||||
key={title}
|
||||
to={`/page/${id}`}
|
||||
component={NavLink}
|
||||
disablePadding
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<ListItemButton>{content}</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
},
|
||||
),
|
||||
[additionalLinks],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSidebar>
|
||||
<Card sx={{ minWidth: 275 }}>
|
||||
<CardContent sx={{ paddingBottom: 0 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{t('collection.sidebar.collections')}
|
||||
</Typography>
|
||||
{isSearchEnabled && (
|
||||
<CollectionSearch
|
||||
searchTerm={searchTerm}
|
||||
collections={collections}
|
||||
collection={collection}
|
||||
onSubmit={(query: string, collection?: string) =>
|
||||
searchCollections(query, collection)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<List>
|
||||
{collectionLinks}
|
||||
{links}
|
||||
</List>
|
||||
</Card>
|
||||
</StyledSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Sidebar);
|
122
packages/core/src/components/Collection/SortControl.tsx
Normal file
122
packages/core/src/components/Collection/SortControl.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Button from '@mui/material/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import {
|
||||
SORT_DIRECTION_ASCENDING,
|
||||
SORT_DIRECTION_DESCENDING,
|
||||
SORT_DIRECTION_NONE,
|
||||
} from '@staticcms/core/constants';
|
||||
|
||||
import type {
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
function nextSortDirection(direction: SortDirection) {
|
||||
switch (direction) {
|
||||
case SORT_DIRECTION_ASCENDING:
|
||||
return SORT_DIRECTION_DESCENDING;
|
||||
case SORT_DIRECTION_DESCENDING:
|
||||
return SORT_DIRECTION_NONE;
|
||||
default:
|
||||
return SORT_DIRECTION_ASCENDING;
|
||||
}
|
||||
}
|
||||
|
||||
interface SortControlProps {
|
||||
fields: SortableField[];
|
||||
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort: SortMap | undefined;
|
||||
}
|
||||
|
||||
const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const selectedSort = useMemo(() => {
|
||||
if (!sort) {
|
||||
return { key: undefined, direction: undefined };
|
||||
}
|
||||
|
||||
const sortValues = Object.values(sort);
|
||||
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SORT_DIRECTION_NONE) {
|
||||
return { key: undefined, direction: undefined };
|
||||
}
|
||||
|
||||
return sortValues[0];
|
||||
}, [sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="sort-button"
|
||||
aria-controls={open ? 'sort-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={selectedSort.key ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.sortBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="sort-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'sort-button',
|
||||
}}
|
||||
>
|
||||
{fields.map(field => {
|
||||
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
|
||||
const nextSortDir = nextSortDirection(sortDir);
|
||||
return (
|
||||
<MenuItem
|
||||
key={field.name}
|
||||
onClick={() => onSortClick(field.name, nextSortDir)}
|
||||
selected={field.name === selectedSort.key}
|
||||
>
|
||||
<ListItemText>{field.label ?? field.name}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{field.name === selectedSort.key ? (
|
||||
selectedSort.direction === SORT_DIRECTION_ASCENDING ? (
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
)
|
||||
) : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(SortControl);
|
44
packages/core/src/components/Collection/ViewStyleControl.tsx
Normal file
44
packages/core/src/components/Collection/ViewStyleControl.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import GridViewSharpIcon from '@mui/icons-material/GridViewSharp';
|
||||
import ReorderSharpIcon from '@mui/icons-material/ReorderSharp';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import React from 'react';
|
||||
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
|
||||
const ViewControlsSection = styled('div')`
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface ViewStyleControlPros {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
}
|
||||
|
||||
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
|
||||
return (
|
||||
<ViewControlsSection>
|
||||
<IconButton
|
||||
color={viewStyle === VIEW_STYLE_LIST ? 'primary' : 'default'}
|
||||
aria-label="list view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<ReorderSharpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color={viewStyle === VIEW_STYLE_GRID ? 'primary' : 'default'}
|
||||
aria-label="grid view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<GridViewSharpIcon />
|
||||
</IconButton>
|
||||
</ViewControlsSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewStyleControl;
|
400
packages/core/src/components/Editor/Editor.tsx
Normal file
400
packages/core/src/components/Editor/Editor.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '@staticcms/core/actions/auth';
|
||||
import {
|
||||
createDraftDuplicateFromEntry as createDraftDuplicateFromEntryAction,
|
||||
createEmptyDraft as createEmptyDraftAction,
|
||||
deleteDraftLocalBackup as deleteDraftLocalBackupAction,
|
||||
deleteEntry as deleteEntryAction,
|
||||
deleteLocalBackup as deleteLocalBackupAction,
|
||||
discardDraft as discardDraftAction,
|
||||
loadEntries as loadEntriesAction,
|
||||
loadEntry as loadEntryAction,
|
||||
loadLocalBackup as loadLocalBackupAction,
|
||||
persistEntry as persistEntryAction,
|
||||
persistLocalBackup as persistLocalBackupAction,
|
||||
retrieveLocalBackup as retrieveLocalBackupAction,
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import {
|
||||
loadScroll as loadScrollAction,
|
||||
toggleScroll as toggleScrollAction,
|
||||
} from '@staticcms/core/actions/scroll';
|
||||
import { selectFields } from '@staticcms/core/lib/util/collection.util';
|
||||
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||
import { selectEntry } from '@staticcms/core/reducers';
|
||||
import { history, navigateToCollection, navigateToNewEntry } from '@staticcms/core/routing/history';
|
||||
import confirm from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import EditorInterface from './EditorInterface';
|
||||
|
||||
import type { Blocker } from 'history';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
Entry,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
const Editor = ({
|
||||
entry,
|
||||
entryDraft,
|
||||
fields,
|
||||
collection,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isModification,
|
||||
logoutUser,
|
||||
draftKey,
|
||||
t,
|
||||
editorBackLink,
|
||||
toggleScroll,
|
||||
scrollSyncEnabled,
|
||||
loadScroll,
|
||||
showDelete,
|
||||
slug,
|
||||
localBackup,
|
||||
persistLocalBackup,
|
||||
loadEntry,
|
||||
persistEntry,
|
||||
deleteEntry,
|
||||
loadLocalBackup,
|
||||
retrieveLocalBackup,
|
||||
deleteLocalBackup,
|
||||
deleteDraftLocalBackup,
|
||||
createDraftDuplicateFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
}: TranslatedProps<EditorProps>) => {
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const createBackup = useMemo(
|
||||
() =>
|
||||
debounce(function (entry: Entry, collection: Collection) {
|
||||
persistLocalBackup(entry, collection);
|
||||
}, 2000),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteBackup = useCallback(() => {
|
||||
createBackup.cancel();
|
||||
if (slug) {
|
||||
deleteLocalBackup(collection, slug);
|
||||
}
|
||||
deleteDraftLocalBackup();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collection, createBackup, slug]);
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const handlePersistEntry = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
|
||||
if (!entryDraft.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await persistEntry(collection);
|
||||
setVersion(version + 1);
|
||||
|
||||
deleteBackup();
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.name);
|
||||
if (duplicate && entryDraft.entry) {
|
||||
createDraftDuplicateFromEntry(entryDraft.entry);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
|
||||
setSubmitted(true);
|
||||
},
|
||||
[
|
||||
collection,
|
||||
createDraftDuplicateFromEntry,
|
||||
deleteBackup,
|
||||
entryDraft.entry,
|
||||
persistEntry,
|
||||
version,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDuplicateEntry = useCallback(() => {
|
||||
if (!entryDraft.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToNewEntry(collection.name);
|
||||
createDraftDuplicateFromEntry(entryDraft.entry);
|
||||
}, [collection.name, createDraftDuplicateFromEntry, entryDraft.entry]);
|
||||
|
||||
const handleDeleteEntry = useCallback(async () => {
|
||||
if (entryDraft.hasChanged) {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeleteWithUnsavedChangesTitle',
|
||||
body: 'editor.editor.onDeleteWithUnsavedChangesBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeletePublishedEntryTitle',
|
||||
body: 'editor.editor.onDeletePublishedEntryBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
return navigateToCollection(collection.name);
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await deleteEntry(collection, slug);
|
||||
deleteBackup();
|
||||
return navigateToCollection(collection.name);
|
||||
}, 0);
|
||||
}, [collection, deleteBackup, deleteEntry, entryDraft.hasChanged, slug]);
|
||||
|
||||
const [prevLocalBackup, setPrevLocalBackup] = useState<
|
||||
| {
|
||||
entry: Entry;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevLocalBackup && localBackup) {
|
||||
const updateLocalBackup = async () => {
|
||||
const confirmLoadBackupBody = await confirm({
|
||||
title: 'editor.editor.confirmLoadBackupTitle',
|
||||
body: 'editor.editor.confirmLoadBackupBody',
|
||||
});
|
||||
|
||||
if (confirmLoadBackupBody) {
|
||||
loadLocalBackup();
|
||||
setVersion(version + 1);
|
||||
} else {
|
||||
deleteBackup();
|
||||
}
|
||||
};
|
||||
|
||||
updateLocalBackup();
|
||||
}
|
||||
|
||||
setPrevLocalBackup(localBackup);
|
||||
}, [deleteBackup, loadLocalBackup, localBackup, prevLocalBackup, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChanged && entryDraft.entry) {
|
||||
createBackup(entryDraft.entry, collection);
|
||||
}
|
||||
|
||||
return () => {
|
||||
createBackup.flush();
|
||||
};
|
||||
}, [collection, createBackup, entryDraft.entry, hasChanged]);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
|
||||
const [preSlug, setPrevSlug] = useState<string | undefined | null>(null);
|
||||
useEffect(() => {
|
||||
if (!slug && preSlug !== slug) {
|
||||
setTimeout(() => {
|
||||
createEmptyDraft(collection, location.search);
|
||||
});
|
||||
} else if (slug && (prevCollection !== collection || preSlug !== slug)) {
|
||||
setTimeout(() => {
|
||||
retrieveLocalBackup(collection, slug);
|
||||
loadEntry(collection, slug);
|
||||
});
|
||||
}
|
||||
|
||||
setPrevCollection(collection);
|
||||
setPrevSlug(slug);
|
||||
}, [
|
||||
collection,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
entryDraft.entry,
|
||||
loadEntry,
|
||||
preSlug,
|
||||
prevCollection,
|
||||
retrieveLocalBackup,
|
||||
slug,
|
||||
]);
|
||||
|
||||
const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]);
|
||||
|
||||
const exitBlocker = useCallback(
|
||||
(event: BeforeUnloadEvent) => {
|
||||
if (entryDraft.hasChanged) {
|
||||
// This message is ignored in most browsers, but its presence triggers the confirmation dialog
|
||||
event.returnValue = leaveMessage;
|
||||
return leaveMessage;
|
||||
}
|
||||
},
|
||||
[entryDraft.hasChanged, leaveMessage],
|
||||
);
|
||||
|
||||
useWindowEvent('beforeunload', exitBlocker);
|
||||
|
||||
const navigationBlocker: Blocker = useCallback(
|
||||
({ location, action }) => {
|
||||
/**
|
||||
* New entry being saved and redirected to it's new slug based url.
|
||||
*/
|
||||
const isPersisting = entryDraft.entry?.isPersisting;
|
||||
const newRecord = entryDraft.entry?.newRecord;
|
||||
const newEntryPath = `/collections/${collection.name}/new`;
|
||||
if (isPersisting && newRecord && location.pathname === newEntryPath && action === 'PUSH') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
return leaveMessage;
|
||||
}
|
||||
},
|
||||
[
|
||||
collection.name,
|
||||
entryDraft.entry?.isPersisting,
|
||||
entryDraft.entry?.newRecord,
|
||||
hasChanged,
|
||||
leaveMessage,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unblock = history.block(navigationBlocker);
|
||||
|
||||
return () => {
|
||||
unblock();
|
||||
};
|
||||
}, [collection.name, deleteBackup, discardDraft, navigationBlocker]);
|
||||
|
||||
if (entry && entry.error) {
|
||||
return (
|
||||
<div>
|
||||
<h3>{entry.error}</h3>
|
||||
</div>
|
||||
);
|
||||
} else if (entryDraft == null || entryDraft.entry === undefined || (entry && entry.isFetching)) {
|
||||
return <Loader>{t('editor.editor.loadingEntry')}</Loader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorInterface
|
||||
key={`editor-${version}`}
|
||||
draftKey={draftKey}
|
||||
entry={entryDraft.entry}
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
fieldsErrors={entryDraft.fieldsErrors}
|
||||
onPersist={handlePersistEntry}
|
||||
onDelete={handleDeleteEntry}
|
||||
onDuplicate={handleDuplicateEntry}
|
||||
showDelete={showDelete ?? true}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
isNewEntry={!slug}
|
||||
isModification={isModification}
|
||||
onLogoutClick={logoutUser}
|
||||
editorBackLink={editorBackLink}
|
||||
toggleScroll={toggleScroll}
|
||||
scrollSyncEnabled={scrollSyncEnabled}
|
||||
loadScroll={loadScroll}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollectionViewOwnProps {
|
||||
name: string;
|
||||
slug?: string;
|
||||
newRecord: boolean;
|
||||
showDelete?: boolean;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) {
|
||||
const { collections, entryDraft, auth, config, entries, scroll } = state;
|
||||
const { name, slug } = ownProps;
|
||||
const collection = collections[name];
|
||||
const collectionName = collection.name;
|
||||
const fields = selectFields(collection, slug);
|
||||
const entry = !slug ? null : selectEntry(state, collectionName, slug);
|
||||
const user = auth.user;
|
||||
const hasChanged = entryDraft.hasChanged;
|
||||
const displayUrl = config.config?.display_url;
|
||||
const isModification = entryDraft.entry?.isModification ?? false;
|
||||
const collectionEntriesLoaded = Boolean(entries.pages[collectionName]);
|
||||
const localBackup = entryDraft.localBackup;
|
||||
const draftKey = entryDraft.key;
|
||||
let editorBackLink = `/collections/${collectionName}`;
|
||||
if ('files' in collection && collection.files?.length === 1) {
|
||||
editorBackLink = '/';
|
||||
}
|
||||
|
||||
if ('nested' in collection && collection.nested && slug) {
|
||||
const pathParts = slug.split('/');
|
||||
if (pathParts.length > 2) {
|
||||
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
collection,
|
||||
collections,
|
||||
entryDraft,
|
||||
fields,
|
||||
entry,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
localBackup,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntry: loadEntryAction,
|
||||
loadEntries: loadEntriesAction,
|
||||
loadLocalBackup: loadLocalBackupAction,
|
||||
deleteDraftLocalBackup: deleteDraftLocalBackupAction,
|
||||
retrieveLocalBackup: retrieveLocalBackupAction,
|
||||
persistLocalBackup: persistLocalBackupAction,
|
||||
deleteLocalBackup: deleteLocalBackupAction,
|
||||
createDraftDuplicateFromEntry: createDraftDuplicateFromEntryAction,
|
||||
createEmptyDraft: createEmptyDraftAction,
|
||||
discardDraft: discardDraftAction,
|
||||
persistEntry: persistEntryAction,
|
||||
deleteEntry: deleteEntryAction,
|
||||
logoutUser: logoutUserAction,
|
||||
toggleScroll: toggleScrollAction,
|
||||
loadScroll: loadScrollAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Editor) as ComponentType<EditorProps>);
|
@ -0,0 +1,370 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { isEqual } from 'lodash';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React, { createElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeDraftField as changeDraftFieldAction,
|
||||
changeDraftFieldValidation,
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
|
||||
import {
|
||||
clearMediaControl as clearMediaControlAction,
|
||||
openMediaLibrary as openMediaLibraryAction,
|
||||
removeInsertedMedia as removeInsertedMediaAction,
|
||||
removeMediaControl as removeMediaControlAction,
|
||||
} from '@staticcms/core/actions/mediaLibrary';
|
||||
import { query as queryAction } from '@staticcms/core/actions/search';
|
||||
import { borders, colors, lengths, transitions } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare';
|
||||
import useUUID from '@staticcms/core/lib/hooks/useUUID';
|
||||
import { resolveWidget } from '@staticcms/core/lib/registry';
|
||||
import { getFieldLabel } from '@staticcms/core/lib/util/field.util';
|
||||
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
|
||||
import { validate } from '@staticcms/core/lib/util/validation.util';
|
||||
import { selectFieldErrors } from '@staticcms/core/reducers/entryDraft';
|
||||
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
|
||||
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
||||
|
||||
import type {
|
||||
Field,
|
||||
FieldsErrors,
|
||||
GetAssetFunction,
|
||||
I18nSettings,
|
||||
TranslatedProps,
|
||||
UnknownField,
|
||||
ValueOrNestedValue,
|
||||
Widget,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
/**
|
||||
* This is a necessary bridge as we are still passing classnames to widgets
|
||||
* for styling. Once that changes we can stop storing raw style strings like
|
||||
* this.
|
||||
*/
|
||||
const styleStrings = {
|
||||
widget: `
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: ${lengths.inputPadding};
|
||||
margin: 0;
|
||||
border: ${borders.textField};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border-top-left-radius: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background-color: ${colors.inputBackground};
|
||||
color: #444a57;
|
||||
transition: border-color ${transitions.main};
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
|
||||
select& {
|
||||
text-indent: 14px;
|
||||
height: 58px;
|
||||
}
|
||||
`,
|
||||
widgetActive: `
|
||||
border-color: ${colors.active};
|
||||
`,
|
||||
widgetError: `
|
||||
border-color: ${colors.errorText};
|
||||
`,
|
||||
disabled: `
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
background: #ccc;
|
||||
`,
|
||||
hidden: `
|
||||
visibility: hidden;
|
||||
`,
|
||||
};
|
||||
|
||||
interface ControlContainerProps {
|
||||
$isHidden: boolean;
|
||||
}
|
||||
|
||||
const ControlContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<ControlContainerProps>(
|
||||
({ $isHidden }) => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
${$isHidden ? styleStrings.hidden : ''};
|
||||
`,
|
||||
);
|
||||
|
||||
const ControlErrorsList = styled('ul')`
|
||||
list-style-type: none;
|
||||
font-size: 12px;
|
||||
color: ${colors.errorText};
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
`;
|
||||
|
||||
interface ControlHintProps {
|
||||
$error: boolean;
|
||||
}
|
||||
|
||||
const ControlHint = styled(
|
||||
'p',
|
||||
transientOptions,
|
||||
)<ControlHintProps>(
|
||||
({ $error }) => `
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: ${$error ? colors.errorText : colors.controlLabel};
|
||||
transition: color ${transitions.main};
|
||||
`,
|
||||
);
|
||||
|
||||
const EditorControl = ({
|
||||
clearMediaControl,
|
||||
collection,
|
||||
config: configState,
|
||||
entry,
|
||||
field,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
getAsset,
|
||||
isDisabled,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isHidden = false,
|
||||
locale,
|
||||
mediaPaths,
|
||||
openMediaLibrary,
|
||||
parentPath,
|
||||
query,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
t,
|
||||
value,
|
||||
forList = false,
|
||||
changeDraftField,
|
||||
i18n,
|
||||
}: TranslatedProps<EditorControlProps>) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = useUUID();
|
||||
|
||||
const widgetName = field.widget;
|
||||
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue>;
|
||||
const fieldHint = field.hint;
|
||||
|
||||
const path = useMemo(
|
||||
() => (parentPath.length > 0 ? `${parentPath}.${field.name}` : field.name),
|
||||
[field.name, parentPath],
|
||||
);
|
||||
|
||||
const [dirty, setDirty] = useState(!isEmpty(value));
|
||||
|
||||
const fieldErrorsSelector = useMemo(() => selectFieldErrors(path), [path]);
|
||||
const errors = useAppSelector(fieldErrorsSelector);
|
||||
|
||||
const hasErrors = (submitted || dirty) && Boolean(errors.length);
|
||||
|
||||
const handleGetAsset: GetAssetFunction = useMemo(
|
||||
() => (path: string, field?: Field) => {
|
||||
return getAsset(collection, entry, path, field);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty && !submitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validateValue = async () => {
|
||||
const errors = await validate(field, value, widget, t);
|
||||
dispatch(changeDraftFieldValidation(path, errors));
|
||||
};
|
||||
|
||||
validateValue();
|
||||
}, [dispatch, field, path, t, value, widget, dirty, submitted]);
|
||||
|
||||
const handleChangeDraftField = useCallback(
|
||||
(value: ValueOrNestedValue) => {
|
||||
setDirty(true);
|
||||
changeDraftField({ path, field, value, i18n });
|
||||
},
|
||||
[changeDraftField, field, i18n, path],
|
||||
);
|
||||
|
||||
const config = useMemo(() => configState.config, [configState.config]);
|
||||
|
||||
const finalValue = useMemoCompare(value, isEqual);
|
||||
|
||||
const [version, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
if (isNotNullish(finalValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('default' in field && isNotNullish(!field.default)) {
|
||||
if (widget.getDefaultValue) {
|
||||
handleChangeDraftField(
|
||||
widget.getDefaultValue(field.default, field as unknown as UnknownField),
|
||||
);
|
||||
} else {
|
||||
handleChangeDraftField(field.default);
|
||||
}
|
||||
setVersion(version => version + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.getDefaultValue) {
|
||||
handleChangeDraftField(widget.getDefaultValue(null, field as unknown as UnknownField));
|
||||
setVersion(version => version + 1);
|
||||
}
|
||||
}, [field, finalValue, handleChangeDraftField, widget]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!collection || !entry || !config || field.widget === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlContainer $isHidden={isHidden}>
|
||||
<>
|
||||
{createElement(widget.control, {
|
||||
key: `${id}-${version}`,
|
||||
collection,
|
||||
config,
|
||||
entry,
|
||||
field: field as UnknownField,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
getAsset: handleGetAsset,
|
||||
isDisabled: isDisabled ?? false,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
label: getFieldLabel(field, t),
|
||||
locale,
|
||||
mediaPaths,
|
||||
onChange: handleChangeDraftField,
|
||||
clearMediaControl,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
path,
|
||||
query,
|
||||
t,
|
||||
value: finalValue,
|
||||
forList,
|
||||
i18n,
|
||||
hasErrors,
|
||||
})}
|
||||
{fieldHint ? (
|
||||
<ControlHint key="hint" $error={hasErrors}>
|
||||
{fieldHint}
|
||||
</ControlHint>
|
||||
) : null}
|
||||
{hasErrors ? (
|
||||
<ControlErrorsList key="errors">
|
||||
{errors.map(error => {
|
||||
return (
|
||||
error.message &&
|
||||
typeof error.message === 'string' && (
|
||||
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ControlErrorsList>
|
||||
) : null}
|
||||
</>
|
||||
</ControlContainer>
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
collection,
|
||||
config,
|
||||
path,
|
||||
errors,
|
||||
isHidden,
|
||||
widget.control,
|
||||
field,
|
||||
submitted,
|
||||
handleGetAsset,
|
||||
isDisabled,
|
||||
t,
|
||||
locale,
|
||||
mediaPaths,
|
||||
handleChangeDraftField,
|
||||
clearMediaControl,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
query,
|
||||
finalValue,
|
||||
forList,
|
||||
i18n,
|
||||
hasErrors,
|
||||
fieldHint,
|
||||
]);
|
||||
};
|
||||
|
||||
interface EditorControlOwnProps {
|
||||
field: Field;
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
isDisabled?: boolean;
|
||||
isFieldDuplicate?: (field: Field) => boolean;
|
||||
isFieldHidden?: (field: Field) => boolean;
|
||||
isHidden?: boolean;
|
||||
locale?: string;
|
||||
parentPath: string;
|
||||
value: ValueOrNestedValue;
|
||||
forList?: boolean;
|
||||
i18n: I18nSettings | undefined;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {
|
||||
const { collections, entryDraft } = state;
|
||||
const entry = entryDraft.entry;
|
||||
const collection = entryDraft.entry ? collections[entryDraft.entry.collection] : null;
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
mediaPaths: state.mediaLibrary.controlMedia,
|
||||
config: state.config,
|
||||
entry,
|
||||
collection,
|
||||
isLoadingAsset,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
clearMediaControl: clearMediaControlAction,
|
||||
removeMediaControl: removeMediaControlAction,
|
||||
removeInsertedMedia: removeInsertedMediaAction,
|
||||
query: queryAction,
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorControlProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(EditorControl) as ComponentType<EditorControlProps>);
|
@ -0,0 +1,241 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import get from 'lodash/get';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeDraftField as changeDraftFieldAction } from '@staticcms/core/actions/entries';
|
||||
import confirm from '@staticcms/core/components/UI/Confirm';
|
||||
import {
|
||||
getI18nInfo,
|
||||
getLocaleDataPath,
|
||||
hasI18n,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isFieldTranslatable,
|
||||
} from '@staticcms/core/lib/i18n';
|
||||
import EditorControl from './EditorControl';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
I18nSettings,
|
||||
TranslatedProps,
|
||||
ValueOrNestedValue,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
const ControlPaneContainer = styled('div')`
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const LocaleRowWrapper = styled('div')`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
interface LocaleDropdownProps {
|
||||
locales: string[];
|
||||
dropdownText: string;
|
||||
onLocaleChange: (locale: string) => void;
|
||||
}
|
||||
|
||||
const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdownProps) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{dropdownText}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{locales.map(locale => (
|
||||
<MenuItem key={locale} onClick={() => onLocaleChange(locale)}>
|
||||
{locale}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getFieldValue(
|
||||
field: Field,
|
||||
entry: Entry,
|
||||
isTranslatable: boolean,
|
||||
locale: string | undefined,
|
||||
): ValueOrNestedValue {
|
||||
if (isTranslatable && locale) {
|
||||
const dataPath = getLocaleDataPath(locale);
|
||||
return get(entry, [...dataPath, field.name]);
|
||||
}
|
||||
|
||||
return entry.data?.[field.name];
|
||||
}
|
||||
|
||||
const EditorControlPane = ({
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
changeDraftField,
|
||||
locale,
|
||||
onLocaleChange,
|
||||
t,
|
||||
}: TranslatedProps<EditorControlPaneProps>) => {
|
||||
const i18n = useMemo(() => {
|
||||
if (hasI18n(collection)) {
|
||||
const { locales, defaultLocale } = getI18nInfo(collection);
|
||||
return {
|
||||
currentLocale: locale ?? locales[0],
|
||||
locales,
|
||||
defaultLocale,
|
||||
} as I18nSettings;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [collection, locale]);
|
||||
|
||||
const copyFromOtherLocale = useCallback(
|
||||
({ targetLocale }: { targetLocale?: string }) =>
|
||||
async (sourceLocale: string) => {
|
||||
if (!targetLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle',
|
||||
body: {
|
||||
key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody',
|
||||
options: { locale: sourceLocale.toUpperCase() },
|
||||
},
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fields.forEach(field => {
|
||||
if (isFieldTranslatable(field, targetLocale, sourceLocale)) {
|
||||
const copyValue = getFieldValue(
|
||||
field,
|
||||
entry,
|
||||
sourceLocale !== i18n?.defaultLocale,
|
||||
sourceLocale,
|
||||
);
|
||||
changeDraftField({ path: field.name, field, value: copyValue, i18n });
|
||||
}
|
||||
});
|
||||
},
|
||||
[fields, entry, i18n, changeDraftField],
|
||||
);
|
||||
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry || entry.partial === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlPaneContainer>
|
||||
{i18n?.locales && locale ? (
|
||||
<LocaleRowWrapper>
|
||||
<LocaleDropdown
|
||||
locales={i18n.locales}
|
||||
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
|
||||
locale: locale?.toUpperCase(),
|
||||
})}
|
||||
onLocaleChange={onLocaleChange}
|
||||
/>
|
||||
<LocaleDropdown
|
||||
locales={i18n.locales.filter(l => l !== locale)}
|
||||
dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')}
|
||||
onLocaleChange={copyFromOtherLocale({ targetLocale: locale })}
|
||||
/>
|
||||
</LocaleRowWrapper>
|
||||
) : null}
|
||||
{fields.map(field => {
|
||||
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
|
||||
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
|
||||
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);
|
||||
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;
|
||||
|
||||
return (
|
||||
<EditorControl
|
||||
key={key}
|
||||
field={field}
|
||||
value={getFieldValue(field, entry, isTranslatable, locale)}
|
||||
fieldsErrors={fieldsErrors}
|
||||
submitted={submitted}
|
||||
isDisabled={isDuplicate}
|
||||
isHidden={isHidden}
|
||||
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
|
||||
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
|
||||
locale={locale}
|
||||
parentPath=""
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditorControlPaneOwnProps {
|
||||
collection: Collection;
|
||||
entry: Entry;
|
||||
fields: Field[];
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
locale?: string;
|
||||
onLocaleChange: (locale: string) => void;
|
||||
}
|
||||
|
||||
function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) {
|
||||
return {
|
||||
...ownProps,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorControlPaneProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EditorControlPane);
|
372
packages/core/src/components/Editor/EditorInterface.tsx
Normal file
372
packages/core/src/components/Editor/EditorInterface.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import HeightIcon from '@mui/icons-material/Height';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import { colorsRaw, components, zIndex } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n';
|
||||
import { getFileFromSlug } from '@staticcms/core/lib/util/collection.util';
|
||||
import EditorControlPane from './EditorControlPane/EditorControlPane';
|
||||
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
TranslatedProps,
|
||||
User,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
const I18N_VISIBLE = 'cms.i18n-visible';
|
||||
|
||||
const StyledSplitPane = styled('div')`
|
||||
display: grid;
|
||||
grid-template-columns: min(864px, 50%) auto;
|
||||
height: calc(100vh - 64px);
|
||||
|
||||
> div:nth-of-type(2)::before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
background-color: rgb(223, 223, 227);
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const NoPreviewContainer = styled('div')`
|
||||
${components.card};
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Editor = styled('div')`
|
||||
height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
background-color: ${colorsRaw.white};
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
interface PreviewPaneContainerProps {
|
||||
$blockEntry?: boolean;
|
||||
$overFlow?: boolean;
|
||||
}
|
||||
|
||||
const PreviewPaneContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<PreviewPaneContainerProps>(
|
||||
({ $blockEntry, $overFlow }) => `
|
||||
height: 100%;
|
||||
pointer-events: ${$blockEntry ? 'none' : 'auto'};
|
||||
overflow-y: ${$overFlow ? 'auto' : 'hidden'};
|
||||
`,
|
||||
);
|
||||
|
||||
const ControlPaneContainer = styled(PreviewPaneContainer)`
|
||||
padding: 24px 16px 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledViewControls = styled('div')`
|
||||
position: fixed;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
z-index: ${zIndex.zIndex299};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
interface EditorContentProps {
|
||||
i18nVisible: boolean;
|
||||
previewVisible: boolean;
|
||||
editor: JSX.Element;
|
||||
editorSideBySideLocale: JSX.Element;
|
||||
editorWithPreview: JSX.Element;
|
||||
}
|
||||
|
||||
const EditorContent = ({
|
||||
i18nVisible,
|
||||
previewVisible,
|
||||
editor,
|
||||
editorSideBySideLocale,
|
||||
editorWithPreview,
|
||||
}: EditorContentProps) => {
|
||||
if (i18nVisible) {
|
||||
return editorSideBySideLocale;
|
||||
} else if (previewVisible) {
|
||||
return editorWithPreview;
|
||||
} else {
|
||||
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
|
||||
}
|
||||
};
|
||||
|
||||
interface EditorInterfaceProps {
|
||||
draftKey: string;
|
||||
entry: Entry;
|
||||
collection: Collection;
|
||||
fields: Field[] | undefined;
|
||||
fieldsErrors: FieldsErrors;
|
||||
onPersist: (opts?: EditorPersistOptions) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onDuplicate: () => void;
|
||||
showDelete: boolean;
|
||||
user: User | undefined;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
isNewEntry: boolean;
|
||||
isModification: boolean;
|
||||
onLogoutClick: () => void;
|
||||
editorBackLink: string;
|
||||
toggleScroll: () => Promise<{ readonly type: 'TOGGLE_SCROLL' }>;
|
||||
scrollSyncEnabled: boolean;
|
||||
loadScroll: () => void;
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
const EditorInterface = ({
|
||||
collection,
|
||||
entry,
|
||||
fields = [],
|
||||
fieldsErrors,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onPersist,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
onLogoutClick,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
t,
|
||||
loadScroll,
|
||||
toggleScroll,
|
||||
submitted,
|
||||
}: TranslatedProps<EditorInterfaceProps>) => {
|
||||
const [previewVisible, setPreviewVisible] = useState(
|
||||
localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
|
||||
);
|
||||
const [i18nVisible, setI18nVisible] = useState(localStorage.getItem(I18N_VISIBLE) !== 'false');
|
||||
|
||||
useEffect(() => {
|
||||
loadScroll();
|
||||
}, [loadScroll]);
|
||||
|
||||
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
|
||||
const [selectedLocale, setSelectedLocale] = useState(locales?.[0]);
|
||||
const switchToDefaultLocale = useCallback(() => {
|
||||
if (hasI18n(collection)) {
|
||||
const { defaultLocale } = getI18nInfo(collection);
|
||||
setSelectedLocale(defaultLocale);
|
||||
}
|
||||
}, [collection]);
|
||||
|
||||
const handleOnPersist = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
await switchToDefaultLocale();
|
||||
// TODO Trigger field validation on persist
|
||||
// this.controlPaneRef.validate();
|
||||
onPersist({ createNew, duplicate });
|
||||
},
|
||||
[onPersist, switchToDefaultLocale],
|
||||
);
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
const newPreviewVisible = !previewVisible;
|
||||
setPreviewVisible(newPreviewVisible);
|
||||
localStorage.setItem(PREVIEW_VISIBLE, `${newPreviewVisible}`);
|
||||
}, [previewVisible]);
|
||||
|
||||
const handleToggleScrollSync = useCallback(() => {
|
||||
toggleScroll();
|
||||
}, [toggleScroll]);
|
||||
|
||||
const handleToggleI18n = useCallback(() => {
|
||||
const newI18nVisible = !i18nVisible;
|
||||
setI18nVisible(newI18nVisible);
|
||||
localStorage.setItem(I18N_VISIBLE, `${newI18nVisible}`);
|
||||
}, [i18nVisible]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: string) => {
|
||||
setSelectedLocale(locale);
|
||||
}, []);
|
||||
|
||||
const [previewEnabled, previewInFrame] = useMemo(() => {
|
||||
let preview = collection.editor?.preview ?? true;
|
||||
let frame = collection.editor?.frame ?? true;
|
||||
|
||||
if ('files' in collection) {
|
||||
const file = getFileFromSlug(collection, entry.slug);
|
||||
if (file?.editor?.preview !== undefined) {
|
||||
preview = file.editor.preview;
|
||||
}
|
||||
|
||||
if (file?.editor?.frame !== undefined) {
|
||||
frame = file.editor.frame;
|
||||
}
|
||||
}
|
||||
|
||||
return [preview, frame];
|
||||
}, [collection, entry.slug]);
|
||||
|
||||
const collectionI18nEnabled = hasI18n(collection);
|
||||
|
||||
const editor = (
|
||||
<ControlPaneContainer id="control-pane" $overFlow>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={selectedLocale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const editorLocale = (
|
||||
<ControlPaneContainer $overFlow={!scrollSyncEnabled}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={locales?.[1]}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const previewEntry = collectionI18nEnabled
|
||||
? getPreviewEntry(entry, selectedLocale, defaultLocale)
|
||||
: entry;
|
||||
|
||||
const editorWithPreview = (
|
||||
<>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<PreviewPaneContainer>
|
||||
<EditorPreviewPane
|
||||
collection={collection}
|
||||
previewInFrame={previewInFrame}
|
||||
entry={previewEntry}
|
||||
fields={fields}
|
||||
/>
|
||||
</PreviewPaneContainer>
|
||||
</StyledSplitPane>
|
||||
</>
|
||||
);
|
||||
|
||||
const editorSideBySideLocale = (
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<div>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>{editorLocale}</ScrollSyncPane>
|
||||
</StyledSplitPane>
|
||||
</div>
|
||||
</ScrollSync>
|
||||
);
|
||||
|
||||
const finalI18nVisible = collectionI18nEnabled && i18nVisible;
|
||||
const finalPreviewVisible = previewEnabled && previewVisible;
|
||||
const scrollSyncVisible = finalI18nVisible || finalPreviewVisible;
|
||||
|
||||
return (
|
||||
<EditorContainer>
|
||||
<EditorToolbar
|
||||
isPersisting={entry.isPersisting}
|
||||
isDeleting={entry.isDeleting}
|
||||
onPersist={handleOnPersist}
|
||||
onPersistAndNew={() => handleOnPersist({ createNew: true })}
|
||||
onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })}
|
||||
onDelete={onDelete}
|
||||
showDelete={showDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
onLogoutClick={onLogoutClick}
|
||||
editorBackLink={editorBackLink}
|
||||
/>
|
||||
<Editor key={draftKey}>
|
||||
<StyledViewControls>
|
||||
{collectionI18nEnabled && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={finalI18nVisible ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleToggleI18n}
|
||||
title={t('editor.editorInterface.toggleI18n')}
|
||||
>
|
||||
<LanguageIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{previewEnabled && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={finalPreviewVisible ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleTogglePreview}
|
||||
title={t('editor.editorInterface.togglePreview')}
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{scrollSyncVisible && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={scrollSyncEnabled ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleToggleScrollSync}
|
||||
title={t('editor.editorInterface.toggleScrollSync')}
|
||||
>
|
||||
<HeightIcon />
|
||||
</Fab>
|
||||
)}
|
||||
</StyledViewControls>
|
||||
<EditorContent
|
||||
i18nVisible={finalI18nVisible}
|
||||
previewVisible={finalPreviewVisible}
|
||||
editor={editor}
|
||||
editorSideBySideLocale={editorSideBySideLocale}
|
||||
editorWithPreview={editorWithPreview}
|
||||
/>
|
||||
</Editor>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
@ -0,0 +1,27 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import type { TemplatePreviewProps } from '@staticcms/core/interface';
|
||||
|
||||
const PreviewContainer = styled('div')`
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
|
||||
`;
|
||||
|
||||
const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps) => {
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{fields.map(field => (
|
||||
<div key={field.name}>{widgetFor(field.name)}</div>
|
||||
))}
|
||||
</PreviewContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preview;
|
@ -0,0 +1,22 @@
|
||||
import { createElement, memo } from 'react';
|
||||
|
||||
import type { TemplatePreviewComponent, TemplatePreviewProps } from '@staticcms/core/interface';
|
||||
|
||||
interface EditorPreviewContentProps {
|
||||
previewComponent?: TemplatePreviewComponent;
|
||||
previewProps: TemplatePreviewProps;
|
||||
}
|
||||
|
||||
const EditorPreviewContent = memo(
|
||||
({ previewComponent, previewProps }: EditorPreviewContentProps) => {
|
||||
if (!previewComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createElement(previewComponent, previewProps);
|
||||
},
|
||||
);
|
||||
|
||||
EditorPreviewContent.displayName = 'EditorPreviewContent';
|
||||
|
||||
export default EditorPreviewContent;
|
@ -0,0 +1,608 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { Fragment, isValidElement, useCallback, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Frame, { FrameContextConsumer } from 'react-frame-component';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
|
||||
import { ErrorBoundary } from '@staticcms/core/components/UI';
|
||||
import { lengths } from '@staticcms/core/components/UI/styles';
|
||||
import { getPreviewStyles, getPreviewTemplate, resolveWidget } from '@staticcms/core/lib/registry';
|
||||
import { selectTemplateName, useInferedFields } from '@staticcms/core/lib/util/collection.util';
|
||||
import { selectField } from '@staticcms/core/lib/util/field.util';
|
||||
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
|
||||
import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers';
|
||||
import EditorPreview from './EditorPreview';
|
||||
import EditorPreviewContent from './EditorPreviewContent';
|
||||
import PreviewHOC from './PreviewHOC';
|
||||
|
||||
import type { InferredField } from '@staticcms/core/constants/fieldInference';
|
||||
import type {
|
||||
Collection,
|
||||
Config,
|
||||
Entry,
|
||||
EntryData,
|
||||
Field,
|
||||
GetAssetFunction,
|
||||
ListField,
|
||||
ObjectValue,
|
||||
RenderedField,
|
||||
TemplatePreviewProps,
|
||||
TranslatedProps,
|
||||
ValueOrNestedValue,
|
||||
WidgetPreviewComponent,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType, ReactFragment, ReactNode } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
const PreviewPaneFrame = styled(Frame)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
const FrameGlobalStyles = `
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.frame-content {
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewPaneWrapper = styled('div')`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const StyledPreviewContent = styled('div')`
|
||||
width: calc(100% - min(864px, 50%));
|
||||
top: 64px;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Returns the widget component for a named field, and makes recursive calls
|
||||
* to retrieve components for nested and deeply nested fields, which occur in
|
||||
* object and list type fields. Used internally to retrieve widgets, and also
|
||||
* exposed for use in custom preview templates.
|
||||
*/
|
||||
function getWidgetFor(
|
||||
config: Config,
|
||||
collection: Collection,
|
||||
name: string,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[] = fields,
|
||||
values: EntryData = entry.data,
|
||||
idx: number | null = null,
|
||||
): ReactNode {
|
||||
// We retrieve the field by name so that this function can also be used in
|
||||
// custom preview templates, where the field object can't be passed in.
|
||||
const field = widgetFields && widgetFields.find(f => f.name === name);
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = values?.[field.name];
|
||||
let fieldWithWidgets = Object.entries(field).reduce((acc, [key, fieldValue]) => {
|
||||
if (!['fields', 'fields'].includes(key)) {
|
||||
acc[key] = fieldValue;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, unknown>) as RenderedField;
|
||||
|
||||
if ('fields' in field && field.fields) {
|
||||
fieldWithWidgets = {
|
||||
...fieldWithWidgets,
|
||||
fields: getNestedWidgets(
|
||||
config,
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
field.fields,
|
||||
value as EntryData | EntryData[],
|
||||
),
|
||||
};
|
||||
} else if ('types' in field && field.types) {
|
||||
fieldWithWidgets = {
|
||||
...fieldWithWidgets,
|
||||
fields: getTypedNestedWidgets(
|
||||
config,
|
||||
collection,
|
||||
field,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
value as EntryData[],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const labelledWidgets = ['string', 'text', 'number'];
|
||||
const inferedField = Object.entries(inferedFields)
|
||||
.filter(([key]) => {
|
||||
const fieldToMatch = selectField(collection, key);
|
||||
return fieldToMatch === fieldWithWidgets;
|
||||
})
|
||||
.map(([, value]) => value)[0];
|
||||
|
||||
let renderedValue: ValueOrNestedValue | ReactNode = value;
|
||||
if (inferedField) {
|
||||
renderedValue = inferedField.defaultPreview(String(value));
|
||||
} else if (
|
||||
value &&
|
||||
fieldWithWidgets.widget &&
|
||||
labelledWidgets.indexOf(fieldWithWidgets.widget) !== -1 &&
|
||||
value.toString().length < 50
|
||||
) {
|
||||
renderedValue = (
|
||||
<div key={field.name}>
|
||||
<>
|
||||
<strong>{field.label ?? field.name}:</strong> {value}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderedValue
|
||||
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset, idx)
|
||||
: null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isJsxElement(value: any): value is JSX.Element {
|
||||
return isValidElement(value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isReactFragment(value: any): value is ReactFragment {
|
||||
if (value.type) {
|
||||
return value.type === Fragment;
|
||||
}
|
||||
|
||||
return value === Fragment;
|
||||
}
|
||||
|
||||
function getWidget(
|
||||
config: Config,
|
||||
field: RenderedField<Field>,
|
||||
collection: Collection,
|
||||
value: ValueOrNestedValue | ReactNode,
|
||||
entry: Entry,
|
||||
getAsset: GetAssetFunction,
|
||||
idx: number | null = null,
|
||||
) {
|
||||
if (!field.widget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const widget = resolveWidget(field.widget);
|
||||
const key = idx ? field.name + '_' + idx : field.name;
|
||||
|
||||
if (field.widget === 'hidden' || !widget.preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use an HOC to provide conditional updates for all previews.
|
||||
*/
|
||||
return !widget.preview ? null : (
|
||||
<PreviewHOC
|
||||
previewComponent={widget.preview as WidgetPreviewComponent}
|
||||
key={key}
|
||||
field={field as RenderedField}
|
||||
getAsset={getAsset}
|
||||
config={config}
|
||||
collection={collection}
|
||||
value={
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
field.name in value &&
|
||||
!isJsxElement(value) &&
|
||||
!isReactFragment(value)
|
||||
? (value as Record<string, unknown>)[field.name]
|
||||
: value
|
||||
}
|
||||
entry={entry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use getWidgetFor as a mapping function for recursive widget retrieval
|
||||
*/
|
||||
function widgetsForNestedFields(
|
||||
config: Config,
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[],
|
||||
values: EntryData,
|
||||
idx: number | null = null,
|
||||
) {
|
||||
return widgetFields
|
||||
.map(field =>
|
||||
getWidgetFor(
|
||||
config,
|
||||
collection,
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
values,
|
||||
idx,
|
||||
),
|
||||
)
|
||||
.filter(widget => Boolean(widget)) as JSX.Element[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves widgets for nested fields (children of object/list fields)
|
||||
*/
|
||||
function getTypedNestedWidgets(
|
||||
config: Config,
|
||||
collection: Collection,
|
||||
field: ListField,
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
values: EntryData[],
|
||||
) {
|
||||
return values
|
||||
?.flatMap((value, index) => {
|
||||
const itemType = getTypedFieldForValue(field, value ?? {}, index);
|
||||
if (!itemType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return widgetsForNestedFields(
|
||||
config,
|
||||
collection,
|
||||
itemType.fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
itemType.fields,
|
||||
value,
|
||||
index,
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves widgets for nested fields (children of object/list fields)
|
||||
*/
|
||||
function getNestedWidgets(
|
||||
config: Config,
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[],
|
||||
values: EntryData | EntryData[],
|
||||
) {
|
||||
// Fields nested within a list field will be paired with a List of value Maps.
|
||||
if (Array.isArray(values)) {
|
||||
return values.flatMap(value =>
|
||||
widgetsForNestedFields(
|
||||
config,
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fields nested within an object field will be paired with a single Record of values.
|
||||
return widgetsForNestedFields(
|
||||
config,
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
||||
const { entry, collection, config, fields, previewInFrame, getAsset, t } = props;
|
||||
|
||||
const inferedFields = useInferedFields(collection);
|
||||
|
||||
const handleGetAsset = useCallback(
|
||||
(path: string, field?: Field) => {
|
||||
return getAsset(collection, entry, path, field);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collection],
|
||||
);
|
||||
|
||||
const widgetFor = useCallback(
|
||||
(name: string) => {
|
||||
if (!config.config) {
|
||||
return null;
|
||||
}
|
||||
return getWidgetFor(
|
||||
config.config,
|
||||
collection,
|
||||
name,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
handleGetAsset,
|
||||
);
|
||||
},
|
||||
[collection, config, entry, fields, handleGetAsset, inferedFields],
|
||||
);
|
||||
|
||||
/**
|
||||
* This function exists entirely to expose nested widgets for object and list
|
||||
* fields to custom preview templates.
|
||||
*/
|
||||
const widgetsFor = useCallback(
|
||||
(name: string) => {
|
||||
const cmsConfig = config.config;
|
||||
if (!cmsConfig) {
|
||||
return {
|
||||
data: null,
|
||||
widgets: {},
|
||||
};
|
||||
}
|
||||
|
||||
const field = fields.find(f => f.name === name);
|
||||
if (!field || !('fields' in field)) {
|
||||
return {
|
||||
data: null,
|
||||
widgets: {},
|
||||
};
|
||||
}
|
||||
|
||||
const value = entry.data?.[field.name];
|
||||
const nestedFields = field && 'fields' in field ? field.fields ?? [] : [];
|
||||
|
||||
if (field.widget === 'list' || Array.isArray(value)) {
|
||||
let finalValue: ObjectValue[];
|
||||
if (!value || typeof value !== 'object') {
|
||||
finalValue = [];
|
||||
} else if (!Array.isArray(value)) {
|
||||
finalValue = [value];
|
||||
} else {
|
||||
finalValue = value as ObjectValue[];
|
||||
}
|
||||
|
||||
return finalValue
|
||||
.filter((val: unknown) => typeof val === 'object')
|
||||
.map((val: ObjectValue) => {
|
||||
const widgets = nestedFields.reduce((acc, field, index) => {
|
||||
acc[field.name] = (
|
||||
<div key={index}>
|
||||
{getWidgetFor(
|
||||
cmsConfig,
|
||||
collection,
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
handleGetAsset,
|
||||
nestedFields,
|
||||
val,
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return acc;
|
||||
}, {} as Record<string, ReactNode>);
|
||||
return { data: val, widgets };
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return {
|
||||
data: {},
|
||||
widgets: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: value,
|
||||
widgets: nestedFields.reduce((acc, field, index) => {
|
||||
acc[field.name] = (
|
||||
<div key={index}>
|
||||
{getWidgetFor(
|
||||
cmsConfig,
|
||||
collection,
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
handleGetAsset,
|
||||
nestedFields,
|
||||
value,
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return acc;
|
||||
}, {} as Record<string, ReactNode>),
|
||||
};
|
||||
},
|
||||
[collection, config.config, entry, fields, handleGetAsset, inferedFields],
|
||||
);
|
||||
|
||||
const previewStyles = useMemo(
|
||||
() => [
|
||||
...getPreviewStyles().map((style, i) => {
|
||||
if (style.raw) {
|
||||
return <style key={i}>{style.value}</style>;
|
||||
}
|
||||
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
|
||||
}),
|
||||
<style key="global">{FrameGlobalStyles}</style>,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const previewComponent = useMemo(
|
||||
() => getPreviewTemplate(selectTemplateName(collection, entry.slug)) ?? EditorPreview,
|
||||
[collection, entry.slug],
|
||||
);
|
||||
|
||||
const initialFrameContent = useMemo(
|
||||
() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank"/>
|
||||
</head>
|
||||
<body><div></div></body>
|
||||
</html>
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const element = useMemo(() => document.getElementById('cms-root'), []);
|
||||
|
||||
const previewProps = useMemo(
|
||||
() =>
|
||||
({
|
||||
...props,
|
||||
getAsset: handleGetAsset,
|
||||
widgetFor,
|
||||
widgetsFor,
|
||||
} as Omit<TemplatePreviewProps, 'document' | 'window'>),
|
||||
[handleGetAsset, props, widgetFor, widgetsFor],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<StyledPreviewContent className="preview-content">
|
||||
{!entry || !entry.data ? null : (
|
||||
<ErrorBoundary config={config}>
|
||||
{previewInFrame ? (
|
||||
<PreviewPaneFrame
|
||||
key="preview-frame"
|
||||
id="preview-pane"
|
||||
head={previewStyles}
|
||||
initialContent={initialFrameContent}
|
||||
>
|
||||
{!collection ? (
|
||||
t('collection.notFound')
|
||||
) : (
|
||||
<FrameContextConsumer>
|
||||
{({ document, window }) => {
|
||||
return (
|
||||
<ScrollSyncPane
|
||||
key="preview-frame-scroll-sync"
|
||||
attachTo={
|
||||
(document?.scrollingElement ?? undefined) as HTMLElement | undefined
|
||||
}
|
||||
>
|
||||
<EditorPreviewContent
|
||||
key="preview-frame-content"
|
||||
previewComponent={previewComponent}
|
||||
previewProps={{ ...previewProps, document, window }}
|
||||
/>
|
||||
</ScrollSyncPane>
|
||||
);
|
||||
}}
|
||||
</FrameContextConsumer>
|
||||
)}
|
||||
</PreviewPaneFrame>
|
||||
) : (
|
||||
<ScrollSyncPane key="preview-wrapper-scroll-sync">
|
||||
<PreviewPaneWrapper key="preview-wrapper" id="preview-pane">
|
||||
{!collection ? (
|
||||
t('collection.notFound')
|
||||
) : (
|
||||
<>
|
||||
{previewStyles}
|
||||
<EditorPreviewContent
|
||||
key="preview-wrapper-content"
|
||||
previewComponent={previewComponent}
|
||||
previewProps={{ ...previewProps, document, window }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PreviewPaneWrapper>
|
||||
</ScrollSyncPane>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</StyledPreviewContent>,
|
||||
element,
|
||||
'preview-content',
|
||||
);
|
||||
}, [
|
||||
collection,
|
||||
config,
|
||||
element,
|
||||
entry,
|
||||
initialFrameContent,
|
||||
previewComponent,
|
||||
previewInFrame,
|
||||
previewProps,
|
||||
previewStyles,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
||||
export interface EditorPreviewPaneOwnProps {
|
||||
collection: Collection;
|
||||
fields: Field[];
|
||||
entry: Entry;
|
||||
previewInFrame: boolean;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EditorPreviewPaneOwnProps) {
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
return { ...ownProps, isLoadingAsset, config: state.config };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorPreviewPaneProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(PreviewPane) as ComponentType<EditorPreviewPaneProps>);
|
@ -0,0 +1,21 @@
|
||||
import { cloneElement, createElement, isValidElement } from 'react';
|
||||
|
||||
import type { WidgetPreviewComponent, WidgetPreviewProps } from '@staticcms/core/interface';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface PreviewHOCProps extends Omit<WidgetPreviewProps, 'widgetFor'> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
previewComponent: WidgetPreviewComponent;
|
||||
}
|
||||
|
||||
const PreviewHOC = ({ previewComponent, ...props }: PreviewHOCProps) => {
|
||||
if (!previewComponent) {
|
||||
return null;
|
||||
} else if (isValidElement(previewComponent)) {
|
||||
return cloneElement(previewComponent, props);
|
||||
} else {
|
||||
return createElement(previewComponent, props);
|
||||
}
|
||||
};
|
||||
|
||||
export default PreviewHOC;
|
40
packages/core/src/components/Editor/EditorRoute.tsx
Normal file
40
packages/core/src/components/Editor/EditorRoute.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import Editor from './Editor';
|
||||
|
||||
import type { Collections } from '@staticcms/core/interface';
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const first = Object.values(collections).filter(collection => collection.hide !== true)[0];
|
||||
if (first) {
|
||||
return `/collections/${first.name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorRouteProps {
|
||||
newRecord?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => {
|
||||
const { name, slug } = useParams();
|
||||
const shouldRedirect = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return !collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (shouldRedirect || !name || (!newRecord && !slug)) {
|
||||
return <Navigate to={defaultPath} />;
|
||||
}
|
||||
|
||||
return <Editor name={name} slug={slug} newRecord={newRecord} />;
|
||||
};
|
||||
|
||||
export default EditorRoute;
|
305
packages/core/src/components/Editor/EditorToolbar.tsx
Normal file
305
packages/core/src/components/Editor/EditorToolbar.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { green } from '@mui/material/colors';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { colors, components, zIndex } from '@staticcms/core/components/UI/styles';
|
||||
import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util';
|
||||
import { SettingsDropdown } from '../UI';
|
||||
import NavLink from '../UI/NavLink';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
TranslatedProps,
|
||||
User,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
z-index: ${zIndex.zIndex100};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledToolbarSectionBackLink = styled('div')`
|
||||
display: flex;
|
||||
margin: -32px -24px;
|
||||
height: 64px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledToolbarSectionMain = styled('div')`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
margin-left: 24px;
|
||||
`;
|
||||
|
||||
const StyledBackCollection = styled('div')`
|
||||
color: ${colors.textLead};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StyledBackStatus = styled('div')`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const StyledBackStatusUnchanged = styled(StyledBackStatus)`
|
||||
${components.textBadgeSuccess};
|
||||
`;
|
||||
|
||||
const StyledBackStatusChanged = styled(StyledBackStatus)`
|
||||
${components.textBadgeDanger};
|
||||
`;
|
||||
|
||||
const StyledButtonWrapper = styled('div')`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export interface EditorToolbarProps {
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
onPersist: (opts?: EditorPersistOptions) => Promise<void>;
|
||||
onPersistAndNew: () => Promise<void>;
|
||||
onPersistAndDuplicate: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
showDelete: boolean;
|
||||
onDuplicate: () => void;
|
||||
user: User;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
collection: Collection;
|
||||
isNewEntry: boolean;
|
||||
isModification?: boolean;
|
||||
onLogoutClick: () => void;
|
||||
editorBackLink: string;
|
||||
}
|
||||
|
||||
const EditorToolbar = ({
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
collection,
|
||||
onLogoutClick,
|
||||
onDuplicate,
|
||||
isPersisting,
|
||||
onPersist,
|
||||
onPersistAndDuplicate,
|
||||
onPersistAndNew,
|
||||
isNewEntry,
|
||||
showDelete,
|
||||
onDelete,
|
||||
t,
|
||||
editorBackLink,
|
||||
}: TranslatedProps<EditorToolbarProps>) => {
|
||||
const canCreate = useMemo(
|
||||
() => ('folder' in collection && collection.create) ?? false,
|
||||
[collection],
|
||||
);
|
||||
const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]);
|
||||
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleMenuOptionClick = useCallback(
|
||||
(callback: () => Promise<void> | void) => () => {
|
||||
handleClose();
|
||||
callback();
|
||||
},
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
const handlePersistAndNew = useMemo(
|
||||
() => handleMenuOptionClick(onPersistAndNew),
|
||||
[handleMenuOptionClick, onPersistAndNew],
|
||||
);
|
||||
const handlePersistAndDuplicate = useMemo(
|
||||
() => handleMenuOptionClick(onPersistAndDuplicate),
|
||||
[handleMenuOptionClick, onPersistAndDuplicate],
|
||||
);
|
||||
const handleDuplicate = useMemo(
|
||||
() => handleMenuOptionClick(onDuplicate),
|
||||
[handleMenuOptionClick, onDuplicate],
|
||||
);
|
||||
const handlePersist = useMemo(
|
||||
() => handleMenuOptionClick(() => onPersist()),
|
||||
[handleMenuOptionClick, onPersist],
|
||||
);
|
||||
const handleDelete = useMemo(
|
||||
() => handleMenuOptionClick(onDelete),
|
||||
[handleMenuOptionClick, onDelete],
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
|
||||
if (!isPublished) {
|
||||
items.push(
|
||||
<MenuItem key="publishNow" onClick={handlePersist}>
|
||||
{t('editor.editorToolbar.publishNow')}
|
||||
</MenuItem>,
|
||||
);
|
||||
|
||||
if (canCreate) {
|
||||
items.push(
|
||||
<MenuItem key="publishAndCreateNew" onClick={handlePersistAndNew}>
|
||||
{t('editor.editorToolbar.publishAndCreateNew')}
|
||||
</MenuItem>,
|
||||
<MenuItem key="publishAndDuplicate" onClick={handlePersistAndDuplicate}>
|
||||
{t('editor.editorToolbar.publishAndDuplicate')}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (canCreate) {
|
||||
items.push(
|
||||
<MenuItem key="duplicate" onClick={handleDuplicate}>
|
||||
{t('editor.editorToolbar.duplicate')}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
canCreate,
|
||||
handleDuplicate,
|
||||
handlePersist,
|
||||
handlePersistAndDuplicate,
|
||||
handlePersistAndNew,
|
||||
isPublished,
|
||||
t,
|
||||
]);
|
||||
|
||||
const controls = useMemo(
|
||||
() => (
|
||||
<StyledToolbarSectionMain>
|
||||
<div>
|
||||
<StyledButtonWrapper>
|
||||
<Button
|
||||
id="existing-published-button"
|
||||
aria-controls={open ? 'existing-published-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
color={isPublished ? 'success' : 'primary'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
disabled={menuItems.length === 0 || isPersisting}
|
||||
>
|
||||
{isPublished
|
||||
? t('editor.editorToolbar.published')
|
||||
: isPersisting
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</Button>
|
||||
{isPersisting ? (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
sx={{
|
||||
color: green[500],
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: '-12px',
|
||||
marginLeft: '-12px',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</StyledButtonWrapper>
|
||||
<Menu
|
||||
id="existing-published-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'existing-published-button',
|
||||
}}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</div>
|
||||
{showDelete && canDelete ? (
|
||||
<Button variant="outlined" color="error" key="delete-button" onClick={handleDelete}>
|
||||
{t('editor.editorToolbar.deleteEntry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</StyledToolbarSectionMain>
|
||||
),
|
||||
[
|
||||
anchorEl,
|
||||
canDelete,
|
||||
handleClick,
|
||||
handleClose,
|
||||
handleDelete,
|
||||
isPersisting,
|
||||
isPublished,
|
||||
menuItems,
|
||||
open,
|
||||
showDelete,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="relative">
|
||||
<StyledToolbar>
|
||||
<StyledToolbarSectionBackLink>
|
||||
<Button component={NavLink} to={editorBackLink}>
|
||||
<ArrowBackIcon />
|
||||
<div>
|
||||
<StyledBackCollection>
|
||||
{t('editor.editorToolbar.backCollection', {
|
||||
collectionLabel: collection.label,
|
||||
})}
|
||||
</StyledBackCollection>
|
||||
{hasChanged ? (
|
||||
<StyledBackStatusChanged key="back-changed">
|
||||
{t('editor.editorToolbar.unsavedChanges')}
|
||||
</StyledBackStatusChanged>
|
||||
) : (
|
||||
<StyledBackStatusUnchanged key="back-unchanged">
|
||||
{t('editor.editorToolbar.changesSaved')}
|
||||
</StyledBackStatusUnchanged>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</StyledToolbarSectionBackLink>
|
||||
{controls}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(EditorToolbar);
|
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { WidgetControlProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const UnknownControl = ({ field, t }: TranslatedProps<WidgetControlProps<unknown>>) => {
|
||||
return <div>{t('editor.editorWidgets.unknownControl.noControl', { widget: field.widget })}</div>;
|
||||
};
|
||||
|
||||
export default translate()(UnknownControl);
|
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { WidgetPreviewProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const UnknownPreview = ({ field, t }: TranslatedProps<WidgetPreviewProps>) => {
|
||||
return (
|
||||
<div className="nc-widgetPreview">
|
||||
{t('editor.editorWidgets.unknownPreview.noPreview', { widget: field.widget })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(UnknownPreview);
|
5
packages/core/src/components/EditorWidgets/index.ts
Normal file
5
packages/core/src/components/EditorWidgets/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { registerWidget } from '@staticcms/core/lib/registry';
|
||||
import UnknownControl from './Unknown/UnknownControl';
|
||||
import UnknownPreview from './Unknown/UnknownPreview';
|
||||
|
||||
registerWidget('unknown', UnknownControl, UnknownPreview);
|
24
packages/core/src/components/MediaLibrary/EmptyMessage.tsx
Normal file
24
packages/core/src/components/MediaLibrary/EmptyMessage.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
const EmptyMessageContainer = styled('div')`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
interface EmptyMessageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const EmptyMessage = ({ content }: EmptyMessageProps) => {
|
||||
return (
|
||||
<EmptyMessageContainer>
|
||||
<h1>{content}</h1>
|
||||
</EmptyMessageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyMessage;
|
399
packages/core/src/components/MediaLibrary/MediaLibrary.tsx
Normal file
399
packages/core/src/components/MediaLibrary/MediaLibrary.tsx
Normal file
@ -0,0 +1,399 @@
|
||||
import fuzzy from 'fuzzy';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
closeMediaLibrary as closeMediaLibraryAction,
|
||||
deleteMedia as deleteMediaAction,
|
||||
insertMedia as insertMediaAction,
|
||||
loadMedia as loadMediaAction,
|
||||
loadMediaDisplayURL as loadMediaDisplayURLAction,
|
||||
persistMedia as persistMediaAction,
|
||||
} from '@staticcms/core/actions/mediaLibrary';
|
||||
import { fileExtension } from '@staticcms/core/lib/util';
|
||||
import { selectMediaFiles } from '@staticcms/core/reducers/mediaLibrary';
|
||||
import alert from '../UI/Alert';
|
||||
import confirm from '../UI/Confirm';
|
||||
import MediaLibraryModal from './MediaLibraryModal';
|
||||
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
/**
|
||||
* Extensions used to determine which files to show when the media library is
|
||||
* accessed from an image insertion field.
|
||||
*/
|
||||
const IMAGE_EXTENSIONS_VIEWABLE = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'gif',
|
||||
'png',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'svg',
|
||||
'avif',
|
||||
];
|
||||
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
||||
|
||||
const MediaLibrary = ({
|
||||
isVisible,
|
||||
loadMediaDisplayURL,
|
||||
displayURLs,
|
||||
canInsert,
|
||||
files = [],
|
||||
dynamicSearch,
|
||||
dynamicSearchActive,
|
||||
forImage,
|
||||
isLoading,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
config,
|
||||
loadMedia,
|
||||
dynamicSearchQuery,
|
||||
page,
|
||||
persistMedia,
|
||||
deleteMedia,
|
||||
insertMedia,
|
||||
closeMediaLibrary,
|
||||
field,
|
||||
t,
|
||||
}: TranslatedProps<MediaLibraryProps>) => {
|
||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
||||
const [query, setQuery] = useState<string | undefined>(undefined);
|
||||
|
||||
const [prevIsVisible, setPrevIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMedia();
|
||||
}, [loadMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevIsVisible && isVisible) {
|
||||
setSelectedFile(null);
|
||||
setQuery('');
|
||||
}
|
||||
|
||||
setPrevIsVisible(isVisible);
|
||||
}, [isVisible, prevIsVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevIsVisible && isVisible) {
|
||||
loadMedia();
|
||||
}
|
||||
}, [isVisible, loadMedia, prevIsVisible]);
|
||||
|
||||
const loadDisplayURL = useCallback(
|
||||
(file: MediaFile) => {
|
||||
loadMediaDisplayURL(file);
|
||||
},
|
||||
[loadMediaDisplayURL],
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter an array of file data to include only images.
|
||||
*/
|
||||
const filterImages = useCallback((files: MediaFile[]) => {
|
||||
return files.filter(file => {
|
||||
const ext = fileExtension(file.name).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Transform file data for table display.
|
||||
*/
|
||||
const toTableData = useCallback((files: MediaFile[]) => {
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
type: ext.toUpperCase(),
|
||||
size,
|
||||
queryOrder,
|
||||
displayURL,
|
||||
draft,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the sort order for use with `lodash.orderBy`, and always add the
|
||||
* `queryOrder` sort as the lowest priority sort order.
|
||||
*/
|
||||
// TODO Sorting?
|
||||
// const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
|
||||
// const directions = map(sortFields, 'direction').concat('asc');
|
||||
// return orderBy(tableData, fieldNames, directions);
|
||||
return tableData;
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeMediaLibrary();
|
||||
}, [closeMediaLibrary]);
|
||||
|
||||
/**
|
||||
* Toggle asset selection on click.
|
||||
*/
|
||||
const handleAssetClick = useCallback(
|
||||
(asset: MediaFile) => {
|
||||
if (selectedFile?.key !== asset.key) {
|
||||
setSelectedFile(asset);
|
||||
}
|
||||
},
|
||||
[selectedFile?.key],
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>();
|
||||
const scrollToTop = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a file.
|
||||
*/
|
||||
const handlePersist = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => {
|
||||
/**
|
||||
* Stop the browser from automatically handling the file input click, and
|
||||
* get the file for upload, and retain the synthetic event for access after
|
||||
* the asynchronous persist operation.
|
||||
*/
|
||||
|
||||
let fileList: FileList | null;
|
||||
if ('dataTransfer' in event) {
|
||||
fileList = event.dataTransfer?.files ?? null;
|
||||
} else {
|
||||
event.persist();
|
||||
fileList = event.target.files;
|
||||
}
|
||||
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const files = [...Array.from(fileList)];
|
||||
const file = files[0];
|
||||
const maxFileSize = typeof config.max_file_size === 'number' ? config.max_file_size : 512000;
|
||||
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
alert({
|
||||
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
|
||||
body: {
|
||||
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
|
||||
options: {
|
||||
size: Math.floor(maxFileSize / 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await persistMedia(file, { field });
|
||||
|
||||
setSelectedFile(files[0] as unknown as MediaFile);
|
||||
|
||||
scrollToTop();
|
||||
}
|
||||
|
||||
if (!('dataTransfer' in event)) {
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
[config.max_file_size, field, persistMedia],
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores the public path of the file in the application store, where the
|
||||
* editor field that launched the media library can retrieve it.
|
||||
*/
|
||||
const handleInsert = useCallback(() => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { path } = selectedFile;
|
||||
insertMedia(path, field);
|
||||
handleClose();
|
||||
}, [field, handleClose, insertMedia, selectedFile]);
|
||||
|
||||
/**
|
||||
* Removes the selected file from the backend.
|
||||
*/
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'mediaLibrary.mediaLibrary.onDeleteTitle',
|
||||
body: 'mediaLibrary.mediaLibrary.onDelete',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const file = files.find(file => selectedFile?.key === file.key);
|
||||
if (file) {
|
||||
deleteMedia(file).then(() => {
|
||||
setSelectedFile(null);
|
||||
});
|
||||
}
|
||||
}, [deleteMedia, files, selectedFile?.key]);
|
||||
|
||||
/**
|
||||
* Downloads the selected file.
|
||||
*/
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = displayURLs[selectedFile.id]?.url ?? selectedFile.url;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = selectedFile.name;
|
||||
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute('href', url);
|
||||
element.setAttribute('download', filename);
|
||||
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
setSelectedFile(null);
|
||||
}, [displayURLs, selectedFile]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
loadMedia({ query: dynamicSearchQuery, page: (page ?? 0) + 1 });
|
||||
}, [dynamicSearchQuery, loadMedia, page]);
|
||||
|
||||
/**
|
||||
* Executes media library search for implementations that support dynamic
|
||||
* search via request. For these implementations, the Enter key must be
|
||||
* pressed to execute search. If assets are being stored directly through
|
||||
* the GitHub backend, search is in-memory and occurs as the query is typed,
|
||||
* so this handler has no impact.
|
||||
*/
|
||||
const handleSearchKeyDown = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && dynamicSearch) {
|
||||
await loadMedia({ query });
|
||||
scrollToTop();
|
||||
}
|
||||
},
|
||||
[dynamicSearch, loadMedia, query],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates query state as the user types in the search field.
|
||||
*/
|
||||
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Filters files that do not match the query. Not used for dynamic search.
|
||||
*/
|
||||
const queryFilter = useCallback((query: string, files: { name: string }[]) => {
|
||||
/**
|
||||
* Because file names don't have spaces, typing a space eliminates all
|
||||
* potential matches, so we strip them all out internally before running the
|
||||
* query.
|
||||
*/
|
||||
const strippedQuery = query.replace(/ /g, '');
|
||||
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
|
||||
const matchFiles = matches.map((match, queryIndex) => {
|
||||
const file = files[match.index];
|
||||
return { ...file, queryIndex };
|
||||
});
|
||||
return matchFiles;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MediaLibraryModal
|
||||
isVisible={isVisible}
|
||||
canInsert={canInsert}
|
||||
files={files}
|
||||
dynamicSearch={dynamicSearch}
|
||||
dynamicSearchActive={dynamicSearchActive}
|
||||
forImage={forImage}
|
||||
isLoading={isLoading}
|
||||
isPersisting={isPersisting}
|
||||
isDeleting={isDeleting}
|
||||
hasNextPage={hasNextPage}
|
||||
isPaginating={isPaginating}
|
||||
query={query}
|
||||
selectedFile={selectedFile}
|
||||
handleFilter={filterImages}
|
||||
handleQuery={queryFilter}
|
||||
toTableData={toTableData}
|
||||
handleClose={handleClose}
|
||||
handleSearchChange={handleSearchChange}
|
||||
handleSearchKeyDown={handleSearchKeyDown}
|
||||
handlePersist={handlePersist}
|
||||
handleDelete={handleDelete}
|
||||
handleInsert={handleInsert}
|
||||
handleDownload={handleDownload}
|
||||
setScrollContainerRef={scrollContainerRef}
|
||||
handleAssetClick={handleAssetClick}
|
||||
handleLoadMore={handleLoadMore}
|
||||
displayURLs={displayURLs}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { mediaLibrary } = state;
|
||||
const field = mediaLibrary.field;
|
||||
const mediaLibraryProps = {
|
||||
isVisible: mediaLibrary.isVisible,
|
||||
canInsert: mediaLibrary.canInsert,
|
||||
files: selectMediaFiles(state, field),
|
||||
displayURLs: mediaLibrary.displayURLs,
|
||||
dynamicSearch: mediaLibrary.dynamicSearch,
|
||||
dynamicSearchActive: mediaLibrary.dynamicSearchActive,
|
||||
dynamicSearchQuery: mediaLibrary.dynamicSearchQuery,
|
||||
forImage: mediaLibrary.forImage,
|
||||
isLoading: mediaLibrary.isLoading,
|
||||
isPersisting: mediaLibrary.isPersisting,
|
||||
isDeleting: mediaLibrary.isDeleting,
|
||||
config: mediaLibrary.config,
|
||||
page: mediaLibrary.page,
|
||||
hasNextPage: mediaLibrary.hasNextPage,
|
||||
isPaginating: mediaLibrary.isPaginating,
|
||||
field,
|
||||
};
|
||||
return { ...mediaLibraryProps };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadMedia: loadMediaAction,
|
||||
persistMedia: persistMediaAction,
|
||||
deleteMedia: deleteMediaAction,
|
||||
insertMedia: insertMediaAction,
|
||||
loadMediaDisplayURL: loadMediaDisplayURLAction,
|
||||
closeMediaLibrary: closeMediaLibraryAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type MediaLibraryProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(MediaLibrary));
|
@ -0,0 +1,74 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isAbsolutePath } from '@staticcms/core/lib/util';
|
||||
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
export interface CopyToClipBoardButtonProps {
|
||||
disabled: boolean;
|
||||
draft?: boolean;
|
||||
path?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const CopyToClipBoardButton = ({
|
||||
disabled,
|
||||
draft,
|
||||
path,
|
||||
name,
|
||||
t,
|
||||
}: TranslatedProps<CopyToClipBoardButtonProps>) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (alive) {
|
||||
setCopied(false);
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!path || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(isAbsolutePath(path) || !draft ? path : name);
|
||||
setCopied(true);
|
||||
}, [draft, name, path]);
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (copied) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copied');
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copy');
|
||||
}
|
||||
|
||||
if (isAbsolutePath(path)) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copyUrl');
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copyName');
|
||||
}
|
||||
|
||||
return t('mediaLibrary.mediaLibraryCard.copyPath');
|
||||
}, [copied, draft, path, t]);
|
||||
|
||||
return (
|
||||
<Button color="inherit" variant="contained" onClick={handleCopy} disabled={disabled}>
|
||||
{getTitle()}
|
||||
</Button>
|
||||
);
|
||||
};
|
137
packages/core/src/components/MediaLibrary/MediaLibraryCard.tsx
Normal file
137
packages/core/src/components/MediaLibrary/MediaLibraryCard.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { borders, colors, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
|
||||
import type { MediaLibraryDisplayURL } from '@staticcms/core/reducers/mediaLibrary';
|
||||
|
||||
const IMAGE_HEIGHT = 160;
|
||||
|
||||
interface CardProps {
|
||||
$width: string;
|
||||
$height: string;
|
||||
$margin: string;
|
||||
$isSelected: boolean;
|
||||
}
|
||||
|
||||
const Card = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<CardProps>(
|
||||
({ $width, $height, $margin, $isSelected }) => `
|
||||
width: ${$width};
|
||||
height: ${$height};
|
||||
margin: ${$margin};
|
||||
border: ${borders.textField};
|
||||
${$isSelected ? `border-color: ${colors.active};` : ''}
|
||||
border-radius: ${lengths.borderRadius};
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const CardImageWrapper = styled('div')`
|
||||
height: ${IMAGE_HEIGHT + 2}px;
|
||||
${effects.checkerboard};
|
||||
${shadows.inset};
|
||||
border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const CardImage = styled('img')`
|
||||
width: 100%;
|
||||
height: ${IMAGE_HEIGHT}px;
|
||||
object-fit: contain;
|
||||
border-radius: 2px 2px 0 0;
|
||||
`;
|
||||
|
||||
const CardFileIcon = styled('div')`
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px 2px 0 0;
|
||||
padding: 1em;
|
||||
font-size: 3em;
|
||||
`;
|
||||
|
||||
const CardText = styled('p')`
|
||||
color: ${colors.text};
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3;
|
||||
`;
|
||||
|
||||
const DraftText = styled('p')`
|
||||
color: ${colors.mediaDraftText};
|
||||
background-color: ${colors.mediaDraftBackground};
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0;
|
||||
`;
|
||||
|
||||
interface MediaLibraryCardProps {
|
||||
isSelected?: boolean;
|
||||
displayURL: MediaLibraryDisplayURL;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
draftText: string;
|
||||
width: string;
|
||||
height: string;
|
||||
margin: string;
|
||||
type?: string;
|
||||
isViewableImage: boolean;
|
||||
loadDisplayURL: () => void;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
const MediaLibraryCard = ({
|
||||
isSelected = false,
|
||||
displayURL,
|
||||
text,
|
||||
onClick,
|
||||
draftText,
|
||||
width,
|
||||
height,
|
||||
margin,
|
||||
type,
|
||||
isViewableImage,
|
||||
isDraft,
|
||||
loadDisplayURL,
|
||||
}: MediaLibraryCardProps) => {
|
||||
const url = useMemo(() => displayURL.url, [displayURL.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!displayURL.url) {
|
||||
loadDisplayURL();
|
||||
}
|
||||
}, [displayURL.url, loadDisplayURL]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
$isSelected={isSelected}
|
||||
$width={width}
|
||||
$height={height}
|
||||
$margin={margin}
|
||||
onClick={onClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<CardImageWrapper>
|
||||
{isDraft ? <DraftText data-testid="draft-text">{draftText}</DraftText> : null}
|
||||
{url && isViewableImage ? (
|
||||
<CardImage src={url} />
|
||||
) : (
|
||||
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
|
||||
)}
|
||||
</CardImageWrapper>
|
||||
<CardText>{text}</CardText>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibraryCard;
|
@ -0,0 +1,220 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
|
||||
import MediaLibraryCard from './MediaLibraryCard';
|
||||
|
||||
import type { GridChildComponentProps } from 'react-window';
|
||||
import type { MediaFile } from '@staticcms/core/interface';
|
||||
import type {
|
||||
MediaLibraryDisplayURL,
|
||||
MediaLibraryState,
|
||||
} from '@staticcms/core/reducers/mediaLibrary';
|
||||
|
||||
export interface MediaLibraryCardItem {
|
||||
displayURL?: MediaLibraryDisplayURL;
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
draft: boolean;
|
||||
isViewableImage?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MediaLibraryCardGridProps {
|
||||
setScrollContainerRef: () => void;
|
||||
mediaItems: MediaFile[];
|
||||
isSelectedFile: (file: MediaFile) => boolean;
|
||||
onAssetClick: (asset: MediaFile) => void;
|
||||
canLoadMore?: boolean;
|
||||
onLoadMore: () => void;
|
||||
isPaginating?: boolean;
|
||||
paginatingMessage?: string;
|
||||
cardDraftText: string;
|
||||
cardWidth: string;
|
||||
cardHeight: string;
|
||||
cardMargin: string;
|
||||
loadDisplayURL: (asset: MediaFile) => void;
|
||||
displayURLs: MediaLibraryState['displayURLs'];
|
||||
}
|
||||
|
||||
export type CardGridItemData = MediaLibraryCardGridProps & {
|
||||
columnCount: number;
|
||||
gutter: number;
|
||||
};
|
||||
|
||||
const CardWrapper = ({
|
||||
rowIndex,
|
||||
columnIndex,
|
||||
style,
|
||||
data: {
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
columnCount,
|
||||
gutter,
|
||||
},
|
||||
}: GridChildComponentProps<CardGridItemData>) => {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
if (index >= mediaItems.length) {
|
||||
return null;
|
||||
}
|
||||
const file = mediaItems[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
left: typeof style.left === 'number' ? style.left ?? +gutter * columnIndex : style.left,
|
||||
top: typeof style.top === 'number' ? style.top + gutter : style.top,
|
||||
width: typeof style.width === 'number' ? style.width - gutter : style.width,
|
||||
height: typeof style.height === 'number' ? style.height - gutter : style.height,
|
||||
}}
|
||||
>
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={'0px'}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage ?? false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StyledCardGridContainerProps {
|
||||
$width?: number;
|
||||
$height?: number;
|
||||
}
|
||||
|
||||
const StyledCardGridContainer = styled('div')<StyledCardGridContainerProps>(
|
||||
({ $width, $height }) => `
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: ${$width ? `${$width}px` : '100%'};
|
||||
height: ${$height ? `${$height}px` : '100%'};ƒ
|
||||
`,
|
||||
);
|
||||
|
||||
const CardGrid = styled('div')`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
`;
|
||||
|
||||
const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
|
||||
const {
|
||||
cardWidth: inputCardWidth,
|
||||
cardHeight: inputCardHeight,
|
||||
cardMargin,
|
||||
mediaItems,
|
||||
setScrollContainerRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
const cardWidth = parseInt(inputCardWidth, 10);
|
||||
const cardHeight = parseInt(inputCardHeight, 10);
|
||||
const gutter = parseInt(cardMargin, 10);
|
||||
const columnWidth = cardWidth + gutter;
|
||||
const rowHeight = cardHeight + gutter;
|
||||
const columnCount = Math.floor(width / columnWidth);
|
||||
const rowCount = Math.ceil(mediaItems.length / columnCount);
|
||||
|
||||
return (
|
||||
<StyledCardGridContainer $width={width} $height={height} ref={setScrollContainerRef}>
|
||||
<Grid
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={
|
||||
{
|
||||
...props,
|
||||
gutter,
|
||||
columnCount,
|
||||
} as CardGridItemData
|
||||
}
|
||||
>
|
||||
{CardWrapper}
|
||||
</Grid>
|
||||
</StyledCardGridContainer>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
};
|
||||
|
||||
const PaginatedGrid = ({
|
||||
setScrollContainerRef,
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
cardMargin,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
isPaginating,
|
||||
paginatingMessage,
|
||||
}: MediaLibraryCardGridProps) => {
|
||||
return (
|
||||
<StyledCardGridContainer ref={setScrollContainerRef}>
|
||||
<CardGrid>
|
||||
{mediaItems.map(file => (
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={cardMargin}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage ?? false}
|
||||
/>
|
||||
))}
|
||||
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
|
||||
</CardGrid>
|
||||
{!isPaginating ? null : <h1>{paginatingMessage}</h1>}
|
||||
</StyledCardGridContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function MediaLibraryCardGrid(props: MediaLibraryCardGridProps) {
|
||||
const { canLoadMore, isPaginating } = props;
|
||||
if (canLoadMore || isPaginating) {
|
||||
return <PaginatedGrid {...props} />;
|
||||
}
|
||||
return <VirtualizedGrid {...props} />;
|
||||
}
|
||||
|
||||
export default MediaLibraryCardGrid;
|
200
packages/core/src/components/MediaLibrary/MediaLibraryModal.tsx
Normal file
200
packages/core/src/components/MediaLibrary/MediaLibraryModal.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import EmptyMessage from './EmptyMessage';
|
||||
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
||||
import MediaLibraryTop from './MediaLibraryTop';
|
||||
|
||||
import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { MediaLibraryState } from '@staticcms/core/reducers/mediaLibrary';
|
||||
|
||||
const StyledFab = styled(Fab)`
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: -20px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* TODO Responsive styling needs to be overhauled. Current setup requires specifying
|
||||
* widths per breakpoint.
|
||||
*/
|
||||
const cardWidth = `280px`;
|
||||
const cardHeight = `240px`;
|
||||
const cardMargin = `10px`;
|
||||
|
||||
/**
|
||||
* cardWidth + cardMargin * 2 = cardOutsideWidth
|
||||
* (not using calc because this will be nested in other calcs)
|
||||
*/
|
||||
const cardOutsideWidth = `300px`;
|
||||
|
||||
const StyledModal = styled(Dialog)`
|
||||
.MuiDialog-paper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
height: 80%;
|
||||
width: calc(${cardOutsideWidth} + 20px);
|
||||
max-width: calc(${cardOutsideWidth} + 20px);
|
||||
|
||||
@media (min-width: 800px) {
|
||||
width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1760px) {
|
||||
width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 2080px) {
|
||||
width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface MediaLibraryModalProps {
|
||||
isVisible?: boolean;
|
||||
canInsert?: boolean;
|
||||
files: MediaFile[];
|
||||
dynamicSearch?: boolean;
|
||||
dynamicSearchActive?: boolean;
|
||||
forImage?: boolean;
|
||||
isLoading?: boolean;
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
isPaginating?: boolean;
|
||||
query?: string;
|
||||
selectedFile?: MediaFile;
|
||||
handleFilter: (files: MediaFile[]) => MediaFile[];
|
||||
handleQuery: (query: string, files: MediaFile[]) => MediaFile[];
|
||||
toTableData: (files: MediaFile[]) => MediaFile[];
|
||||
handleClose: () => void;
|
||||
handleSearchChange: ChangeEventHandler<HTMLInputElement>;
|
||||
handleSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
handlePersist: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
|
||||
handleDelete: () => void;
|
||||
handleInsert: () => void;
|
||||
handleDownload: () => void;
|
||||
setScrollContainerRef: () => void;
|
||||
handleAssetClick: (asset: MediaFile) => void;
|
||||
handleLoadMore: () => void;
|
||||
loadDisplayURL: (file: MediaFile) => void;
|
||||
displayURLs: MediaLibraryState['displayURLs'];
|
||||
}
|
||||
|
||||
const MediaLibraryModal = ({
|
||||
isVisible = false,
|
||||
canInsert,
|
||||
files,
|
||||
dynamicSearch,
|
||||
dynamicSearchActive,
|
||||
forImage,
|
||||
isLoading,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
query,
|
||||
selectedFile,
|
||||
handleFilter,
|
||||
handleQuery,
|
||||
toTableData,
|
||||
handleClose,
|
||||
handleSearchChange,
|
||||
handleSearchKeyDown,
|
||||
handlePersist,
|
||||
handleDelete,
|
||||
handleInsert,
|
||||
handleDownload,
|
||||
setScrollContainerRef,
|
||||
handleAssetClick,
|
||||
handleLoadMore,
|
||||
loadDisplayURL,
|
||||
displayURLs,
|
||||
t,
|
||||
}: TranslatedProps<MediaLibraryModalProps>) => {
|
||||
const filteredFiles = forImage ? handleFilter(files) : files;
|
||||
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
|
||||
const tableData = toTableData(queriedFiles);
|
||||
const hasFiles = files && !!files.length;
|
||||
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
|
||||
const hasSearchResults = queriedFiles && !!queriedFiles.length;
|
||||
const hasMedia = hasSearchResults;
|
||||
const shouldShowEmptyMessage = !hasMedia;
|
||||
const emptyMessage =
|
||||
(isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) ||
|
||||
(dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) ||
|
||||
(!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) ||
|
||||
(!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) ||
|
||||
(!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults')) ||
|
||||
'';
|
||||
|
||||
const hasSelection = hasMedia && !isEmpty(selectedFile);
|
||||
|
||||
return (
|
||||
<StyledModal open={isVisible} onClose={handleClose}>
|
||||
<StyledFab color="default" aria-label="add" onClick={handleClose} size="small">
|
||||
<CloseIcon />
|
||||
</StyledFab>
|
||||
<MediaLibraryTop
|
||||
t={t}
|
||||
onClose={handleClose}
|
||||
forImage={forImage}
|
||||
onDownload={handleDownload}
|
||||
onUpload={handlePersist}
|
||||
query={query}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
searchDisabled={!dynamicSearchActive && !hasFilteredFiles}
|
||||
onDelete={handleDelete}
|
||||
canInsert={canInsert}
|
||||
onInsert={handleInsert}
|
||||
hasSelection={hasSelection}
|
||||
isPersisting={isPersisting}
|
||||
isDeleting={isDeleting}
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
<DialogContent>
|
||||
{!shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} />}
|
||||
<MediaLibraryCardGrid
|
||||
setScrollContainerRef={setScrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
isSelectedFile={file => selectedFile?.key === file.key}
|
||||
onAssetClick={handleAssetClick}
|
||||
canLoadMore={hasNextPage}
|
||||
onLoadMore={handleLoadMore}
|
||||
isPaginating={isPaginating}
|
||||
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
|
||||
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
|
||||
cardWidth={cardWidth}
|
||||
cardHeight={cardHeight}
|
||||
cardMargin={cardMargin}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
displayURLs={displayURLs}
|
||||
/>
|
||||
</DialogContent>
|
||||
</StyledModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(MediaLibraryModal);
|
@ -0,0 +1,43 @@
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React from 'react';
|
||||
|
||||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
|
||||
export interface MediaLibrarySearchProps {
|
||||
value?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MediaLibrarySearch = ({
|
||||
value = '',
|
||||
onChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: MediaLibrarySearchProps) => {
|
||||
return (
|
||||
<TextField
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibrarySearch;
|
165
packages/core/src/components/MediaLibrary/MediaLibraryTop.tsx
Normal file
165
packages/core/src/components/MediaLibrary/MediaLibraryTop.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React from 'react';
|
||||
|
||||
import { CopyToClipBoardButton } from './MediaLibraryButtons';
|
||||
import MediaLibrarySearch from './MediaLibrarySearch';
|
||||
import { buttons, shadows, zIndex } from '@staticcms/core/components/UI/styles';
|
||||
import FileUploadButton from '../UI/FileUploadButton';
|
||||
|
||||
import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const LibraryTop = styled('div')`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledButtonsContainer = styled('div')`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const StyledDialogTitle = styled(DialogTitle)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const UploadButton = styled(FileUploadButton)`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
display: inline-block;
|
||||
margin-left: 15px;
|
||||
margin-right: 2px;
|
||||
|
||||
&[disabled] {
|
||||
${buttons.disabled};
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
${buttons.gray};
|
||||
${shadows.dropMain};
|
||||
margin-bottom: 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 0.1px;
|
||||
width: 0.1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: ${zIndex.zIndex0};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface MediaLibraryTopProps {
|
||||
onClose: () => void;
|
||||
forImage?: boolean;
|
||||
onDownload: () => void;
|
||||
onUpload: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
|
||||
query?: string;
|
||||
onSearchChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
searchDisabled: boolean;
|
||||
onDelete: () => void;
|
||||
canInsert?: boolean;
|
||||
onInsert: () => void;
|
||||
hasSelection: boolean;
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
selectedFile?: MediaFile;
|
||||
}
|
||||
|
||||
const MediaLibraryTop = ({
|
||||
t,
|
||||
forImage,
|
||||
onDownload,
|
||||
onUpload,
|
||||
query,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
searchDisabled,
|
||||
onDelete,
|
||||
canInsert,
|
||||
onInsert,
|
||||
hasSelection,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
selectedFile,
|
||||
}: TranslatedProps<MediaLibraryTopProps>) => {
|
||||
const shouldShowButtonLoader = isPersisting || isDeleting;
|
||||
const uploadEnabled = !shouldShowButtonLoader;
|
||||
const deleteEnabled = !shouldShowButtonLoader && hasSelection;
|
||||
|
||||
const uploadButtonLabel = isPersisting
|
||||
? t('mediaLibrary.mediaLibraryModal.uploading')
|
||||
: t('mediaLibrary.mediaLibraryModal.upload');
|
||||
const deleteButtonLabel = isDeleting
|
||||
? t('mediaLibrary.mediaLibraryModal.deleting')
|
||||
: t('mediaLibrary.mediaLibraryModal.deleteSelected');
|
||||
const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download');
|
||||
const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected');
|
||||
|
||||
return (
|
||||
<LibraryTop>
|
||||
<StyledDialogTitle>
|
||||
{forImage
|
||||
? t('mediaLibrary.mediaLibraryModal.images')
|
||||
: t('mediaLibrary.mediaLibraryModal.mediaAssets')}
|
||||
<StyledButtonsContainer>
|
||||
<CopyToClipBoardButton
|
||||
disabled={!hasSelection}
|
||||
path={selectedFile?.path}
|
||||
name={selectedFile?.name}
|
||||
draft={selectedFile?.draft}
|
||||
t={t}
|
||||
/>
|
||||
<Button color="inherit" variant="contained" onClick={onDownload} disabled={!hasSelection}>
|
||||
{downloadButtonLabel}
|
||||
</Button>
|
||||
<UploadButton
|
||||
label={uploadButtonLabel}
|
||||
imagesOnly={forImage}
|
||||
onChange={onUpload}
|
||||
disabled={!uploadEnabled}
|
||||
/>
|
||||
</StyledButtonsContainer>
|
||||
</StyledDialogTitle>
|
||||
<StyledDialogTitle>
|
||||
<MediaLibrarySearch
|
||||
value={query}
|
||||
onChange={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
|
||||
disabled={searchDisabled}
|
||||
/>
|
||||
<StyledButtonsContainer>
|
||||
<Button color="error" variant="outlined" onClick={onDelete} disabled={!deleteEnabled}>
|
||||
{deleteButtonLabel}
|
||||
</Button>
|
||||
{!canInsert ? null : (
|
||||
<Button color="success" variant="contained" onClick={onInsert} disabled={!hasSelection}>
|
||||
{insertButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</StyledButtonsContainer>
|
||||
</StyledDialogTitle>
|
||||
</LibraryTop>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibraryTop;
|
105
packages/core/src/components/UI/Alert.tsx
Normal file
105
packages/core/src/components/UI/Alert.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import AlertEvent from '@staticcms/core/lib/util/events/AlertEvent';
|
||||
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
interface AlertProps {
|
||||
title: string | { key: string; options?: Record<string, unknown> };
|
||||
body: string | { key: string; options?: Record<string, unknown> };
|
||||
okay?: string | { key: string; options?: Record<string, unknown> };
|
||||
color?: 'success' | 'error' | 'primary';
|
||||
}
|
||||
|
||||
export interface AlertDialogProps extends AlertProps {
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
const AlertDialog = ({ t }: TranslateProps) => {
|
||||
const [detail, setDetail] = useState<AlertDialogProps | null>(null);
|
||||
const {
|
||||
resolve,
|
||||
title: rawTitle,
|
||||
body: rawBody,
|
||||
okay: rawOkay = 'ui.common.okay',
|
||||
color = 'primary',
|
||||
} = detail ?? {};
|
||||
|
||||
const onAlertMessage = useCallback((event: AlertEvent) => {
|
||||
setDetail(event.detail);
|
||||
}, []);
|
||||
|
||||
useWindowEvent('alert', onAlertMessage);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDetail(null);
|
||||
resolve?.();
|
||||
}, [resolve]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rawTitle) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options);
|
||||
}, [rawTitle, t]);
|
||||
|
||||
const body = useMemo(() => {
|
||||
if (!rawBody) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options);
|
||||
}, [rawBody, t]);
|
||||
|
||||
const okay = useMemo(
|
||||
() => (typeof rawOkay === 'string' ? t(rawOkay) : t(rawOkay.key, rawOkay.options)),
|
||||
[rawOkay, t],
|
||||
);
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">{body}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} variant="contained" color={color}>
|
||||
{okay}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Alert = translate()(AlertDialog);
|
||||
|
||||
const alert = (props: AlertProps) => {
|
||||
return new Promise<void>(resolve => {
|
||||
window.dispatchEvent(
|
||||
new AlertEvent({
|
||||
...props,
|
||||
resolve,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default alert;
|
86
packages/core/src/components/UI/AuthenticationPage.tsx
Normal file
86
packages/core/src/components/UI/AuthenticationPage.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import GoBackButton from './GoBackButton';
|
||||
import Icon from './Icon';
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react';
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const CustomIconWrapper = styled('span')`
|
||||
width: 300px;
|
||||
height: 15∏0px;
|
||||
margin-top: -150px;
|
||||
`;
|
||||
|
||||
const SimpleLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const StaticCustomIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const CustomLogoIcon = ({ url }: { url: string }) => {
|
||||
return (
|
||||
<CustomIconWrapper>
|
||||
<img src={url} alt="Logo" />
|
||||
</CustomIconWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPageLogo = (logoUrl?: string) => {
|
||||
if (logoUrl) {
|
||||
return <CustomLogoIcon url={logoUrl} />;
|
||||
}
|
||||
return <SimpleLogoIcon width={300} height={150} type="static-cms" />;
|
||||
};
|
||||
|
||||
export interface AuthenticationPageProps {
|
||||
onLogin?: MouseEventHandler<HTMLButtonElement>;
|
||||
logoUrl?: string;
|
||||
siteUrl?: string;
|
||||
loginDisabled?: boolean;
|
||||
loginErrorMessage?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
buttonContent?: ReactNode;
|
||||
pageContent?: ReactNode;
|
||||
}
|
||||
|
||||
const AuthenticationPage = ({
|
||||
onLogin,
|
||||
loginDisabled,
|
||||
loginErrorMessage,
|
||||
icon,
|
||||
buttonContent,
|
||||
pageContent,
|
||||
logoUrl,
|
||||
siteUrl,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
{renderPageLogo(logoUrl)}
|
||||
{loginErrorMessage ? <p>{loginErrorMessage}</p> : null}
|
||||
{pageContent ?? null}
|
||||
{buttonContent ? (
|
||||
<Button variant="contained" disabled={loginDisabled} onClick={onLogin} startIcon={icon}>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
) : null}
|
||||
{siteUrl ? <GoBackButton href={siteUrl} t={t} /> : null}
|
||||
{logoUrl ? <StaticCustomIcon width={100} height={100} type="static-cms" /> : null}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
124
packages/core/src/components/UI/Confirm.tsx
Normal file
124
packages/core/src/components/UI/Confirm.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import ConfirmEvent from '@staticcms/core/lib/util/events/ConfirmEvent';
|
||||
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
interface ConfirmProps {
|
||||
title: string | { key: string; options?: Record<string, unknown> };
|
||||
body: string | { key: string; options?: Record<string, unknown> };
|
||||
cancel?: string | { key: string; options?: Record<string, unknown> };
|
||||
confirm?: string | { key: string; options?: Record<string, unknown> };
|
||||
color?: 'success' | 'error' | 'primary';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogProps extends ConfirmProps {
|
||||
resolve: (value: boolean | PromiseLike<boolean>) => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog = ({ t }: TranslateProps) => {
|
||||
const [detail, setDetail] = useState<ConfirmDialogProps | null>(null);
|
||||
const {
|
||||
resolve,
|
||||
title: rawTitle,
|
||||
body: rawBody,
|
||||
cancel: rawCancel = 'ui.common.no',
|
||||
confirm: rawConfirm = 'ui.common.yes',
|
||||
color = 'primary',
|
||||
} = detail ?? {};
|
||||
|
||||
const onConfirmMessage = useCallback((event: ConfirmEvent) => {
|
||||
setDetail(event.detail);
|
||||
}, []);
|
||||
|
||||
useWindowEvent('confirm', onConfirmMessage);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDetail(null);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
resolve?.(false);
|
||||
handleClose();
|
||||
}, [handleClose, resolve]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resolve?.(true);
|
||||
handleClose();
|
||||
}, [handleClose, resolve]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rawTitle) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options);
|
||||
}, [rawTitle, t]);
|
||||
|
||||
const body = useMemo(() => {
|
||||
if (!rawBody) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options);
|
||||
}, [rawBody, t]);
|
||||
|
||||
const cancel = useMemo(
|
||||
() => (typeof rawCancel === 'string' ? t(rawCancel) : t(rawCancel.key, rawCancel.options)),
|
||||
[rawCancel, t],
|
||||
);
|
||||
|
||||
const confirm = useMemo(
|
||||
() => (typeof rawConfirm === 'string' ? t(rawConfirm) : t(rawConfirm.key, rawConfirm.options)),
|
||||
[rawConfirm, t],
|
||||
);
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleCancel}
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="confirm-dialog-description">{body}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancel} color="inherit">
|
||||
{cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color={color}>
|
||||
{confirm}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Confirm = translate()(ConfirmDialog);
|
||||
|
||||
const confirm = (props: ConfirmProps) => {
|
||||
return new Promise<boolean>(resolve => {
|
||||
window.dispatchEvent(
|
||||
new ConfirmEvent({
|
||||
...props,
|
||||
resolve,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default confirm;
|
227
packages/core/src/components/UI/ErrorBoundary.tsx
Normal file
227
packages/core/src/components/UI/ErrorBoundary.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import cleanStack from 'clean-stack';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import truncate from 'lodash/truncate';
|
||||
import React, { Component } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { buttons, colors } from '@staticcms/core/components/UI/styles';
|
||||
import { localForage } from '@staticcms/core/lib/util';
|
||||
|
||||
import type { Config, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?';
|
||||
|
||||
function getIssueTemplate(version: string, provider: string, browser: string, config: string) {
|
||||
return `
|
||||
**Describe the bug**
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
**Screenshots**
|
||||
|
||||
**Applicable Versions:**
|
||||
- Static CMS version: \`${version}\`
|
||||
- Git provider: \`${provider}\`
|
||||
- Browser version: \`${browser}\`
|
||||
|
||||
**CMS configuration**
|
||||
\`\`\`
|
||||
${config}
|
||||
\`\`\`
|
||||
|
||||
**Additional context**
|
||||
`;
|
||||
}
|
||||
|
||||
function buildIssueTemplate(config: Config) {
|
||||
let version = '';
|
||||
if (typeof STATIC_CMS_CORE_VERSION === 'string') {
|
||||
version = `static-cms@${STATIC_CMS_CORE_VERSION}`;
|
||||
}
|
||||
const template = getIssueTemplate(
|
||||
version,
|
||||
config?.backend?.name,
|
||||
navigator.userAgent,
|
||||
yaml.stringify(config),
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
function buildIssueUrl(title: string, config: Config) {
|
||||
try {
|
||||
const body = buildIssueTemplate(config);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('title', truncate(title, { length: 100 }));
|
||||
params.append('body', truncate(body, { length: 4000, omission: '\n...' }));
|
||||
params.append('labels', 'type: bug');
|
||||
|
||||
return `${ISSUE_URL}${params.toString()}`;
|
||||
} catch (e) {
|
||||
console.info(e);
|
||||
return `${ISSUE_URL}template=bug_report.md`;
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundaryContainer = styled('div')`
|
||||
padding: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: ${colors.text};
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: ${colors.textLead};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 200px;
|
||||
margin: 30px 0;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: ${colors.text};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacyWarning = styled('span')`
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
const CopyButton = styled('button')`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
interface RecoveredEntryProps {
|
||||
entry: string;
|
||||
}
|
||||
|
||||
const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
|
||||
console.info(entry);
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
|
||||
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
|
||||
<CopyButton onClick={() => copyToClipboard(entry)}>
|
||||
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
|
||||
</CopyButton>
|
||||
<pre>
|
||||
<code>{entry}</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
config: Config;
|
||||
showBackup?: boolean;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
errorMessage: string;
|
||||
errorTitle: string;
|
||||
backup: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
TranslatedProps<ErrorBoundaryProps>,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = {
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
errorTitle: '',
|
||||
backup: '',
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
console.error(error);
|
||||
return {
|
||||
hasError: true,
|
||||
errorMessage: cleanStack(error.stack, { basePath: window.location.origin || '' }),
|
||||
errorTitle: error.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(
|
||||
_nextProps: TranslatedProps<ErrorBoundaryProps>,
|
||||
nextState: ErrorBoundaryState,
|
||||
) {
|
||||
if (this.props.showBackup) {
|
||||
return (
|
||||
this.state.errorMessage !== nextState.errorMessage || this.state.backup !== nextState.backup
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async componentDidUpdate() {
|
||||
if (this.props.showBackup) {
|
||||
const backup = await localForage.getItem<string>('backup');
|
||||
if (backup) {
|
||||
console.info(backup);
|
||||
this.setState({ backup });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError, errorMessage, backup, errorTitle } = this.state;
|
||||
const { showBackup, t } = this.props;
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
return (
|
||||
<ErrorBoundaryContainer key="error-boundary-container">
|
||||
<h1>{t('ui.errorBoundary.title')}</h1>
|
||||
<p>
|
||||
<span>{t('ui.errorBoundary.details')}</span>
|
||||
<a
|
||||
href={buildIssueUrl(errorTitle, this.props.config)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="issue-url"
|
||||
>
|
||||
{t('ui.errorBoundary.reportIt')}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('ui.errorBoundary.privacyWarning')
|
||||
.split('\n')
|
||||
.map((item, index) => [
|
||||
<PrivacyWarning key={`private-warning-${index}`}>{item}</PrivacyWarning>,
|
||||
<br key={`break-${index}`} />,
|
||||
])}
|
||||
</p>
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.detailsHeading')}</h2>
|
||||
<p>{errorMessage}</p>
|
||||
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
|
||||
</ErrorBoundaryContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(ErrorBoundary);
|
55
packages/core/src/components/UI/FieldLabel.tsx
Normal file
55
packages/core/src/components/UI/FieldLabel.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import { colors } from './styles';
|
||||
|
||||
import type { MouseEventHandler } from 'react';
|
||||
|
||||
const stateColors = {
|
||||
default: {
|
||||
text: colors.controlLabel,
|
||||
},
|
||||
error: {
|
||||
text: colors.errorText,
|
||||
},
|
||||
};
|
||||
|
||||
export interface StyledLabelProps {
|
||||
hasErrors: boolean;
|
||||
}
|
||||
|
||||
function getStateColors({ hasErrors }: StyledLabelProps) {
|
||||
if (hasErrors) {
|
||||
return stateColors.error;
|
||||
}
|
||||
|
||||
return stateColors.default;
|
||||
}
|
||||
|
||||
interface FieldLabelProps {
|
||||
children: string | string[];
|
||||
htmlFor?: string;
|
||||
hasErrors?: boolean;
|
||||
isActive?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLLabelElement>;
|
||||
}
|
||||
|
||||
const FieldLabel = ({ children, htmlFor, onClick, hasErrors = false }: FieldLabelProps) => {
|
||||
return (
|
||||
<Typography
|
||||
key="field-label"
|
||||
variant="body2"
|
||||
component="label"
|
||||
htmlFor={htmlFor}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
color: getStateColors({ hasErrors }).text,
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldLabel;
|
29
packages/core/src/components/UI/FileUploadButton.tsx
Normal file
29
packages/core/src/components/UI/FileUploadButton.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import React from 'react';
|
||||
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
|
||||
export interface FileUploadButtonProps {
|
||||
label: string;
|
||||
imagesOnly?: boolean;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const FileUploadButton = ({ label, imagesOnly, onChange, disabled }: FileUploadButtonProps) => {
|
||||
return (
|
||||
<Button variant="contained" component="label">
|
||||
{label}
|
||||
<input
|
||||
hidden
|
||||
multiple
|
||||
type="file"
|
||||
accept={imagesOnly ? 'image/*' : '*/*'}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadButton;
|
19
packages/core/src/components/UI/GoBackButton.tsx
Normal file
19
packages/core/src/components/UI/GoBackButton.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import Button from '@mui/material/Button';
|
||||
import React from 'react';
|
||||
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
interface GoBackButtonProps {
|
||||
href: string;
|
||||
}
|
||||
|
||||
const GoBackButton = ({ href, t }: TranslatedProps<GoBackButtonProps>) => {
|
||||
return (
|
||||
<Button href={href} startIcon={<ArrowBackIcon />}>
|
||||
{t('ui.default.goBackToSite')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoBackButton;
|
97
packages/core/src/components/UI/Icon.tsx
Normal file
97
packages/core/src/components/UI/Icon.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import icons from './Icon/icons';
|
||||
import transientOptions from '@staticcms/core/lib/util/transientOptions';
|
||||
|
||||
import type { IconType } from './Icon/icons';
|
||||
|
||||
interface IconWrapperProps {
|
||||
$width: number;
|
||||
$height: number;
|
||||
$rotation: string;
|
||||
}
|
||||
|
||||
const IconWrapper = styled(
|
||||
'span',
|
||||
transientOptions,
|
||||
)<IconWrapperProps>(
|
||||
({ $width, $height, $rotation }) => `
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
width: ${$width}px;
|
||||
height: ${$height}px;
|
||||
transform: rotate(${$rotation});
|
||||
|
||||
& path:not(.no-fill),
|
||||
& circle:not(.no-fill),
|
||||
& polygon:not(.no-fill),
|
||||
& rect:not(.no-fill) {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
& path.clipped {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const rotations = { right: 90, down: 180, left: 270, up: 360 };
|
||||
|
||||
export type Direction = keyof typeof rotations;
|
||||
|
||||
/**
|
||||
* Calculates rotation for icons that have a `direction` property configured
|
||||
* in the imported icon definition object. If no direction is configured, a
|
||||
* neutral rotation value is returned.
|
||||
*
|
||||
* Returned value is a string of shape `${degrees}deg`, for use in a CSS
|
||||
* transform.
|
||||
*/
|
||||
function getRotation(iconDirection?: Direction, newDirection?: Direction) {
|
||||
if (!iconDirection || !newDirection) {
|
||||
return '0deg';
|
||||
}
|
||||
const degrees = rotations[newDirection] - rotations[iconDirection];
|
||||
return `${degrees}deg`;
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
xsmall: 12,
|
||||
small: 18,
|
||||
medium: 24,
|
||||
large: 32,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
type: IconType;
|
||||
direction?: Direction;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: keyof typeof sizes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Icon = ({ type, direction, width, height, size = 'medium', className }: IconProps) => {
|
||||
const IconSvg = icons[type].image;
|
||||
|
||||
return (
|
||||
<IconWrapper
|
||||
className={className}
|
||||
$width={width ? width : size in sizes ? sizes[size as keyof typeof sizes] : sizes['medium']}
|
||||
$height={
|
||||
height ? height : size in sizes ? sizes[size as keyof typeof sizes] : sizes['medium']
|
||||
}
|
||||
$rotation={getRotation(icons[type].direction, direction)}
|
||||
>
|
||||
<IconSvg />
|
||||
</IconWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Icon;
|
42
packages/core/src/components/UI/Icon/icons.tsx
Normal file
42
packages/core/src/components/UI/Icon/icons.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import images from './images/_index';
|
||||
|
||||
import type { Direction } from '../Icon';
|
||||
|
||||
export type IconType = keyof typeof images;
|
||||
|
||||
/**
|
||||
* This module outputs icon objects with the following shape:
|
||||
*
|
||||
* {
|
||||
* image: <svg>...</svg>,
|
||||
* ...props
|
||||
* }
|
||||
*
|
||||
* `props` here are config properties defined in this file for specific icons.
|
||||
* For example, an icon may face a specific direction, and the Icon component
|
||||
* accepts a `direction` prop to rotate directional icons, which relies on
|
||||
* defining the default direction here.
|
||||
*/
|
||||
|
||||
interface IconTypeConfig {
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
export interface IconTypeProps extends Partial<IconTypeConfig> {
|
||||
image: () => JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record icon definition objects - imported object of images simply maps the icon
|
||||
* name to the raw svg, so we move that to the `image` property of the
|
||||
* definition object and set any additional configured properties for each icon.
|
||||
*/
|
||||
const icons = (Object.keys(images) as IconType[]).reduce((acc, name) => {
|
||||
const image = images[name];
|
||||
acc[name] = {
|
||||
image,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<IconType, IconTypeProps>);
|
||||
|
||||
export default icons;
|
13
packages/core/src/components/UI/Icon/images/_index.tsx
Normal file
13
packages/core/src/components/UI/Icon/images/_index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import bitbucket from './bitbucket.svg';
|
||||
import github from './github.svg';
|
||||
import gitlab from './gitlab.svg';
|
||||
import staticCms from './static-cms-logo.svg';
|
||||
|
||||
const images = {
|
||||
bitbucket,
|
||||
github,
|
||||
gitlab,
|
||||
'static-cms': staticCms,
|
||||
};
|
||||
|
||||
export default images;
|
@ -0,0 +1,3 @@
|
||||
<svg width="26px" height="26px" viewBox="0 0 26 26" version="1.1">
|
||||
<path d="M2.77580579,3.0000546 C2.58222841,2.99755793 2.39745454,3.08078757 2.27104968,3.2274172 C2.14464483,3.37404683 2.08954809,3.5690671 2.12053915,3.76016391 L4.90214605,20.6463853 C4.97368482,21.0729296 5.34116371,21.38653 5.77365069,21.3901129 L19.1181559,21.3901129 C19.4427702,21.3942909 19.7215068,21.1601522 19.7734225,20.839689 L22.5550294,3.76344024 C22.5860205,3.57234343 22.5309237,3.37732317 22.4045189,3.23069353 C22.278114,3.0840639 22.0933402,3.00083426 21.8997628,3.00333094 L2.77580579,3.0000546 Z M14.488697,15.2043958 L10.2294639,15.2043958 L9.07619457,9.17921905 L15.520742,9.17921905 L14.488697,15.2043958 Z" id="Shape" fill="#2684FF" fill-rule="nonzero" />
|
||||
</svg>
|
After Width: | Height: | Size: 758 B |
1
packages/core/src/components/UI/Icon/images/github.svg
Normal file
1
packages/core/src/components/UI/Icon/images/github.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
After Width: | Height: | Size: 670 B |
1
packages/core/src/components/UI/Icon/images/gitlab.svg
Normal file
1
packages/core/src/components/UI/Icon/images/gitlab.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="26" height="26" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path d="M22.616 14.971L21.52 11.5l-2.173-6.882a.37.37 0 0 0-.71 0l-2.172 6.882H9.252L7.079 4.617a.37.37 0 0 0-.71 0l-2.172 6.882L3.1 14.971c-.1.317.01.664.27.86l9.487 7.094 9.487-7.094a.781.781 0 0 0 .27-.86" fill="#FC6D26"/><path d="M12.858 22.925L16.465 11.5H9.251z" fill="#E24329"/><path d="M12.858 22.925L9.251 11.5H4.197z" fill="#FC6D26"/><path d="M4.197 11.499L3.1 14.971c-.1.317.01.664.27.86l9.487 7.094L4.197 11.5z" fill="#FCA326"/><path d="M4.197 11.499H9.25L7.08 4.617a.37.37 0 0 0-.71 0l-2.172 6.882z" fill="#E24329"/><path d="M12.858 22.925L16.465 11.5h5.055z" fill="#FC6D26"/><path d="M21.52 11.499l1.096 3.472c.1.317-.01.664-.271.86l-9.487 7.094L21.52 11.5z" fill="#FCA326"/><path d="M21.52 11.499h-5.055l2.172-6.882a.37.37 0 0 1 .71 0l2.173 6.882z" fill="#E24329"/></g></svg>
|
After Width: | Height: | Size: 889 B |
995
packages/core/src/components/UI/Icon/images/static-cms-logo.svg
Normal file
995
packages/core/src/components/UI/Icon/images/static-cms-logo.svg
Normal file
@ -0,0 +1,995 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 1280 640" enable-background="new 0 0 1280 640" xml:space="preserve">
|
||||
<path class="no-fill" fill="none" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M783.000000,641.000000
|
||||
C522.033875,641.000000 261.567780,641.000000 1.050843,641.000000
|
||||
C1.050843,427.728333 1.050843,214.456650 1.050843,1.092481
|
||||
C427.549469,1.092481 854.098938,1.092481 1280.824219,1.092481
|
||||
C1280.824219,214.333221 1280.824219,427.666595 1280.824219,641.000000
|
||||
C1115.123169,641.000000 949.311584,641.000000 783.000000,641.000000
|
||||
M167.485123,140.998108
|
||||
C152.329453,147.700104 139.128983,157.422043 126.590767,168.028687
|
||||
C116.481079,176.580917 107.793411,186.570709 100.004242,197.262222
|
||||
C85.694534,216.903946 75.327057,238.455933 70.081841,262.250488
|
||||
C68.205719,270.761383 66.482559,279.455566 66.145454,288.122711
|
||||
C65.570129,302.914490 64.655029,317.957428 66.672409,332.508087
|
||||
C68.531563,345.917480 74.138252,358.795380 77.949402,371.949371
|
||||
C81.754852,385.083740 89.176323,396.389252 96.392693,407.685608
|
||||
C103.655174,419.054169 112.337166,429.500641 122.778961,438.140961
|
||||
C132.785294,446.420929 142.906265,454.701569 153.771606,461.758270
|
||||
C161.836838,466.996368 170.952942,470.860291 180.000198,474.280029
|
||||
C190.989990,478.433990 202.285751,481.923035 213.671463,484.828522
|
||||
C220.082153,486.464447 226.981689,486.073761 233.589005,487.072449
|
||||
C249.075256,489.413147 264.357178,487.584259 279.562469,484.981415
|
||||
C285.976227,483.883484 292.266449,481.900085 298.500885,479.959625
|
||||
C306.555847,477.452454 314.680359,475.014648 322.432281,471.736694
|
||||
C328.595734,469.130463 334.105804,465.021606 340.151367,462.085205
|
||||
C352.351593,456.159454 362.313995,447.248901 372.157410,438.279358
|
||||
C381.824799,429.470215 389.920959,419.321014 397.565430,408.572968
|
||||
C404.582367,398.707245 410.132721,388.259460 414.814606,377.365112
|
||||
C418.826904,368.028748 421.724396,358.118256 424.158295,348.226318
|
||||
C426.427979,339.001648 428.352325,329.508057 428.777374,320.058563
|
||||
C429.348846,307.354767 428.864685,294.536163 427.823303,281.849792
|
||||
C427.076324,272.749359 425.399597,263.574127 422.852966,254.805374
|
||||
C418.168976,238.677246 411.955780,222.994354 402.395996,209.082657
|
||||
C395.006927,198.329865 387.167419,187.736511 378.362671,178.141830
|
||||
C365.162262,163.757111 349.724396,151.866730 331.920624,143.547440
|
||||
C320.680145,138.295029 309.365997,132.877670 297.560303,129.233841
|
||||
C280.149902,123.860130 262.051788,122.000023 243.756104,122.991013
|
||||
C235.691360,123.427856 227.486328,123.001205 219.588638,124.371231
|
||||
C209.026443,126.203468 198.561508,128.955856 188.322586,132.166962
|
||||
C181.399002,134.338303 174.936737,137.980591 167.485123,140.998108
|
||||
M588.000854,275.527588
|
||||
C588.470947,269.078613 587.082581,263.069641 583.921143,257.417480
|
||||
C578.941101,248.514008 570.934082,242.966827 561.858337,239.587006
|
||||
C551.286987,235.650284 540.317566,232.476944 529.262634,230.260956
|
||||
C522.822632,228.970016 516.920837,227.201736 511.234009,224.041794
|
||||
C505.534332,220.874710 504.070007,214.132782 508.270813,209.178177
|
||||
C513.577026,202.919907 520.974243,201.255127 528.410767,201.213486
|
||||
C534.762268,201.177933 541.093628,203.404480 547.489624,203.877670
|
||||
C554.741760,204.414185 561.024353,207.487778 566.748657,211.198135
|
||||
C571.240906,214.109848 571.808105,212.283157 573.098389,208.703751
|
||||
C575.379395,202.375687 577.827271,196.088501 580.601257,189.963547
|
||||
C582.804077,185.099792 582.892395,184.744690 577.844971,182.550110
|
||||
C573.797913,180.790466 569.874329,178.625580 565.679688,177.353638
|
||||
C558.794373,175.265778 551.789612,172.573914 544.735779,172.240433
|
||||
C528.430420,171.469513 512.011536,170.976379 496.440857,177.917358
|
||||
C487.796967,181.770569 480.246643,187.108170 475.819519,195.212646
|
||||
C472.373993,201.520096 470.727905,208.965134 469.218536,216.102615
|
||||
C468.578278,219.130356 470.636566,222.636673 470.944489,225.973831
|
||||
C471.980652,237.202454 478.552704,244.590332 487.696991,249.933990
|
||||
C495.561554,254.529800 503.889832,257.875763 513.137451,259.230347
|
||||
C520.827393,260.356781 528.394958,262.711884 535.815613,265.138000
|
||||
C540.357971,266.623077 544.889038,268.770264 548.710022,271.589386
|
||||
C550.855347,273.172180 553.331726,277.485077 552.578552,279.262970
|
||||
C549.454102,286.638275 544.707581,291.310364 534.922668,290.938019
|
||||
C524.502258,290.541504 514.014709,292.390778 503.700928,288.208069
|
||||
C497.129425,285.543030 490.249908,283.795319 484.575043,279.258331
|
||||
C481.500427,276.800171 478.573273,277.949280 476.920074,281.534302
|
||||
C474.549225,286.675537 471.240784,291.423462 469.251984,296.686707
|
||||
C468.112793,299.701477 465.233276,303.984406 469.474884,306.645996
|
||||
C474.204224,309.613586 479.466431,311.800751 484.655548,313.942780
|
||||
C488.164978,315.391388 491.905396,316.364624 495.619141,317.216370
|
||||
C503.096130,318.931183 510.581085,321.391418 518.145081,321.735718
|
||||
C526.356934,322.109497 534.607849,322.611816 543.059509,320.396271
|
||||
C550.034912,318.567749 557.043640,317.409698 563.589294,314.051422
|
||||
C578.978088,306.156097 587.657349,294.079529 588.000854,275.527588
|
||||
M696.000305,441.431122
|
||||
C696.280396,445.252808 696.560486,449.074463 696.848206,452.999634
|
||||
C705.390991,452.999634 713.048584,452.885834 720.700012,453.053192
|
||||
C723.957947,453.124420 725.111755,451.975342 725.053284,448.709167
|
||||
C724.895264,439.880585 724.999878,431.047333 724.999939,422.215912
|
||||
C725.000061,395.388428 724.882812,368.559967 725.109741,341.734375
|
||||
C725.149841,336.994293 723.716858,335.609222 719.140381,335.911102
|
||||
C713.499512,336.283203 707.808838,335.850311 702.154175,336.096405
|
||||
C700.564941,336.165588 698.513123,336.995209 697.579041,338.186218
|
||||
C695.992859,340.208679 695.260071,342.874359 693.930359,345.129150
|
||||
C690.400452,351.114929 686.733948,357.020111 683.139099,362.967804
|
||||
C679.739929,368.591797 676.318481,374.202972 672.986633,379.866791
|
||||
C668.257690,387.905548 663.601990,395.987427 658.513550,404.741425
|
||||
C655.381348,399.407440 652.672058,394.814240 649.982727,390.209412
|
||||
C645.542114,382.605865 641.184082,374.953003 636.662170,367.398254
|
||||
C631.848633,359.356506 626.762939,351.476044 622.033630,343.386292
|
||||
C619.274841,338.667267 616.587097,334.796417 610.000183,335.865082
|
||||
C605.616577,336.576233 601.001953,336.278046 596.531311,335.921661
|
||||
C592.076294,335.566528 590.876221,337.190399 590.907288,341.564209
|
||||
C591.094299,367.890472 591.000244,394.218719 591.000305,420.546326
|
||||
C591.000305,429.877625 590.884399,439.211823 591.118286,448.537262
|
||||
C591.155945,450.038422 592.706604,452.751038 593.637390,452.785950
|
||||
C602.374634,453.113800 611.129150,452.980591 620.181335,452.980591
|
||||
C620.181335,432.068787 620.181335,411.870605 620.181335,391.220276
|
||||
C622.650879,394.703552 625.027832,397.594269 626.901367,400.780945
|
||||
C634.063843,412.963043 640.954224,425.306458 648.229004,437.419861
|
||||
C649.505859,439.545929 651.432251,441.138275 654.937317,441.244873
|
||||
C661.930969,441.457672 666.755981,440.582794 670.194763,433.133759
|
||||
C674.858215,423.031982 681.281494,413.734589 687.064148,404.161102
|
||||
C689.675842,399.837463 692.563293,395.680420 695.997070,390.418884
|
||||
C695.997070,407.940155 695.997070,424.193878 696.000305,441.431122
|
||||
M792.999878,252.560181
|
||||
C792.798584,244.730682 790.993469,237.259460 787.981323,230.056213
|
||||
C785.148071,223.280777 780.631165,217.936340 774.407898,214.031769
|
||||
C763.999512,207.501312 752.314941,205.255402 740.381958,205.198578
|
||||
C732.836365,205.162659 725.259033,206.019333 717.654114,207.677628
|
||||
C707.546326,209.881653 698.457581,213.445953 689.690674,219.468826
|
||||
C693.342529,227.096298 696.779785,234.600174 700.550293,241.932648
|
||||
C701.547974,243.872879 703.118652,244.561127 705.837402,242.909592
|
||||
C710.996033,239.775909 716.502258,236.421005 722.278870,235.305313
|
||||
C729.018188,234.003708 736.218506,234.692459 743.187317,235.081741
|
||||
C750.969604,235.516434 757.726318,243.497620 757.810120,252.000000
|
||||
C752.336609,252.000000 746.862244,252.016968 741.387939,251.996552
|
||||
C729.017151,251.950424 716.742554,251.479584 704.910461,256.833282
|
||||
C696.736755,260.531677 690.386353,265.875183 688.254883,274.199524
|
||||
C685.921936,283.310516 685.205444,292.922241 689.989441,301.934509
|
||||
C694.853394,311.097412 702.732483,316.889069 712.426147,318.718323
|
||||
C721.241699,320.381866 730.522278,320.135529 739.566956,319.825745
|
||||
C745.134827,319.635040 750.535645,317.526825 754.930359,313.605682
|
||||
C756.372742,312.318695 758.034058,311.277039 760.196777,309.678650
|
||||
C760.196777,313.246552 760.196777,315.995300 760.196777,319.000000
|
||||
C769.656128,319.000000 778.798218,318.825989 787.928223,319.081451
|
||||
C791.848022,319.191132 793.124573,318.054779 793.086670,314.019501
|
||||
C792.897217,293.863983 793.000000,273.705688 792.999878,252.560181
|
||||
M1046.551025,316.048645
|
||||
C1047.831421,315.434387 1049.099121,314.792145 1050.394165,314.210327
|
||||
C1059.250854,310.230896 1065.500610,303.509552 1070.260986,295.254120
|
||||
C1072.388550,291.564362 1070.295654,289.659180 1067.210449,287.951721
|
||||
C1062.379761,285.278290 1057.595459,282.496582 1052.961548,279.498047
|
||||
C1048.113647,276.361023 1044.405273,276.114838 1042.305908,279.227448
|
||||
C1035.458618,289.379486 1025.353760,293.762543 1013.902893,290.780396
|
||||
C1010.265442,289.833099 1006.783020,287.527374 1003.755798,285.168121
|
||||
C998.357483,280.961029 995.894592,275.084015 994.528748,268.319214
|
||||
C992.803162,259.773285 996.428223,244.389465 1005.751953,239.669769
|
||||
C1008.222839,238.419022 1010.544128,236.242340 1013.106934,235.935883
|
||||
C1018.301819,235.314667 1024.356079,234.048935 1028.704956,236.024017
|
||||
C1035.184326,238.966690 1040.494507,244.483963 1046.974487,249.417694
|
||||
C1052.616943,246.206375 1059.053589,241.589417 1066.201172,238.784027
|
||||
C1071.676880,236.634918 1071.855103,233.759644 1069.843628,229.790024
|
||||
C1068.438110,227.016327 1066.043091,224.733246 1064.030151,222.280106
|
||||
C1056.477905,213.075745 1046.088745,208.220032 1034.949829,206.287491
|
||||
C1024.342285,204.447144 1013.473145,204.445419 1002.616760,207.716599
|
||||
C995.051086,209.996262 987.931396,212.646393 981.560242,217.161316
|
||||
C968.211548,226.620941 960.529236,239.698441 959.139709,255.881668
|
||||
C957.983032,269.354370 959.889343,282.200500 967.795166,294.132965
|
||||
C978.148560,309.759735 992.491150,318.269867 1010.547485,319.743103
|
||||
C1022.310181,320.702789 1034.517456,321.704590 1046.551025,316.048645
|
||||
M804.464111,334.000153
|
||||
C802.965515,333.981384 801.434692,334.156738 799.973450,333.913513
|
||||
C787.066406,331.765137 774.828369,333.583282 762.796753,338.853973
|
||||
C751.740051,343.697601 744.540161,351.638824 742.519897,363.267914
|
||||
C740.822815,373.036865 741.047913,382.947540 748.536316,391.117950
|
||||
C757.873718,401.305908 770.932312,403.149811 783.120667,407.004578
|
||||
C790.264221,409.263855 797.543823,411.196228 804.474121,413.990265
|
||||
C809.193115,415.892761 809.622559,424.367157 805.276245,426.796356
|
||||
C799.796265,429.859161 793.524597,430.989838 787.377747,430.802948
|
||||
C782.094482,430.642273 776.747437,429.371368 771.630188,427.893311
|
||||
C766.798767,426.497803 762.220398,424.216095 757.549622,422.278534
|
||||
C754.632202,421.068268 751.749939,419.773193 748.489929,418.359131
|
||||
C748.133728,420.319946 748.177673,421.537262 747.695374,422.487671
|
||||
C745.225586,427.355530 742.184875,431.992889 740.299988,437.066101
|
||||
C739.647400,438.822418 741.152649,442.209290 742.739197,443.759521
|
||||
C744.736877,445.711487 747.826538,446.547668 750.448120,447.859192
|
||||
C761.569458,453.422913 773.802917,454.333435 785.806763,455.807800
|
||||
C790.166321,456.343231 794.788940,454.199982 799.306335,454.120209
|
||||
C805.345581,454.013611 810.783691,452.425812 816.288879,450.099426
|
||||
C827.710022,445.273102 834.913574,436.539001 837.684631,425.046478
|
||||
C839.369019,418.060669 838.969971,410.146881 834.914734,403.431641
|
||||
C832.971619,400.213806 830.819336,396.711060 827.862793,394.591003
|
||||
C817.633484,387.255615 805.513794,384.716919 793.486755,381.873260
|
||||
C787.127747,380.369751 780.973267,377.816742 774.924011,375.268433
|
||||
C772.080627,374.070679 770.781860,366.358246 772.511292,364.197937
|
||||
C777.424255,358.060760 785.227112,359.722198 791.551575,357.419800
|
||||
C794.154114,356.472412 797.832214,358.252411 800.961670,359.030975
|
||||
C806.139099,360.318970 811.309814,361.667236 816.402039,363.251526
|
||||
C819.390442,364.181274 822.221375,365.616730 824.403870,366.525299
|
||||
C827.263306,360.332886 829.878113,355.432190 831.745850,350.261658
|
||||
C832.525452,348.103210 832.681274,343.951324 831.472961,343.130066
|
||||
C823.672485,337.828247 814.492676,335.920166 804.464111,334.000153
|
||||
M480.524170,436.042999
|
||||
C491.791809,450.022217 508.078308,453.189972 524.372314,455.791107
|
||||
C528.825073,456.501923 533.705627,454.791779 538.350403,453.969940
|
||||
C543.125610,453.125061 547.873901,452.098236 552.596375,450.990875
|
||||
C562.003906,448.784821 568.895874,442.685303 575.500916,436.103180
|
||||
C577.560913,434.050385 577.293945,432.563965 575.291260,430.757324
|
||||
C570.705750,426.620880 565.945496,422.627563 561.775391,418.098114
|
||||
C558.704285,414.762360 556.325073,415.921875 554.267456,418.467712
|
||||
C547.934998,426.302734 538.910950,429.017029 529.812073,429.622986
|
||||
C524.213440,429.995819 518.116699,427.251831 512.686218,424.889893
|
||||
C500.984863,419.800568 495.795105,410.015167 495.030884,397.643433
|
||||
C494.825958,394.325470 494.432220,390.857544 495.140594,387.685211
|
||||
C496.053070,383.598816 497.496246,379.456116 499.587128,375.845062
|
||||
C503.598267,368.917725 509.455353,364.596741 517.267944,361.448273
|
||||
C525.322449,358.202332 532.985596,358.657959 540.325623,360.599670
|
||||
C545.604614,361.996277 549.829224,367.101868 554.797607,370.099823
|
||||
C556.540527,371.151550 559.922241,371.922729 561.139343,370.984619
|
||||
C566.120605,367.145233 570.447327,362.470947 575.235596,358.361023
|
||||
C577.655823,356.283630 577.226685,354.251709 575.634766,352.396637
|
||||
C566.942017,342.267151 555.349243,336.652588 542.705505,334.261078
|
||||
C531.668335,332.173462 520.449585,332.510742 509.130951,335.702026
|
||||
C498.175751,338.790771 488.948853,343.937866 480.952240,351.634033
|
||||
C473.666687,358.645966 468.155609,367.421478 466.339630,376.988068
|
||||
C463.204559,393.503784 463.150543,410.340576 472.099915,425.654755
|
||||
C474.198761,429.246307 477.327484,432.236023 480.524170,436.042999
|
||||
M889.969299,305.820374
|
||||
C888.049561,301.091583 885.816650,296.459808 884.311890,291.602509
|
||||
C883.163635,287.896027 882.324097,286.827911 878.717957,289.553741
|
||||
C876.299194,291.382141 872.950439,292.582031 869.907532,292.861359
|
||||
C862.932007,293.501770 857.055176,287.527557 857.014038,280.330353
|
||||
C856.955994,270.167114 856.998962,260.003326 856.998779,249.839767
|
||||
C856.998657,245.589661 856.998718,241.339554 856.998718,237.000305
|
||||
C865.014526,237.000305 872.341980,236.882507 879.662659,237.054733
|
||||
C882.867920,237.130127 884.160278,236.123077 884.056946,232.785156
|
||||
C883.861084,226.459808 883.816650,220.116776 884.069458,213.795502
|
||||
C884.218079,210.080276 882.991699,208.799484 879.237488,208.927612
|
||||
C871.960205,209.175949 864.668457,209.000229 856.998779,209.000229
|
||||
C856.998779,201.147369 856.873962,193.690231 857.055969,186.240585
|
||||
C857.136780,182.930405 855.914551,181.885361 852.685303,181.946136
|
||||
C844.024353,182.109100 835.353577,182.169205 826.696411,181.924866
|
||||
C822.862244,181.816650 821.815918,183.225128 821.930847,186.859314
|
||||
C822.160706,194.129303 821.998962,201.411682 821.998962,209.080811
|
||||
C816.456604,209.080811 811.373901,209.080811 806.243713,209.080811
|
||||
C806.243713,218.554535 806.243713,227.624756 806.243713,237.093979
|
||||
C811.555908,237.093979 816.638550,237.093979 821.998718,237.093979
|
||||
C821.998718,250.623886 821.980408,263.753937 822.006348,276.883942
|
||||
C822.019836,283.721100 822.417053,290.286316 824.710083,297.068939
|
||||
C828.765015,309.063416 836.881409,316.429565 848.423889,318.704926
|
||||
C860.348633,321.055634 872.710938,322.043884 884.697510,317.104858
|
||||
C890.942017,314.531799 892.264709,312.832123 889.969299,305.820374
|
||||
M646.022705,286.253082
|
||||
C645.348145,283.837036 644.246521,281.448303 644.089783,278.999115
|
||||
C643.781677,274.186981 643.998718,269.341217 643.998718,264.509369
|
||||
C643.998657,255.412064 643.998718,246.314774 643.998718,236.829391
|
||||
C653.304871,236.829391 662.048340,236.829391 670.711182,236.829391
|
||||
C670.711182,227.356247 670.711182,218.285568 670.711182,208.819717
|
||||
C661.684021,208.819717 652.940796,208.819717 643.998779,208.819717
|
||||
C643.998779,201.109039 643.842957,193.794189 644.065674,186.490891
|
||||
C644.174133,182.933350 642.891846,181.856796 639.434570,181.938187
|
||||
C631.108459,182.134247 622.772461,182.111343 614.444641,181.955643
|
||||
C610.258484,181.877396 608.688538,183.381592 608.903992,187.731018
|
||||
C609.253906,194.791382 608.998413,201.881760 608.998413,209.163147
|
||||
C603.306885,209.163147 598.351929,209.163147 593.202759,209.163147
|
||||
C593.202759,218.460022 593.202759,227.529846 593.202759,237.001740
|
||||
C598.459839,237.001740 603.542419,237.001740 608.998291,237.001740
|
||||
C608.998291,249.851242 609.261414,262.310272 608.913330,274.752197
|
||||
C608.644531,284.362213 609.916626,293.511353 614.017456,302.252594
|
||||
C618.360107,311.509308 626.071716,316.807739 635.560608,318.786652
|
||||
C642.401489,320.213318 649.687622,320.237030 656.712585,319.823730
|
||||
C662.569946,319.479126 668.368835,317.724457 674.131897,316.337769
|
||||
C678.027527,315.400391 678.862000,312.112457 677.869019,308.935822
|
||||
C675.815918,302.368164 673.282166,295.943329 670.707214,289.556030
|
||||
C670.398743,288.790710 668.456482,288.045837 667.535828,288.289673
|
||||
C665.143677,288.923157 662.869873,290.049774 660.600708,291.092468
|
||||
C654.228577,294.020691 649.879395,292.773499 646.022705,286.253082
|
||||
M942.006714,231.500061
|
||||
C942.006714,225.169708 941.978516,218.839203 942.016663,212.509079
|
||||
C942.042236,208.266632 940.608398,205.807190 935.758789,205.940140
|
||||
C927.435608,206.168289 919.098877,206.133057 910.773499,205.949417
|
||||
C906.476990,205.854630 904.940247,207.596161 904.951782,211.831055
|
||||
C905.044800,245.814789 905.077942,279.799225 904.912476,313.782379
|
||||
C904.891235,318.144440 906.776978,319.066925 910.456299,319.027740
|
||||
C918.784729,318.938934 927.114807,319.006897 935.444214,318.997711
|
||||
C941.540771,318.990997 942.002625,318.536865 942.004028,312.462311
|
||||
C942.010315,285.808228 942.006714,259.154114 942.006714,231.500061
|
||||
M931.917603,157.015991
|
||||
C926.253113,157.030777 919.942444,155.432159 915.055786,157.389175
|
||||
C904.182983,161.743515 898.360107,171.227386 903.963867,184.211945
|
||||
C909.002197,195.886246 924.573608,197.451218 933.144470,193.210266
|
||||
C937.956421,190.829224 941.406616,187.386108 943.475098,181.586349
|
||||
C946.463623,173.206970 943.402832,162.441132 935.160034,159.024567
|
||||
C934.268433,158.654968 933.552551,157.861313 931.917603,157.015991
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M167.876633,140.981537
|
||||
C174.936737,137.980591 181.399002,134.338303 188.322586,132.166962
|
||||
C198.561508,128.955856 209.026443,126.203468 219.588638,124.371231
|
||||
C227.486328,123.001205 235.691360,123.427856 243.756104,122.991013
|
||||
C262.051788,122.000023 280.149902,123.860130 297.560303,129.233841
|
||||
C309.365997,132.877670 320.680145,138.295029 331.920624,143.547440
|
||||
C349.724396,151.866730 365.162262,163.757111 378.362671,178.141830
|
||||
C387.167419,187.736511 395.006927,198.329865 402.395996,209.082657
|
||||
C411.955780,222.994354 418.168976,238.677246 422.852966,254.805374
|
||||
C425.399597,263.574127 427.076324,272.749359 427.823303,281.849792
|
||||
C428.864685,294.536163 429.348846,307.354767 428.777374,320.058563
|
||||
C428.352325,329.508057 426.427979,339.001648 424.158295,348.226318
|
||||
C421.724396,358.118256 418.826904,368.028748 414.814606,377.365112
|
||||
C410.132721,388.259460 404.582367,398.707245 397.565430,408.572968
|
||||
C389.920959,419.321014 381.824799,429.470215 372.157410,438.279358
|
||||
C362.313995,447.248901 352.351593,456.159454 340.151367,462.085205
|
||||
C334.105804,465.021606 328.595734,469.130463 322.432281,471.736694
|
||||
C314.680359,475.014648 306.555847,477.452454 298.500885,479.959625
|
||||
C292.266449,481.900085 285.976227,483.883484 279.562469,484.981415
|
||||
C264.357178,487.584259 249.075256,489.413147 233.589005,487.072449
|
||||
C226.981689,486.073761 220.082153,486.464447 213.671463,484.828522
|
||||
C202.285751,481.923035 190.989990,478.433990 180.000198,474.280029
|
||||
C170.952942,470.860291 161.836838,466.996368 153.771606,461.758270
|
||||
C142.906265,454.701569 132.785294,446.420929 122.778961,438.140961
|
||||
C112.337166,429.500641 103.655174,419.054169 96.392693,407.685608
|
||||
C89.176323,396.389252 81.754852,385.083740 77.949402,371.949371
|
||||
C74.138252,358.795380 68.531563,345.917480 66.672409,332.508087
|
||||
C64.655029,317.957428 65.570129,302.914490 66.145454,288.122711
|
||||
C66.482559,279.455566 68.205719,270.761383 70.081841,262.250488
|
||||
C75.327057,238.455933 85.694534,216.903946 100.004242,197.262222
|
||||
C107.793411,186.570709 116.481079,176.580917 126.590767,168.028687
|
||||
C139.128983,157.422043 152.329453,147.700104 167.876633,140.981537
|
||||
M132.966751,214.354034
|
||||
C124.555054,223.287628 118.893524,233.974274 113.911568,245.001236
|
||||
C103.961014,267.025665 99.775597,290.178680 101.275322,314.314514
|
||||
C101.344681,315.430603 102.444328,317.469421 102.865555,317.419037
|
||||
C105.927773,317.052612 109.150627,316.694000 111.855560,315.375000
|
||||
C112.849022,314.890533 112.908173,311.802826 112.834854,309.924408
|
||||
C112.111908,291.402679 115.583374,273.626099 121.922684,256.299652
|
||||
C123.057983,253.196686 124.701180,251.934250 128.133636,251.974991
|
||||
C143.129929,252.153030 158.130569,252.145660 173.127274,251.985245
|
||||
C176.880829,251.945099 178.162659,253.303375 178.018082,256.944580
|
||||
C177.819901,261.935822 178.115417,266.945953 177.944290,271.939240
|
||||
C177.841385,274.941528 178.841797,276.131317 181.940247,276.017944
|
||||
C187.099396,275.829132 192.273209,276.068695 197.435638,275.931274
|
||||
C200.220673,275.857178 201.326218,276.882599 201.259171,279.699951
|
||||
C201.132324,285.029541 201.049988,290.373322 201.303955,295.694275
|
||||
C201.476990,299.319550 200.004684,300.243896 196.624146,300.170044
|
||||
C188.129105,299.984344 179.618729,300.335754 171.131744,300.015228
|
||||
C166.815002,299.852234 165.805374,301.428619 165.901123,305.456909
|
||||
C166.150558,315.949921 166.251190,326.463776 165.810379,336.944061
|
||||
C165.695541,339.673981 164.002289,342.981995 161.965134,344.873016
|
||||
C153.940643,352.321838 145.350052,359.157410 137.240845,366.519409
|
||||
C130.297775,372.822662 129.367477,372.802704 125.607910,364.281097
|
||||
C124.737373,362.307922 124.079933,360.232574 123.107780,358.314026
|
||||
C121.034584,354.222504 120.734001,350.364960 123.037453,346.102112
|
||||
C125.569832,341.415527 123.708107,335.234375 119.447762,332.180908
|
||||
C114.261009,328.463470 107.819405,329.382233 103.476433,334.458893
|
||||
C99.840965,338.708557 100.381050,345.415680 104.393188,349.854431
|
||||
C106.578735,352.272400 108.955849,354.848663 110.125664,357.798126
|
||||
C116.462189,373.774200 124.735435,388.698853 135.753143,401.781433
|
||||
C148.237762,416.605804 163.224503,428.763458 180.550903,437.644287
|
||||
C204.637253,449.989929 230.180344,455.488586 257.366608,453.940338
|
||||
C277.048920,452.819458 295.621857,448.148193 313.365265,439.728912
|
||||
C328.202942,432.688446 341.472839,423.257477 353.081024,411.834534
|
||||
C360.232697,404.796967 366.481384,396.634552 372.089600,388.275452
|
||||
C387.549530,365.232300 396.454956,339.830261 396.971863,311.879791
|
||||
C397.121918,303.764557 396.494507,295.617767 395.839783,287.516449
|
||||
C395.666595,285.373474 394.066406,283.345825 393.121429,281.265259
|
||||
C389.899841,282.756622 385.302917,280.502197 383.149933,285.226410
|
||||
C382.548767,286.545532 380.513245,288.002289 379.103882,288.035278
|
||||
C368.777191,288.276855 358.440613,288.065491 348.111145,288.237701
|
||||
C344.727600,288.294098 343.822144,286.956360 343.898560,283.812164
|
||||
C344.064514,276.983612 343.757751,270.140808 344.021179,263.318390
|
||||
C344.174805,259.340698 342.748108,258.115997 338.841583,258.175781
|
||||
C326.179230,258.369537 313.487885,257.783051 300.856537,258.438324
|
||||
C294.190521,258.784149 290.467590,256.250427 287.392548,250.890137
|
||||
C285.607635,247.778778 285.449554,245.611679 288.166260,243.024536
|
||||
C293.957214,237.509674 299.682739,231.904694 305.082367,226.013351
|
||||
C306.684814,224.264984 307.870514,221.461853 307.943695,219.101624
|
||||
C308.253418,209.112335 308.035614,199.107651 308.086517,189.108963
|
||||
C308.109802,184.532486 309.500061,183.569366 313.491699,185.827530
|
||||
C318.800049,188.830597 324.001495,192.067581 329.008667,195.549561
|
||||
C343.651642,205.732224 355.517517,218.734985 364.744904,233.829880
|
||||
C369.199524,241.117050 374.787750,248.376083 374.120422,257.985413
|
||||
C373.695007,264.111664 377.886230,269.233765 383.065033,269.929779
|
||||
C389.128632,270.744751 395.040710,267.428040 396.674744,262.294739
|
||||
C398.485931,256.604828 395.861298,250.082947 389.869598,248.144943
|
||||
C384.918579,246.543533 382.739075,243.239304 380.966187,239.016235
|
||||
C380.262634,237.340271 379.459229,235.689972 378.537781,234.124207
|
||||
C369.733398,219.163620 359.173676,205.763748 346.050812,194.168015
|
||||
C334.437012,183.905731 321.714966,175.524963 307.610474,169.672470
|
||||
C285.038452,160.306488 261.533936,155.418793 236.795868,158.114029
|
||||
C221.548752,159.775223 206.898743,163.035767 192.589386,168.807236
|
||||
C170.275879,177.807083 152.075470,192.121353 136.079208,209.652496
|
||||
C134.988205,210.848221 134.302048,212.413361 132.966751,214.354034
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M588.000854,276.010529
|
||||
C587.657349,294.079529 578.978088,306.156097 563.589294,314.051422
|
||||
C557.043640,317.409698 550.034912,318.567749 543.059509,320.396271
|
||||
C534.607849,322.611816 526.356934,322.109497 518.145081,321.735718
|
||||
C510.581085,321.391418 503.096130,318.931183 495.619141,317.216370
|
||||
C491.905396,316.364624 488.164978,315.391388 484.655548,313.942780
|
||||
C479.466431,311.800751 474.204224,309.613586 469.474884,306.645996
|
||||
C465.233276,303.984406 468.112793,299.701477 469.251984,296.686707
|
||||
C471.240784,291.423462 474.549225,286.675537 476.920074,281.534302
|
||||
C478.573273,277.949280 481.500427,276.800171 484.575043,279.258331
|
||||
C490.249908,283.795319 497.129425,285.543030 503.700928,288.208069
|
||||
C514.014709,292.390778 524.502258,290.541504 534.922668,290.938019
|
||||
C544.707581,291.310364 549.454102,286.638275 552.578552,279.262970
|
||||
C553.331726,277.485077 550.855347,273.172180 548.710022,271.589386
|
||||
C544.889038,268.770264 540.357971,266.623077 535.815613,265.138000
|
||||
C528.394958,262.711884 520.827393,260.356781 513.137451,259.230347
|
||||
C503.889832,257.875763 495.561554,254.529800 487.696991,249.933990
|
||||
C478.552704,244.590332 471.980652,237.202454 470.944489,225.973831
|
||||
C470.636566,222.636673 468.578278,219.130356 469.218536,216.102615
|
||||
C470.727905,208.965134 472.373993,201.520096 475.819519,195.212646
|
||||
C480.246643,187.108170 487.796967,181.770569 496.440857,177.917358
|
||||
C512.011536,170.976379 528.430420,171.469513 544.735779,172.240433
|
||||
C551.789612,172.573914 558.794373,175.265778 565.679688,177.353638
|
||||
C569.874329,178.625580 573.797913,180.790466 577.844971,182.550110
|
||||
C582.892395,184.744690 582.804077,185.099792 580.601257,189.963547
|
||||
C577.827271,196.088501 575.379395,202.375687 573.098389,208.703751
|
||||
C571.808105,212.283157 571.240906,214.109848 566.748657,211.198135
|
||||
C561.024353,207.487778 554.741760,204.414185 547.489624,203.877670
|
||||
C541.093628,203.404480 534.762268,201.177933 528.410767,201.213486
|
||||
C520.974243,201.255127 513.577026,202.919907 508.270813,209.178177
|
||||
C504.070007,214.132782 505.534332,220.874710 511.234009,224.041794
|
||||
C516.920837,227.201736 522.822632,228.970016 529.262634,230.260956
|
||||
C540.317566,232.476944 551.286987,235.650284 561.858337,239.587006
|
||||
C570.934082,242.966827 578.941101,248.514008 583.921143,257.417480
|
||||
C587.082581,263.069641 588.470947,269.078613 588.000854,276.010529
|
||||
z"/>
|
||||
<path class="no-fill" fill="#68C4E2" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M695.998657,440.939362
|
||||
C695.997070,424.193878 695.997070,407.940155 695.997070,390.418884
|
||||
C692.563293,395.680420 689.675842,399.837463 687.064148,404.161102
|
||||
C681.281494,413.734589 674.858215,423.031982 670.194763,433.133759
|
||||
C666.755981,440.582794 661.930969,441.457672 654.937317,441.244873
|
||||
C651.432251,441.138275 649.505859,439.545929 648.229004,437.419861
|
||||
C640.954224,425.306458 634.063843,412.963043 626.901367,400.780945
|
||||
C625.027832,397.594269 622.650879,394.703552 620.181335,391.220276
|
||||
C620.181335,411.870605 620.181335,432.068787 620.181335,452.980591
|
||||
C611.129150,452.980591 602.374634,453.113800 593.637390,452.785950
|
||||
C592.706604,452.751038 591.155945,450.038422 591.118286,448.537262
|
||||
C590.884399,439.211823 591.000305,429.877625 591.000305,420.546326
|
||||
C591.000244,394.218719 591.094299,367.890472 590.907288,341.564209
|
||||
C590.876221,337.190399 592.076294,335.566528 596.531311,335.921661
|
||||
C601.001953,336.278046 605.616577,336.576233 610.000183,335.865082
|
||||
C616.587097,334.796417 619.274841,338.667267 622.033630,343.386292
|
||||
C626.762939,351.476044 631.848633,359.356506 636.662170,367.398254
|
||||
C641.184082,374.953003 645.542114,382.605865 649.982727,390.209412
|
||||
C652.672058,394.814240 655.381348,399.407440 658.513550,404.741425
|
||||
C663.601990,395.987427 668.257690,387.905548 672.986633,379.866791
|
||||
C676.318481,374.202972 679.739929,368.591797 683.139099,362.967804
|
||||
C686.733948,357.020111 690.400452,351.114929 693.930359,345.129150
|
||||
C695.260071,342.874359 695.992859,340.208679 697.579041,338.186218
|
||||
C698.513123,336.995209 700.564941,336.165588 702.154175,336.096405
|
||||
C707.808838,335.850311 713.499512,336.283203 719.140381,335.911102
|
||||
C723.716858,335.609222 725.149841,336.994293 725.109741,341.734375
|
||||
C724.882812,368.559967 725.000061,395.388428 724.999939,422.215912
|
||||
C724.999878,431.047333 724.895264,439.880585 725.053284,448.709167
|
||||
C725.111755,451.975342 723.957947,453.124420 720.700012,453.053192
|
||||
C713.048584,452.885834 705.390991,452.999634 696.848206,452.999634
|
||||
C696.560486,449.074463 696.280396,445.252808 695.998657,440.939362
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M792.999939,253.054291
|
||||
C793.000000,273.705688 792.897217,293.863983 793.086670,314.019501
|
||||
C793.124573,318.054779 791.848022,319.191132 787.928223,319.081451
|
||||
C778.798218,318.825989 769.656128,319.000000 760.196777,319.000000
|
||||
C760.196777,315.995300 760.196777,313.246552 760.196777,309.678650
|
||||
C758.034058,311.277039 756.372742,312.318695 754.930359,313.605682
|
||||
C750.535645,317.526825 745.134827,319.635040 739.566956,319.825745
|
||||
C730.522278,320.135529 721.241699,320.381866 712.426147,318.718323
|
||||
C702.732483,316.889069 694.853394,311.097412 689.989441,301.934509
|
||||
C685.205444,292.922241 685.921936,283.310516 688.254883,274.199524
|
||||
C690.386353,265.875183 696.736755,260.531677 704.910461,256.833282
|
||||
C716.742554,251.479584 729.017151,251.950424 741.387939,251.996552
|
||||
C746.862244,252.016968 752.336609,252.000000 757.810120,252.000000
|
||||
C757.726318,243.497620 750.969604,235.516434 743.187317,235.081741
|
||||
C736.218506,234.692459 729.018188,234.003708 722.278870,235.305313
|
||||
C716.502258,236.421005 710.996033,239.775909 705.837402,242.909592
|
||||
C703.118652,244.561127 701.547974,243.872879 700.550293,241.932648
|
||||
C696.779785,234.600174 693.342529,227.096298 689.690674,219.468826
|
||||
C698.457581,213.445953 707.546326,209.881653 717.654114,207.677628
|
||||
C725.259033,206.019333 732.836365,205.162659 740.381958,205.198578
|
||||
C752.314941,205.255402 763.999512,207.501312 774.407898,214.031769
|
||||
C780.631165,217.936340 785.148071,223.280777 787.981323,230.056213
|
||||
C790.993469,237.259460 792.798584,244.730682 792.999939,253.054291
|
||||
M744.517761,294.070740
|
||||
C753.547119,293.064148 759.386597,284.034485 757.339539,274.000244
|
||||
C748.755127,274.000244 740.127502,273.832520 731.510437,274.062531
|
||||
C726.265381,274.202545 722.632568,277.217896 720.541992,281.963623
|
||||
C718.680908,286.188141 721.129089,289.331970 723.739929,292.069397
|
||||
C724.928589,293.315674 727.110779,293.560425 728.733337,294.456757
|
||||
C733.791626,297.251160 738.883972,296.530243 744.517761,294.070740
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1046.243652,316.315552
|
||||
C1034.517456,321.704590 1022.310181,320.702789 1010.547485,319.743103
|
||||
C992.491150,318.269867 978.148560,309.759735 967.795166,294.132965
|
||||
C959.889343,282.200500 957.983032,269.354370 959.139709,255.881668
|
||||
C960.529236,239.698441 968.211548,226.620941 981.560242,217.161316
|
||||
C987.931396,212.646393 995.051086,209.996262 1002.616760,207.716599
|
||||
C1013.473145,204.445419 1024.342285,204.447144 1034.949829,206.287491
|
||||
C1046.088745,208.220032 1056.477905,213.075745 1064.030151,222.280106
|
||||
C1066.043091,224.733246 1068.438110,227.016327 1069.843628,229.790024
|
||||
C1071.855103,233.759644 1071.676880,236.634918 1066.201172,238.784027
|
||||
C1059.053589,241.589417 1052.616943,246.206375 1046.974487,249.417694
|
||||
C1040.494507,244.483963 1035.184326,238.966690 1028.704956,236.024017
|
||||
C1024.356079,234.048935 1018.301819,235.314667 1013.106934,235.935883
|
||||
C1010.544128,236.242340 1008.222839,238.419022 1005.751953,239.669769
|
||||
C996.428223,244.389465 992.803162,259.773285 994.528748,268.319214
|
||||
C995.894592,275.084015 998.357483,280.961029 1003.755798,285.168121
|
||||
C1006.783020,287.527374 1010.265442,289.833099 1013.902893,290.780396
|
||||
C1025.353760,293.762543 1035.458618,289.379486 1042.305908,279.227448
|
||||
C1044.405273,276.114838 1048.113647,276.361023 1052.961548,279.498047
|
||||
C1057.595459,282.496582 1062.379761,285.278290 1067.210449,287.951721
|
||||
C1070.295654,289.659180 1072.388550,291.564362 1070.260986,295.254120
|
||||
C1065.500610,303.509552 1059.250854,310.230896 1050.394165,314.210327
|
||||
C1049.099121,314.792145 1047.831421,315.434387 1046.243652,316.315552
|
||||
z"/>
|
||||
<path class="no-fill" fill="#68C4E2" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M804.927979,334.000214
|
||||
C814.492676,335.920166 823.672485,337.828247 831.472961,343.130066
|
||||
C832.681274,343.951324 832.525452,348.103210 831.745850,350.261658
|
||||
C829.878113,355.432190 827.263306,360.332886 824.403870,366.525299
|
||||
C822.221375,365.616730 819.390442,364.181274 816.402039,363.251526
|
||||
C811.309814,361.667236 806.139099,360.318970 800.961670,359.030975
|
||||
C797.832214,358.252411 794.154114,356.472412 791.551575,357.419800
|
||||
C785.227112,359.722198 777.424255,358.060760 772.511292,364.197937
|
||||
C770.781860,366.358246 772.080627,374.070679 774.924011,375.268433
|
||||
C780.973267,377.816742 787.127747,380.369751 793.486755,381.873260
|
||||
C805.513794,384.716919 817.633484,387.255615 827.862793,394.591003
|
||||
C830.819336,396.711060 832.971619,400.213806 834.914734,403.431641
|
||||
C838.969971,410.146881 839.369019,418.060669 837.684631,425.046478
|
||||
C834.913574,436.539001 827.710022,445.273102 816.288879,450.099426
|
||||
C810.783691,452.425812 805.345581,454.013611 799.306335,454.120209
|
||||
C794.788940,454.199982 790.166321,456.343231 785.806763,455.807800
|
||||
C773.802917,454.333435 761.569458,453.422913 750.448120,447.859192
|
||||
C747.826538,446.547668 744.736877,445.711487 742.739197,443.759521
|
||||
C741.152649,442.209290 739.647400,438.822418 740.299988,437.066101
|
||||
C742.184875,431.992889 745.225586,427.355530 747.695374,422.487671
|
||||
C748.177673,421.537262 748.133728,420.319946 748.489929,418.359131
|
||||
C751.749939,419.773193 754.632202,421.068268 757.549622,422.278534
|
||||
C762.220398,424.216095 766.798767,426.497803 771.630188,427.893311
|
||||
C776.747437,429.371368 782.094482,430.642273 787.377747,430.802948
|
||||
C793.524597,430.989838 799.796265,429.859161 805.276245,426.796356
|
||||
C809.622559,424.367157 809.193115,415.892761 804.474121,413.990265
|
||||
C797.543823,411.196228 790.264221,409.263855 783.120667,407.004578
|
||||
C770.932312,403.149811 757.873718,401.305908 748.536316,391.117950
|
||||
C741.047913,382.947540 740.822815,373.036865 742.519897,363.267914
|
||||
C744.540161,351.638824 751.740051,343.697601 762.796753,338.853973
|
||||
C774.828369,333.583282 787.066406,331.765137 799.973450,333.913513
|
||||
C801.434692,334.156738 802.965515,333.981384 804.927979,334.000214
|
||||
z"/>
|
||||
<path class="no-fill" fill="#68C4E2" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M480.253296,435.772827
|
||||
C477.327484,432.236023 474.198761,429.246307 472.099915,425.654755
|
||||
C463.150543,410.340576 463.204559,393.503784 466.339630,376.988068
|
||||
C468.155609,367.421478 473.666687,358.645966 480.952240,351.634033
|
||||
C488.948853,343.937866 498.175751,338.790771 509.130951,335.702026
|
||||
C520.449585,332.510742 531.668335,332.173462 542.705505,334.261078
|
||||
C555.349243,336.652588 566.942017,342.267151 575.634766,352.396637
|
||||
C577.226685,354.251709 577.655823,356.283630 575.235596,358.361023
|
||||
C570.447327,362.470947 566.120605,367.145233 561.139343,370.984619
|
||||
C559.922241,371.922729 556.540527,371.151550 554.797607,370.099823
|
||||
C549.829224,367.101868 545.604614,361.996277 540.325623,360.599670
|
||||
C532.985596,358.657959 525.322449,358.202332 517.267944,361.448273
|
||||
C509.455353,364.596741 503.598267,368.917725 499.587128,375.845062
|
||||
C497.496246,379.456116 496.053070,383.598816 495.140594,387.685211
|
||||
C494.432220,390.857544 494.825958,394.325470 495.030884,397.643433
|
||||
C495.795105,410.015167 500.984863,419.800568 512.686218,424.889893
|
||||
C518.116699,427.251831 524.213440,429.995819 529.812073,429.622986
|
||||
C538.910950,429.017029 547.934998,426.302734 554.267456,418.467712
|
||||
C556.325073,415.921875 558.704285,414.762360 561.775391,418.098114
|
||||
C565.945496,422.627563 570.705750,426.620880 575.291260,430.757324
|
||||
C577.293945,432.563965 577.560913,434.050385 575.500916,436.103180
|
||||
C568.895874,442.685303 562.003906,448.784821 552.596375,450.990875
|
||||
C547.873901,452.098236 543.125610,453.125061 538.350403,453.969940
|
||||
C533.705627,454.791779 528.825073,456.501923 524.372314,455.791107
|
||||
C508.078308,453.189972 491.791809,450.022217 480.253296,435.772827
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M889.985352,306.242157
|
||||
C892.264709,312.832123 890.942017,314.531799 884.697510,317.104858
|
||||
C872.710938,322.043884 860.348633,321.055634 848.423889,318.704926
|
||||
C836.881409,316.429565 828.765015,309.063416 824.710083,297.068939
|
||||
C822.417053,290.286316 822.019836,283.721100 822.006348,276.883942
|
||||
C821.980408,263.753937 821.998718,250.623886 821.998718,237.093979
|
||||
C816.638550,237.093979 811.555908,237.093979 806.243713,237.093979
|
||||
C806.243713,227.624756 806.243713,218.554535 806.243713,209.080811
|
||||
C811.373901,209.080811 816.456604,209.080811 821.998962,209.080811
|
||||
C821.998962,201.411682 822.160706,194.129303 821.930847,186.859314
|
||||
C821.815918,183.225128 822.862244,181.816650 826.696411,181.924866
|
||||
C835.353577,182.169205 844.024353,182.109100 852.685303,181.946136
|
||||
C855.914551,181.885361 857.136780,182.930405 857.055969,186.240585
|
||||
C856.873962,193.690231 856.998779,201.147369 856.998779,209.000229
|
||||
C864.668457,209.000229 871.960205,209.175949 879.237488,208.927612
|
||||
C882.991699,208.799484 884.218079,210.080276 884.069458,213.795502
|
||||
C883.816650,220.116776 883.861084,226.459808 884.056946,232.785156
|
||||
C884.160278,236.123077 882.867920,237.130127 879.662659,237.054733
|
||||
C872.341980,236.882507 865.014526,237.000305 856.998718,237.000305
|
||||
C856.998718,241.339554 856.998657,245.589661 856.998779,249.839767
|
||||
C856.998962,260.003326 856.955994,270.167114 857.014038,280.330353
|
||||
C857.055176,287.527557 862.932007,293.501770 869.907532,292.861359
|
||||
C872.950439,292.582031 876.299194,291.382141 878.717957,289.553741
|
||||
C882.324097,286.827911 883.163635,287.896027 884.311890,291.602509
|
||||
C885.816650,296.459808 888.049561,301.091583 889.985352,306.242157
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M646.226196,286.604004
|
||||
C649.879395,292.773499 654.228577,294.020691 660.600708,291.092468
|
||||
C662.869873,290.049774 665.143677,288.923157 667.535828,288.289673
|
||||
C668.456482,288.045837 670.398743,288.790710 670.707214,289.556030
|
||||
C673.282166,295.943329 675.815918,302.368164 677.869019,308.935822
|
||||
C678.862000,312.112457 678.027527,315.400391 674.131897,316.337769
|
||||
C668.368835,317.724457 662.569946,319.479126 656.712585,319.823730
|
||||
C649.687622,320.237030 642.401489,320.213318 635.560608,318.786652
|
||||
C626.071716,316.807739 618.360107,311.509308 614.017456,302.252594
|
||||
C609.916626,293.511353 608.644531,284.362213 608.913330,274.752197
|
||||
C609.261414,262.310272 608.998291,249.851242 608.998291,237.001740
|
||||
C603.542419,237.001740 598.459839,237.001740 593.202759,237.001740
|
||||
C593.202759,227.529846 593.202759,218.460022 593.202759,209.163147
|
||||
C598.351929,209.163147 603.306885,209.163147 608.998413,209.163147
|
||||
C608.998413,201.881760 609.253906,194.791382 608.903992,187.731018
|
||||
C608.688538,183.381592 610.258484,181.877396 614.444641,181.955643
|
||||
C622.772461,182.111343 631.108459,182.134247 639.434570,181.938187
|
||||
C642.891846,181.856796 644.174133,182.933350 644.065674,186.490891
|
||||
C643.842957,193.794189 643.998779,201.109039 643.998779,208.819717
|
||||
C652.940796,208.819717 661.684021,208.819717 670.711182,208.819717
|
||||
C670.711182,218.285568 670.711182,227.356247 670.711182,236.829391
|
||||
C662.048340,236.829391 653.304871,236.829391 643.998718,236.829391
|
||||
C643.998718,246.314774 643.998657,255.412064 643.998718,264.509369
|
||||
C643.998718,269.341217 643.781677,274.186981 644.089783,278.999115
|
||||
C644.246521,281.448303 645.348145,283.837036 646.226196,286.604004
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M942.006714,232.000046
|
||||
C942.006714,259.154114 942.010315,285.808228 942.004028,312.462311
|
||||
C942.002625,318.536865 941.540771,318.990997 935.444214,318.997711
|
||||
C927.114807,319.006897 918.784729,318.938934 910.456299,319.027740
|
||||
C906.776978,319.066925 904.891235,318.144440 904.912476,313.782379
|
||||
C905.077942,279.799225 905.044800,245.814789 904.951782,211.831055
|
||||
C904.940247,207.596161 906.476990,205.854630 910.773499,205.949417
|
||||
C919.098877,206.133057 927.435608,206.168289 935.758789,205.940140
|
||||
C940.608398,205.807190 942.042236,208.266632 942.016663,212.509079
|
||||
C941.978516,218.839203 942.006714,225.169708 942.006714,232.000046
|
||||
z"/>
|
||||
<path class="no-fill" fill="#a2b2c1" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M932.336365,157.140182
|
||||
C933.552551,157.861313 934.268433,158.654968 935.160034,159.024567
|
||||
C943.402832,162.441132 946.463623,173.206970 943.475098,181.586349
|
||||
C941.406616,187.386108 937.956421,190.829224 933.144470,193.210266
|
||||
C924.573608,197.451218 909.002197,195.886246 903.963867,184.211945
|
||||
C898.360107,171.227386 904.182983,161.743515 915.055786,157.389175
|
||||
C919.942444,155.432159 926.253113,157.030777 932.336365,157.140182
|
||||
z"/>
|
||||
<path class="no-fill" fill="#6EC5E3" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M133.197174,214.080368
|
||||
C134.302048,212.413361 134.988205,210.848221 136.079208,209.652496
|
||||
C152.075470,192.121353 170.275879,177.807083 192.589386,168.807236
|
||||
C206.898743,163.035767 221.548752,159.775223 236.795868,158.114029
|
||||
C261.533936,155.418793 285.038452,160.306488 307.610474,169.672470
|
||||
C321.714966,175.524963 334.437012,183.905731 346.050812,194.168015
|
||||
C359.173676,205.763748 369.733398,219.163620 378.537781,234.124207
|
||||
C379.459229,235.689972 380.262634,237.340271 380.966187,239.016235
|
||||
C382.739075,243.239304 384.918579,246.543533 389.869598,248.144943
|
||||
C395.861298,250.082947 398.485931,256.604828 396.674744,262.294739
|
||||
C395.040710,267.428040 389.128632,270.744751 383.065033,269.929779
|
||||
C377.886230,269.233765 373.695007,264.111664 374.120422,257.985413
|
||||
C374.787750,248.376083 369.199524,241.117050 364.744904,233.829880
|
||||
C355.517517,218.734985 343.651642,205.732224 329.008667,195.549561
|
||||
C324.001495,192.067581 318.800049,188.830597 313.491699,185.827530
|
||||
C309.500061,183.569366 308.109802,184.532486 308.086517,189.108963
|
||||
C308.035614,199.107651 308.253418,209.112335 307.943695,219.101624
|
||||
C307.870514,221.461853 306.684814,224.264984 305.082367,226.013351
|
||||
C299.682739,231.904694 293.957214,237.509674 288.166260,243.024536
|
||||
C285.449554,245.611679 285.607635,247.778778 287.392548,250.890137
|
||||
C290.467590,256.250427 294.190521,258.784149 300.856537,258.438324
|
||||
C313.487885,257.783051 326.179230,258.369537 338.841583,258.175781
|
||||
C342.748108,258.115997 344.174805,259.340698 344.021179,263.318390
|
||||
C343.757751,270.140808 344.064514,276.983612 343.898560,283.812164
|
||||
C343.822144,286.956360 344.727600,288.294098 348.111145,288.237701
|
||||
C358.440613,288.065491 368.777191,288.276855 379.103882,288.035278
|
||||
C380.513245,288.002289 382.548767,286.545532 383.149933,285.226410
|
||||
C385.302917,280.502197 389.899841,282.756622 393.121460,281.265259
|
||||
C394.066406,283.345825 395.666595,285.373474 395.839783,287.516449
|
||||
C396.494507,295.617767 397.121918,303.764557 396.971863,311.879791
|
||||
C396.454956,339.830261 387.549530,365.232300 372.089600,388.275452
|
||||
C366.481384,396.634552 360.232697,404.796967 353.081024,411.834534
|
||||
C341.472839,423.257477 328.202942,432.688446 313.365265,439.728912
|
||||
C295.621857,448.148193 277.048920,452.819458 257.366608,453.940338
|
||||
C230.180344,455.488586 204.637253,449.989929 180.550903,437.644287
|
||||
C163.224503,428.763458 148.237762,416.605804 135.753143,401.781433
|
||||
C124.735435,388.698853 116.462189,373.774200 110.125664,357.798126
|
||||
C108.955849,354.848663 106.578735,352.272400 104.393188,349.854431
|
||||
C100.381050,345.415680 99.840965,338.708557 103.476433,334.458893
|
||||
C107.819405,329.382233 114.261009,328.463470 119.447762,332.180908
|
||||
C123.708107,335.234375 125.569832,341.415527 123.037453,346.102112
|
||||
C120.734001,350.364960 121.034584,354.222504 123.107780,358.314026
|
||||
C124.079933,360.232574 124.737373,362.307922 125.607910,364.281097
|
||||
C129.367477,372.802704 130.297775,372.822662 137.240845,366.519409
|
||||
C145.350052,359.157410 153.940643,352.321838 161.965134,344.873016
|
||||
C164.002289,342.981995 165.695541,339.673981 165.810379,336.944061
|
||||
C166.251190,326.463776 166.150558,315.949921 165.901123,305.456909
|
||||
C165.805374,301.428619 166.815002,299.852234 171.131744,300.015228
|
||||
C179.618729,300.335754 188.129105,299.984344 196.624146,300.170044
|
||||
C200.004684,300.243896 201.476990,299.319550 201.303955,295.694275
|
||||
C201.049988,290.373322 201.132324,285.029541 201.259171,279.699951
|
||||
C201.326218,276.882599 200.220673,275.857178 197.435638,275.931274
|
||||
C192.273209,276.068695 187.099396,275.829132 181.940247,276.017944
|
||||
C178.841797,276.131317 177.841385,274.941528 177.944290,271.939240
|
||||
C178.115417,266.945953 177.819901,261.935822 178.018082,256.944580
|
||||
C178.162659,253.303375 176.880829,251.945099 173.127274,251.985245
|
||||
C158.130569,252.145660 143.129929,252.153030 128.133636,251.974991
|
||||
C124.701180,251.934250 123.057983,253.196686 121.922684,256.299652
|
||||
C115.583374,273.626099 112.111908,291.402679 112.834854,309.924408
|
||||
C112.908173,311.802826 112.849022,314.890533 111.855560,315.375000
|
||||
C109.150627,316.694000 105.927773,317.052612 102.865555,317.419037
|
||||
C102.444328,317.469421 101.344681,315.430603 101.275322,314.314514
|
||||
C99.775597,290.178680 103.961014,267.025665 113.911568,245.001236
|
||||
C118.893524,233.974274 124.555054,223.287628 133.197174,214.080368
|
||||
M274.468506,440.162323
|
||||
C276.895966,439.649353 279.358032,439.261932 281.745422,438.603668
|
||||
C302.473633,432.888306 321.399200,423.894073 337.661896,409.407410
|
||||
C341.795715,405.725067 341.099976,404.116913 337.709656,401.169861
|
||||
C328.932892,393.540649 320.201111,385.854614 311.618439,378.008453
|
||||
C301.925537,369.147339 304.823242,367.246796 293.226166,379.492371
|
||||
C291.539978,381.272888 288.702576,382.743774 286.296600,382.912292
|
||||
C279.663574,383.376953 272.975677,383.151093 266.311798,383.047882
|
||||
C262.160400,382.983582 260.570435,384.773285 261.144836,388.894226
|
||||
C261.885437,394.207397 261.131378,395.024628 255.780792,395.036896
|
||||
C242.116608,395.068176 228.452301,395.045563 214.788040,395.042053
|
||||
C201.322189,395.038605 201.409409,395.035522 201.142395,381.686310
|
||||
C201.096054,379.369720 200.654022,376.431122 199.211716,374.879639
|
||||
C192.408722,367.561646 185.297485,360.516357 178.016281,353.671570
|
||||
C176.908386,352.630096 173.586334,352.240356 172.549179,353.089386
|
||||
C161.500671,362.133209 150.701157,371.481476 139.847458,380.762756
|
||||
C137.509918,382.761627 137.599960,384.703308 139.477402,387.223145
|
||||
C148.201752,398.933044 158.418747,409.171173 170.330887,417.519104
|
||||
C201.547028,439.395081 236.151672,446.465576 274.468506,440.162323
|
||||
M233.500000,336.022949
|
||||
C240.997757,336.022003 248.500427,336.182892 255.991257,335.954651
|
||||
C259.986053,335.832947 261.279297,337.397217 261.148773,341.242249
|
||||
C260.934143,347.566315 261.087921,353.902985 261.090210,360.234344
|
||||
C261.093719,370.069214 261.087372,370.127258 270.471802,371.375519
|
||||
C273.260010,371.746429 276.183624,372.158508 278.902161,371.701385
|
||||
C281.713837,371.228546 284.950470,370.378296 286.956451,368.548065
|
||||
C293.718719,362.378296 300.093414,355.773041 306.426514,349.152771
|
||||
C307.673859,347.848846 308.697052,345.758453 308.759674,343.993042
|
||||
C309.019470,336.671143 308.809998,329.334015 308.903290,322.004150
|
||||
C308.940643,319.065887 307.843079,317.731903 304.794952,317.832214
|
||||
C300.634216,317.969086 296.465271,317.877014 292.300049,317.850220
|
||||
C283.999756,317.796783 284.978638,318.679932 284.952667,310.250427
|
||||
C284.916229,298.420898 285.046112,286.589478 284.851990,274.763000
|
||||
C284.813416,272.412811 284.068024,269.831543 282.892334,267.792999
|
||||
C280.410339,263.489410 277.678345,259.269165 274.535156,255.436020
|
||||
C273.170746,253.772110 270.573883,252.290100 268.474884,252.202133
|
||||
C259.825928,251.839676 251.147537,252.226547 242.491821,251.957062
|
||||
C238.575317,251.835114 237.576630,253.351913 237.798965,256.926636
|
||||
C238.046722,260.910095 237.868408,264.920654 237.859970,268.919373
|
||||
C237.845459,275.786621 237.575760,276.056458 230.948380,276.033264
|
||||
C226.949677,276.019287 222.935547,276.229584 218.956528,275.945038
|
||||
C214.893097,275.654480 213.754272,277.396240 213.840897,281.203705
|
||||
C214.034119,289.696655 213.822647,298.198090 213.937500,306.693878
|
||||
C213.988983,310.501892 212.545502,312.029694 208.599182,311.928925
|
||||
C199.940842,311.707977 191.270996,311.968231 182.610001,311.807495
|
||||
C179.108963,311.742523 177.836395,313.108643 177.956619,316.521088
|
||||
C178.126663,321.347137 177.726166,326.208038 178.143311,331.004150
|
||||
C178.338882,333.252716 179.382843,335.834106 180.892258,337.489777
|
||||
C186.720444,343.882721 192.880356,349.973999 198.961853,356.133087
|
||||
C199.324295,356.500122 200.081741,356.477173 201.233368,356.795410
|
||||
C201.233368,351.386139 201.438599,346.408020 201.169266,341.455719
|
||||
C200.948715,337.400299 202.250824,335.810150 206.510696,335.944946
|
||||
C215.165955,336.218903 223.836029,336.024445 233.500000,336.022949
|
||||
M226.487503,216.974564
|
||||
C228.318466,216.972504 230.169739,217.144852 231.976410,216.934296
|
||||
C236.626938,216.392303 238.261978,218.372772 237.932877,222.949615
|
||||
C237.611343,227.420914 237.970520,231.936935 237.815445,236.425873
|
||||
C237.706879,239.568451 238.831314,240.808350 242.108658,240.970367
|
||||
C271.671997,242.431732 271.669800,242.475281 292.326508,221.303314
|
||||
C292.791443,220.826767 293.466125,220.437729 293.695282,219.868042
|
||||
C294.830353,217.045822 296.641174,214.209595 296.761719,211.317810
|
||||
C297.149567,202.012863 296.705475,192.675598 296.979675,183.362503
|
||||
C297.106903,179.041595 295.408844,176.904739 291.375214,175.996246
|
||||
C285.569855,174.688721 279.870422,172.750992 274.007111,171.898193
|
||||
C265.338440,170.637329 256.545135,169.256149 247.842560,169.468674
|
||||
C238.302414,169.701630 228.822815,171.862457 219.280701,172.824982
|
||||
C215.221130,173.234451 213.808914,175.022842 213.863144,178.894470
|
||||
C213.986694,187.714676 213.897949,196.537811 213.903442,205.359756
|
||||
C213.910675,216.986603 213.913971,216.986603 226.487503,216.974564
|
||||
M371.632660,365.118713
|
||||
C381.424042,345.942200 385.693726,325.563324 385.051239,304.081818
|
||||
C384.964478,301.180969 383.989960,299.849487 380.932770,299.875000
|
||||
C369.771881,299.968201 358.609558,299.951996 347.448334,299.881836
|
||||
C344.922424,299.865967 343.778198,300.760101 343.869568,303.373566
|
||||
C343.985901,306.701233 343.720398,310.047455 343.936829,313.364960
|
||||
C344.170837,316.951538 342.598175,318.032654 339.218903,317.908264
|
||||
C334.061554,317.718414 328.889465,317.957428 323.728760,317.820557
|
||||
C320.936859,317.746521 319.866180,318.788818 319.924011,321.600494
|
||||
C320.054138,327.928345 319.546234,334.296967 320.086914,340.581085
|
||||
C320.648438,347.107666 318.824554,352.320465 313.837555,356.393951
|
||||
C310.164734,359.394012 310.566040,361.822052 314.039948,364.807373
|
||||
C325.376099,374.549133 336.565674,384.462585 347.740448,394.390106
|
||||
C349.794861,396.215210 351.391815,396.552277 353.008698,394.046967
|
||||
C359.091980,384.621399 365.184479,375.201752 371.632660,365.118713
|
||||
M186.296997,184.796967
|
||||
C184.926163,185.393723 183.470947,185.849045 182.197357,186.608795
|
||||
C167.070114,195.632812 153.923645,207.067444 142.476791,220.361557
|
||||
C138.408340,225.086548 135.374069,230.716568 131.994080,236.014099
|
||||
C130.109818,238.967316 130.255890,240.968063 134.605942,240.938873
|
||||
C147.591537,240.851669 160.578583,240.847214 173.563965,240.950241
|
||||
C176.742004,240.975479 178.128967,239.867004 178.034958,236.601685
|
||||
C177.891266,231.611160 178.113876,226.609787 177.958542,221.619965
|
||||
C177.850693,218.155563 179.285690,216.838531 182.705704,216.951675
|
||||
C187.694931,217.116745 192.694534,216.938385 197.688019,217.016098
|
||||
C200.234314,217.055710 201.883636,216.375702 201.872711,213.440521
|
||||
C201.834320,203.118408 201.879013,192.795975 201.834946,182.473907
|
||||
C201.821243,179.267227 200.385544,178.290359 197.347672,179.755463
|
||||
C193.926743,181.405304 190.417450,182.871918 186.296997,184.796967
|
||||
M225.850571,243.511566
|
||||
C225.844101,239.850021 225.845657,236.188461 225.828674,232.526962
|
||||
C225.814957,229.571564 224.751968,227.996597 221.357635,228.065796
|
||||
C212.420013,228.248016 203.474976,228.191528 194.535095,228.068909
|
||||
C191.428589,228.026306 189.913940,229.020706 189.947632,232.335815
|
||||
C190.044022,241.821625 190.036285,251.309631 189.943573,260.795532
|
||||
C189.913849,263.838531 191.235840,264.908844 194.160797,264.880554
|
||||
C203.147461,264.793610 212.135818,264.800751 221.122803,264.868164
|
||||
C224.538055,264.893768 225.924988,263.292877 225.876480,259.985229
|
||||
C225.800842,254.826813 225.853745,249.666519 225.850571,243.511566
|
||||
M298.077362,270.182892
|
||||
C297.428864,271.404083 296.238220,272.616486 296.218140,273.847900
|
||||
C296.066223,283.176270 296.134796,292.508362 296.154602,301.839264
|
||||
C296.161285,304.975830 297.761932,306.195587 300.892853,306.156067
|
||||
C309.889160,306.042542 318.889404,306.018890 327.884705,306.165649
|
||||
C331.152313,306.218964 332.291229,304.989594 332.245544,301.777557
|
||||
C332.115204,292.615051 332.092163,283.447510 332.259125,274.286194
|
||||
C332.318237,271.040771 331.254395,269.881866 327.968079,269.939819
|
||||
C318.306885,270.110229 308.641083,270.019623 298.077362,270.182892
|
||||
M213.899841,376.368378
|
||||
C213.900818,383.921570 213.900803,383.913757 221.593216,383.904236
|
||||
C229.089645,383.894958 236.589188,383.777710 244.081223,383.960114
|
||||
C247.616165,384.046234 249.246704,383.055389 249.161179,379.197662
|
||||
C248.958115,370.039734 248.978851,360.872498 249.137848,351.713013
|
||||
C249.199341,348.170959 247.779343,347.059479 244.395584,347.118408
|
||||
C235.902191,347.266418 227.400177,347.324707 218.910355,347.095581
|
||||
C214.866028,346.986450 213.723007,348.598572 213.830658,352.408661
|
||||
C214.047012,360.065643 213.898117,367.732941 213.899841,376.368378
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M274.042084,440.210175
|
||||
C236.151672,446.465576 201.547028,439.395081 170.330887,417.519104
|
||||
C158.418747,409.171173 148.201752,398.933044 139.477402,387.223145
|
||||
C137.599960,384.703308 137.509918,382.761627 139.847458,380.762756
|
||||
C150.701157,371.481476 161.500671,362.133209 172.549179,353.089386
|
||||
C173.586334,352.240356 176.908386,352.630096 178.016281,353.671570
|
||||
C185.297485,360.516357 192.408722,367.561646 199.211716,374.879639
|
||||
C200.654022,376.431122 201.096054,379.369720 201.142395,381.686310
|
||||
C201.409409,395.035522 201.322189,395.038605 214.788040,395.042053
|
||||
C228.452301,395.045563 242.116608,395.068176 255.780792,395.036896
|
||||
C261.131378,395.024628 261.885437,394.207397 261.144836,388.894226
|
||||
C260.570435,384.773285 262.160400,382.983582 266.311798,383.047882
|
||||
C272.975677,383.151093 279.663574,383.376953 286.296600,382.912292
|
||||
C288.702576,382.743774 291.539978,381.272888 293.226166,379.492371
|
||||
C304.823242,367.246796 301.925537,369.147339 311.618439,378.008453
|
||||
C320.201111,385.854614 328.932892,393.540649 337.709656,401.169861
|
||||
C341.099976,404.116913 341.795715,405.725067 337.661896,409.407410
|
||||
C321.399200,423.894073 302.473633,432.888306 281.745422,438.603668
|
||||
C279.358032,439.261932 276.895966,439.649353 274.042084,440.210175
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M233.000000,336.023010
|
||||
C223.836029,336.024445 215.165955,336.218903 206.510696,335.944946
|
||||
C202.250824,335.810150 200.948715,337.400299 201.169266,341.455719
|
||||
C201.438599,346.408020 201.233368,351.386139 201.233368,356.795410
|
||||
C200.081741,356.477173 199.324295,356.500122 198.961853,356.133087
|
||||
C192.880356,349.973999 186.720444,343.882721 180.892258,337.489777
|
||||
C179.382843,335.834106 178.338882,333.252716 178.143311,331.004150
|
||||
C177.726166,326.208038 178.126663,321.347137 177.956619,316.521088
|
||||
C177.836395,313.108643 179.108963,311.742523 182.610001,311.807495
|
||||
C191.270996,311.968231 199.940842,311.707977 208.599182,311.928925
|
||||
C212.545502,312.029694 213.988983,310.501892 213.937500,306.693878
|
||||
C213.822647,298.198090 214.034119,289.696655 213.840897,281.203705
|
||||
C213.754272,277.396240 214.893097,275.654480 218.956528,275.945038
|
||||
C222.935547,276.229584 226.949677,276.019287 230.948380,276.033264
|
||||
C237.575760,276.056458 237.845459,275.786621 237.859970,268.919373
|
||||
C237.868408,264.920654 238.046722,260.910095 237.798965,256.926636
|
||||
C237.576630,253.351913 238.575317,251.835114 242.491821,251.957062
|
||||
C251.147537,252.226547 259.825928,251.839676 268.474884,252.202133
|
||||
C270.573883,252.290100 273.170746,253.772110 274.535156,255.436020
|
||||
C277.678345,259.269165 280.410339,263.489410 282.892334,267.792999
|
||||
C284.068024,269.831543 284.813416,272.412811 284.851990,274.763000
|
||||
C285.046112,286.589478 284.916229,298.420898 284.952667,310.250427
|
||||
C284.978638,318.679932 283.999756,317.796783 292.300049,317.850220
|
||||
C296.465271,317.877014 300.634216,317.969086 304.794952,317.832214
|
||||
C307.843079,317.731903 308.940643,319.065887 308.903290,322.004150
|
||||
C308.809998,329.334015 309.019470,336.671143 308.759674,343.993042
|
||||
C308.697052,345.758453 307.673859,347.848846 306.426514,349.152771
|
||||
C300.093414,355.773041 293.718719,362.378296 286.956451,368.548065
|
||||
C284.950470,370.378296 281.713837,371.228546 278.902161,371.701385
|
||||
C276.183624,372.158508 273.260010,371.746429 270.471802,371.375519
|
||||
C261.087372,370.127258 261.093719,370.069214 261.090210,360.234344
|
||||
C261.087921,353.902985 260.934143,347.566315 261.148773,341.242249
|
||||
C261.279297,337.397217 259.986053,335.832947 255.991257,335.954651
|
||||
C248.500427,336.182892 240.997757,336.022003 233.000000,336.023010
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M225.998260,216.975098
|
||||
C213.913971,216.986603 213.910675,216.986603 213.903442,205.359756
|
||||
C213.897949,196.537811 213.986694,187.714676 213.863144,178.894470
|
||||
C213.808914,175.022842 215.221130,173.234451 219.280701,172.824982
|
||||
C228.822815,171.862457 238.302414,169.701630 247.842560,169.468674
|
||||
C256.545135,169.256149 265.338440,170.637329 274.007111,171.898193
|
||||
C279.870422,172.750992 285.569855,174.688721 291.375214,175.996246
|
||||
C295.408844,176.904739 297.106903,179.041595 296.979675,183.362503
|
||||
C296.705475,192.675598 297.149567,202.012863 296.761719,211.317810
|
||||
C296.641174,214.209595 294.830353,217.045822 293.695282,219.868042
|
||||
C293.466125,220.437729 292.791443,220.826767 292.326508,221.303314
|
||||
C271.669800,242.475281 271.671997,242.431732 242.108658,240.970367
|
||||
C238.831314,240.808350 237.706879,239.568451 237.815445,236.425873
|
||||
C237.970520,231.936935 237.611343,227.420914 237.932877,222.949615
|
||||
C238.261978,218.372772 236.626938,216.392303 231.976410,216.934296
|
||||
C230.169739,217.144852 228.318466,216.972504 225.998260,216.975098
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M371.453094,365.449280
|
||||
C365.184479,375.201752 359.091980,384.621399 353.008698,394.046967
|
||||
C351.391815,396.552277 349.794861,396.215210 347.740448,394.390106
|
||||
C336.565674,384.462585 325.376099,374.549133 314.039948,364.807373
|
||||
C310.566040,361.822052 310.164734,359.394012 313.837555,356.393951
|
||||
C318.824554,352.320465 320.648438,347.107666 320.086914,340.581085
|
||||
C319.546234,334.296967 320.054138,327.928345 319.924011,321.600494
|
||||
C319.866180,318.788818 320.936859,317.746521 323.728760,317.820557
|
||||
C328.889465,317.957428 334.061554,317.718414 339.218903,317.908264
|
||||
C342.598175,318.032654 344.170837,316.951538 343.936829,313.364960
|
||||
C343.720398,310.047455 343.985901,306.701233 343.869568,303.373566
|
||||
C343.778198,300.760101 344.922424,299.865967 347.448334,299.881836
|
||||
C358.609558,299.951996 369.771881,299.968201 380.932770,299.875000
|
||||
C383.989960,299.849487 384.964478,301.180969 385.051239,304.081818
|
||||
C385.693726,325.563324 381.424042,345.942200 371.453094,365.449280
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M186.621796,184.607605
|
||||
C190.417450,182.871918 193.926743,181.405304 197.347672,179.755463
|
||||
C200.385544,178.290359 201.821243,179.267227 201.834946,182.473907
|
||||
C201.879013,192.795975 201.834320,203.118408 201.872711,213.440521
|
||||
C201.883636,216.375702 200.234314,217.055710 197.688019,217.016098
|
||||
C192.694534,216.938385 187.694931,217.116745 182.705704,216.951675
|
||||
C179.285690,216.838531 177.850693,218.155563 177.958542,221.619965
|
||||
C178.113876,226.609787 177.891266,231.611160 178.034958,236.601685
|
||||
C178.128967,239.867004 176.742004,240.975479 173.563965,240.950241
|
||||
C160.578583,240.847214 147.591537,240.851669 134.605942,240.938873
|
||||
C130.255890,240.968063 130.109818,238.967316 131.994080,236.014099
|
||||
C135.374069,230.716568 138.408340,225.086548 142.476791,220.361557
|
||||
C153.923645,207.067444 167.070114,195.632812 182.197357,186.608795
|
||||
C183.470947,185.849045 184.926163,185.393723 186.621796,184.607605
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M225.851379,244.009308
|
||||
C225.853745,249.666519 225.800842,254.826813 225.876480,259.985229
|
||||
C225.924988,263.292877 224.538055,264.893768 221.122803,264.868164
|
||||
C212.135818,264.800751 203.147461,264.793610 194.160797,264.880554
|
||||
C191.235840,264.908844 189.913849,263.838531 189.943573,260.795532
|
||||
C190.036285,251.309631 190.044022,241.821625 189.947632,232.335815
|
||||
C189.913940,229.020706 191.428589,228.026306 194.535095,228.068909
|
||||
C203.474976,228.191528 212.420013,228.248016 221.357635,228.065796
|
||||
C224.751968,227.996597 225.814957,229.571564 225.828674,232.526962
|
||||
C225.845657,236.188461 225.844101,239.850021 225.851379,244.009308
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M298.527222,270.107056
|
||||
C308.641083,270.019623 318.306885,270.110229 327.968079,269.939819
|
||||
C331.254395,269.881866 332.318237,271.040771 332.259125,274.286194
|
||||
C332.092163,283.447510 332.115204,292.615051 332.245544,301.777557
|
||||
C332.291229,304.989594 331.152313,306.218964 327.884705,306.165649
|
||||
C318.889404,306.018890 309.889160,306.042542 300.892853,306.156067
|
||||
C297.761932,306.195587 296.161285,304.975830 296.154602,301.839264
|
||||
C296.134796,292.508362 296.066223,283.176270 296.218140,273.847900
|
||||
C296.238220,272.616486 297.428864,271.404083 298.527222,270.107056
|
||||
z"/>
|
||||
<path class="no-fill" fill="#e4e9ed" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M213.899902,375.882202
|
||||
C213.898117,367.732941 214.047012,360.065643 213.830658,352.408661
|
||||
C213.723007,348.598572 214.866028,346.986450 218.910355,347.095581
|
||||
C227.400177,347.324707 235.902191,347.266418 244.395584,347.118408
|
||||
C247.779343,347.059479 249.199341,348.170959 249.137848,351.713013
|
||||
C248.978851,360.872498 248.958115,370.039734 249.161179,379.197662
|
||||
C249.246704,383.055389 247.616165,384.046234 244.081223,383.960114
|
||||
C236.589188,383.777710 229.089645,383.894958 221.593216,383.904236
|
||||
C213.900803,383.913757 213.900818,383.921570 213.899902,375.882202
|
||||
z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 62 KiB |
133
packages/core/src/components/UI/ListItemTopBar.tsx
Normal file
133
packages/core/src/components/UI/ListItemTopBar.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import { transientOptions } from '@staticcms/core/lib/util';
|
||||
import { buttons, colors, lengths, transitions } from './styles';
|
||||
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
|
||||
interface TopBarProps {
|
||||
$isVariableTypesList: boolean;
|
||||
$collapsed: boolean;
|
||||
}
|
||||
|
||||
const TopBar = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<TopBarProps>(
|
||||
({ $isVariableTypesList, $collapsed }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 2px 8px;
|
||||
border-radius: ${
|
||||
!$isVariableTypesList
|
||||
? $collapsed
|
||||
? lengths.borderRadius
|
||||
: `${lengths.borderRadius} ${lengths.borderRadius} 0 0`
|
||||
: $collapsed
|
||||
? `0 ${lengths.borderRadius} ${lengths.borderRadius} ${lengths.borderRadius}`
|
||||
: `0 ${lengths.borderRadius} 0 0`
|
||||
};
|
||||
position: relative;
|
||||
`,
|
||||
);
|
||||
|
||||
const TopBarButton = styled('button')`
|
||||
${buttons.button};
|
||||
color: ${colors.controlLabel};
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled('div')`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 48px;
|
||||
line-height: 40px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const TopBarButtonSpan = TopBarButton.withComponent('span');
|
||||
|
||||
const DragIconContainer = styled(TopBarButtonSpan)`
|
||||
width: 100%;
|
||||
cursor: move;
|
||||
`;
|
||||
|
||||
export interface DragHandleProps {
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
}
|
||||
|
||||
const DragHandle = ({ listeners }: DragHandleProps) => {
|
||||
return (
|
||||
<DragIconContainer {...listeners}>
|
||||
<DragHandleIcon />
|
||||
</DragIconContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ListItemTopBarProps {
|
||||
className?: string;
|
||||
title: ReactNode;
|
||||
collapsed?: boolean;
|
||||
onCollapseToggle?: (event: MouseEvent) => void;
|
||||
onRemove: (event: MouseEvent) => void;
|
||||
isVariableTypesList?: boolean;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
}
|
||||
|
||||
const ListItemTopBar = ({
|
||||
className,
|
||||
title,
|
||||
collapsed = false,
|
||||
onCollapseToggle,
|
||||
onRemove,
|
||||
isVariableTypesList = false,
|
||||
listeners,
|
||||
}: ListItemTopBarProps) => {
|
||||
return (
|
||||
<TopBar className={className} $collapsed={collapsed} $isVariableTypesList={isVariableTypesList}>
|
||||
{onCollapseToggle ? (
|
||||
<IconButton onClick={onCollapseToggle} data-testid="expand-button">
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: `rotateZ(${collapsed ? '-90deg' : '0deg'})`,
|
||||
transition: `transform ${transitions.main};`,
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null}
|
||||
<StyledTitle key="title" onClick={onCollapseToggle}>
|
||||
{title}
|
||||
</StyledTitle>
|
||||
{listeners ? <DragHandle listeners={listeners} /> : null}
|
||||
{onRemove ? (
|
||||
<TopBarButton onClick={onRemove}>
|
||||
<CloseIcon />
|
||||
</TopBarButton>
|
||||
) : null}
|
||||
</TopBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItemTopBar;
|
59
packages/core/src/components/UI/Loader.tsx
Normal file
59
packages/core/src/components/UI/Loader.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const StyledLoader = styled('div')`
|
||||
position: fixed;
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
export interface LoaderProps {
|
||||
children: string | string[] | undefined;
|
||||
}
|
||||
|
||||
const Loader = ({ children }: LoaderProps) => {
|
||||
const [currentItem, setCurrentItem] = useState(0);
|
||||
|
||||
const text = useMemo(() => {
|
||||
if (!children) {
|
||||
return undefined;
|
||||
} else if (typeof children == 'string') {
|
||||
return children;
|
||||
} else if (Array.isArray(children)) {
|
||||
return currentItem < children.length ? children[currentItem] : undefined;
|
||||
}
|
||||
}, [children, currentItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(children)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const nextItem = currentItem === children?.length - 1 ? 0 : currentItem + 1;
|
||||
setCurrentItem(nextItem);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [children, currentItem]);
|
||||
|
||||
return (
|
||||
<StyledLoader>
|
||||
<CircularProgress />
|
||||
<Typography>{text}</Typography>
|
||||
</StyledLoader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
74
packages/core/src/components/UI/NavLink.tsx
Normal file
74
packages/core/src/components/UI/NavLink.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { NavLink as NavLinkBase } from 'react-router-dom';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
|
||||
import type { RefAttributes } from 'react';
|
||||
import type { NavLinkProps as RouterNavLinkProps } from 'react-router-dom';
|
||||
|
||||
export type NavLinkBaseProps = RouterNavLinkProps & RefAttributes<HTMLAnchorElement>;
|
||||
|
||||
export interface NavLinkProps extends RouterNavLinkProps {
|
||||
activeClassName?: string;
|
||||
}
|
||||
|
||||
interface StyledNavLinkProps {
|
||||
$activeClassName?: string;
|
||||
}
|
||||
|
||||
const StyledNavLinkWrapper = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<StyledNavLinkProps>(
|
||||
({ $activeClassName }) => `
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: ${colors.inactive};
|
||||
|
||||
:hover {
|
||||
color: ${colors.active};
|
||||
|
||||
.MuiListItemIcon-root {
|
||||
color: ${colors.active};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${
|
||||
$activeClassName
|
||||
? `
|
||||
& > .${$activeClassName} {
|
||||
color: ${colors.active};
|
||||
|
||||
.MuiListItemIcon-root {
|
||||
color: ${colors.active};
|
||||
}
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(
|
||||
({ activeClassName, ...props }, ref) => (
|
||||
<StyledNavLinkWrapper $activeClassName={activeClassName}>
|
||||
<NavLinkBase
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={({ isActive }) => (isActive ? activeClassName : '')}
|
||||
/>
|
||||
</StyledNavLinkWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
NavLink.displayName = 'NavLink';
|
||||
|
||||
export default NavLink;
|
168
packages/core/src/components/UI/ObjectWidgetTopBar.tsx
Normal file
168
packages/core/src/components/UI/ObjectWidgetTopBar.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import { colors, colorsRaw, transitions } from './styles';
|
||||
|
||||
import type { ObjectField, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
|
||||
const TopBarContainer = styled('div')`
|
||||
position: relative;
|
||||
align-items: center;
|
||||
background-color: ${colors.textFieldBorder};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 8px;
|
||||
`;
|
||||
|
||||
interface ExpandButtonContainerProps {
|
||||
$hasError: boolean;
|
||||
}
|
||||
|
||||
const ExpandButtonContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<ExpandButtonContainerProps>(
|
||||
({ $hasError }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4375em;
|
||||
letter-spacing: 0.00938em;
|
||||
${$hasError ? `color: ${colorsRaw.red}` : ''}
|
||||
`,
|
||||
);
|
||||
|
||||
export interface ObjectWidgetTopBarProps {
|
||||
allowAdd?: boolean;
|
||||
types?: ObjectField[];
|
||||
onAdd?: (event: MouseEvent) => void;
|
||||
onAddType?: (name: string) => void;
|
||||
onCollapseToggle: (event: MouseEvent) => void;
|
||||
collapsed: boolean;
|
||||
heading: ReactNode;
|
||||
label?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
const ObjectWidgetTopBar = ({
|
||||
allowAdd,
|
||||
types,
|
||||
onAdd,
|
||||
onAddType,
|
||||
onCollapseToggle,
|
||||
collapsed,
|
||||
heading,
|
||||
label,
|
||||
hasError = false,
|
||||
t,
|
||||
}: TranslatedProps<ObjectWidgetTopBarProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
const handleAddType = useCallback(
|
||||
(type: ObjectField) => () => {
|
||||
handleClose();
|
||||
onAddType?.(type.name);
|
||||
},
|
||||
[handleClose, onAddType],
|
||||
);
|
||||
|
||||
const renderTypesDropdown = useCallback(
|
||||
(types: ObjectField[]) => {
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="types-button"
|
||||
aria-controls={open ? 'types-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
endIcon={<AddIcon fontSize="small" />}
|
||||
>
|
||||
{t('editor.editorWidgets.list.addType', { item: label })}
|
||||
</Button>
|
||||
<Menu
|
||||
id="types-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'types-button',
|
||||
}}
|
||||
>
|
||||
{types.map((type, idx) =>
|
||||
type ? (
|
||||
<MenuItem key={idx} onClick={handleAddType(type)}>
|
||||
{type.label ?? type.name}
|
||||
</MenuItem>
|
||||
) : null,
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[open, handleClick, t, label, anchorEl, handleClose, handleAddType],
|
||||
);
|
||||
|
||||
const renderAddButton = useCallback(() => {
|
||||
return (
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
endIcon={<AddIcon fontSize="small" />}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{t('editor.editorWidgets.list.add', { item: label })}
|
||||
</Button>
|
||||
);
|
||||
}, [t, label, onAdd]);
|
||||
|
||||
const renderAddUI = useCallback(() => {
|
||||
if (!allowAdd) {
|
||||
return null;
|
||||
}
|
||||
if (types && types.length > 0) {
|
||||
return renderTypesDropdown(types);
|
||||
} else {
|
||||
return renderAddButton();
|
||||
}
|
||||
}, [allowAdd, types, renderTypesDropdown, renderAddButton]);
|
||||
|
||||
return (
|
||||
<TopBarContainer>
|
||||
<ExpandButtonContainer $hasError={hasError}>
|
||||
<IconButton onClick={onCollapseToggle} data-testid="expand-button">
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: `rotateZ(${collapsed ? '-90deg' : '0deg'})`,
|
||||
transition: `transform ${transitions.main};`,
|
||||
color: hasError ? colorsRaw.red : undefined,
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
{heading}
|
||||
</ExpandButtonContainer>
|
||||
{renderAddUI()}
|
||||
</TopBarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectWidgetTopBar;
|
60
packages/core/src/components/UI/Outline.tsx
Normal file
60
packages/core/src/components/UI/Outline.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import transientOptions from '@staticcms/core/lib/util/transientOptions';
|
||||
|
||||
interface StyledOutlineProps {
|
||||
$active: boolean;
|
||||
$hasError: boolean;
|
||||
$hasLabel: boolean;
|
||||
}
|
||||
|
||||
const StyledOutline = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<StyledOutlineProps>(
|
||||
({ $active, $hasError, $hasLabel }) => `
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
top: ${$hasLabel ? 22 : 0}px;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
overflow: hidden;
|
||||
min-width: 0%;
|
||||
border-color: rgba(0, 0, 0, 0.23);
|
||||
${
|
||||
$active
|
||||
? `
|
||||
border-color: #1976d2;
|
||||
border-width: 2px;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
$hasError
|
||||
? `
|
||||
border-color: #d32f2f;
|
||||
border-width: 2px;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
interface OutlineProps {
|
||||
active?: boolean;
|
||||
hasError?: boolean;
|
||||
hasLabel?: boolean;
|
||||
}
|
||||
|
||||
const Outline = ({ active = false, hasError = false, hasLabel = false }: OutlineProps) => {
|
||||
return <StyledOutline $active={active} $hasError={hasError} $hasLabel={hasLabel} />;
|
||||
};
|
||||
|
||||
export default Outline;
|
45
packages/core/src/components/UI/ScrollTop.tsx
Normal file
45
packages/core/src/components/UI/ScrollTop.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import Fade from '@mui/material/Fade';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import useScrollTrigger from '@mui/material/useScrollTrigger';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import type { ReactNode, MouseEvent } from 'react';
|
||||
|
||||
const StyledScrollTop = styled('div')`
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
`;
|
||||
|
||||
interface ScrollTopProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ScrollTop = ({ children }: ScrollTopProps) => {
|
||||
const trigger = useScrollTrigger({
|
||||
disableHysteresis: true,
|
||||
threshold: 100,
|
||||
});
|
||||
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
const anchor = ((event.target as HTMLDivElement).ownerDocument || document).querySelector(
|
||||
'#back-to-top-anchor',
|
||||
);
|
||||
|
||||
if (anchor) {
|
||||
anchor.scrollIntoView({
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Fade in={trigger}>
|
||||
<StyledScrollTop onClick={handleClick} role="presentation">
|
||||
{children}
|
||||
</StyledScrollTop>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollTop;
|
75
packages/core/src/components/UI/SettingsDropdown.tsx
Normal file
75
packages/core/src/components/UI/SettingsDropdown.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
interface AvatarImageProps {
|
||||
imageUrl: string | undefined;
|
||||
}
|
||||
|
||||
const AvatarImage = ({ imageUrl }: AvatarImageProps) => {
|
||||
return imageUrl ? (
|
||||
<Avatar sx={{ width: 32, height: 32 }} src={imageUrl} />
|
||||
) : (
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsDropdownProps {
|
||||
imageUrl?: string;
|
||||
onLogoutClick: () => void;
|
||||
}
|
||||
|
||||
const SettingsDropdown = ({
|
||||
imageUrl,
|
||||
onLogoutClick,
|
||||
t,
|
||||
}: TranslatedProps<SettingsDropdownProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip title="Account settings">
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
aria-controls={open ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
>
|
||||
<AvatarImage imageUrl={imageUrl} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
id="settings-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'settings-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={onLogoutClick}>{t('ui.settingsDropdown.logOut')}</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(SettingsDropdown);
|
18
packages/core/src/components/UI/WidgetPreviewContainer.tsx
Normal file
18
packages/core/src/components/UI/WidgetPreviewContainer.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const StyledWidgetPreviewContainer = styled('div')`
|
||||
margin: 15px 2px;
|
||||
`;
|
||||
|
||||
interface WidgetPreviewContainerProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const WidgetPreviewContainer = ({ children }: WidgetPreviewContainerProps) => {
|
||||
return <StyledWidgetPreviewContainer>{children}</StyledWidgetPreviewContainer>;
|
||||
};
|
||||
|
||||
export default WidgetPreviewContainer;
|
2
packages/core/src/components/UI/index.ts
Normal file
2
packages/core/src/components/UI/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as SettingsDropdown } from './SettingsDropdown';
|
550
packages/core/src/components/UI/styles.tsx
Normal file
550
packages/core/src/components/UI/styles.tsx
Normal file
@ -0,0 +1,550 @@
|
||||
import React from 'react';
|
||||
import { css, Global } from '@emotion/react';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const quantifier = '.cms-wrapper';
|
||||
|
||||
/**
|
||||
* Font Stacks
|
||||
*/
|
||||
const fonts = {
|
||||
primary: `
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol"
|
||||
`,
|
||||
mono: `
|
||||
'SFMono-Regular',
|
||||
Consolas,
|
||||
"Liberation Mono",
|
||||
Menlo,
|
||||
Courier,
|
||||
monospace;
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme Colors
|
||||
*/
|
||||
const colorsRaw = {
|
||||
white: '#fff',
|
||||
grayLight: '#eff0f4',
|
||||
gray: '#798291',
|
||||
grayDark: '#313d3e',
|
||||
blue: '#3a69c7',
|
||||
blueLight: '#e8f5fe',
|
||||
green: '#005614',
|
||||
greenLight: '#caef6f',
|
||||
brown: '#754e00',
|
||||
yellow: '#ffee9c',
|
||||
red: '#ff003b',
|
||||
redLight: '#fcefea',
|
||||
purple: '#70399f',
|
||||
purpleLight: '#f6d8ff',
|
||||
teal: '#17a2b8',
|
||||
tealLight: '#ddf5f9',
|
||||
};
|
||||
|
||||
const colors = {
|
||||
statusDraftText: colorsRaw.purple,
|
||||
statusDraftBackground: colorsRaw.purpleLight,
|
||||
statusReviewText: colorsRaw.brown,
|
||||
statusReviewBackground: colorsRaw.yellow,
|
||||
statusReadyText: colorsRaw.green,
|
||||
statusReadyBackground: colorsRaw.greenLight,
|
||||
text: colorsRaw.gray,
|
||||
textLight: colorsRaw.white,
|
||||
textLead: colorsRaw.grayDark,
|
||||
background: colorsRaw.grayLight,
|
||||
foreground: colorsRaw.white,
|
||||
active: colorsRaw.blue,
|
||||
activeBackground: colorsRaw.blueLight,
|
||||
inactive: colorsRaw.gray,
|
||||
button: colorsRaw.gray,
|
||||
buttonText: colorsRaw.white,
|
||||
inputBackground: colorsRaw.white,
|
||||
infoText: colorsRaw.blue,
|
||||
infoBackground: colorsRaw.blueLight,
|
||||
successText: colorsRaw.green,
|
||||
successBackground: colorsRaw.greenLight,
|
||||
warnText: colorsRaw.brown,
|
||||
warnBackground: colorsRaw.yellow,
|
||||
errorText: colorsRaw.red,
|
||||
errorBackground: colorsRaw.redLight,
|
||||
textFieldBorder: '#f7f9fc',
|
||||
controlLabel: '#7a8291',
|
||||
checkerboardLight: '#f2f2f2',
|
||||
checkerboardDark: '#e6e6e6',
|
||||
mediaDraftText: colorsRaw.purple,
|
||||
mediaDraftBackground: colorsRaw.purpleLight,
|
||||
};
|
||||
|
||||
const lengths = {
|
||||
topBarHeight: '56px',
|
||||
inputPadding: '16px 20px',
|
||||
borderRadius: '5px',
|
||||
richTextEditorMinHeight: '300px',
|
||||
borderWidth: '2px',
|
||||
pageMargin: '24px',
|
||||
objectWidgetTopBarContainerPadding: '0 14px 0',
|
||||
};
|
||||
|
||||
const borders = {
|
||||
textField: `solid ${lengths.borderWidth} ${colors.textFieldBorder}`,
|
||||
};
|
||||
|
||||
const transitions = {
|
||||
main: '.2s ease',
|
||||
};
|
||||
|
||||
const shadows = {
|
||||
drop: `
|
||||
&& {
|
||||
box-shadow: 0 2px 4px 0 rgba(19, 39, 48, 0.12);
|
||||
}
|
||||
`,
|
||||
dropMain: `
|
||||
&& {
|
||||
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1);
|
||||
}
|
||||
`,
|
||||
dropMiddle: `
|
||||
&& {
|
||||
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.15), 0 1px 3px 0 rgba(68, 74, 87, 0.3);
|
||||
}
|
||||
`,
|
||||
dropDeep: `
|
||||
&& {
|
||||
box-shadow: 0 4px 12px 0 rgba(68, 74, 87, 0.15), 0 1px 3px 0 rgba(68, 74, 87, 0.25);
|
||||
}
|
||||
`,
|
||||
inset: `
|
||||
&& {
|
||||
box-shadow: inset 0 0 4px rgba(68, 74, 87, 0.3);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const text = {
|
||||
fieldLabel: css`
|
||||
&& {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const gradients = {
|
||||
checkerboard: `
|
||||
linear-gradient(
|
||||
45deg,
|
||||
${colors.checkerboardDark} 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
${colors.checkerboardDark} 75%,
|
||||
${colors.checkerboardDark}
|
||||
)
|
||||
`,
|
||||
};
|
||||
|
||||
const effects = {
|
||||
checkerboard: css`
|
||||
&& {
|
||||
background-color: ${colors.checkerboardLight};
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
background-image: ${gradients.checkerboard}, ${gradients.checkerboard};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const badge = css`
|
||||
&& {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const backgroundBadge = css`
|
||||
&& {
|
||||
${badge};
|
||||
display: block;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
padding: 4px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const textBadge = css`
|
||||
&& {
|
||||
${badge};
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
`;
|
||||
|
||||
const card = css`
|
||||
&& {
|
||||
${shadows.dropMain};
|
||||
border-radius: 5px;
|
||||
background-color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const buttons = {
|
||||
button: css`
|
||||
&& {
|
||||
border: 0;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
default: css`
|
||||
&& {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
font-weight: 500;
|
||||
padding: 0 15px;
|
||||
background-color: ${colorsRaw.gray};
|
||||
color: ${colorsRaw.white};
|
||||
}
|
||||
`,
|
||||
medium: css`
|
||||
&& {
|
||||
height: 27px;
|
||||
line-height: 27px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
padding: 0 24px 0 14px;
|
||||
}
|
||||
`,
|
||||
small: css`
|
||||
&& {
|
||||
font-size: 13px;
|
||||
height: 23px;
|
||||
line-height: 23px;
|
||||
}
|
||||
`,
|
||||
gray: css`
|
||||
&& {
|
||||
background-color: ${colors.button};
|
||||
color: ${colors.buttonText};
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: ${colorsRaw.white};
|
||||
background-color: #555a65;
|
||||
}
|
||||
}
|
||||
`,
|
||||
grayText: css`
|
||||
&& {
|
||||
background-color: transparent;
|
||||
color: ${colorsRaw.gray};
|
||||
}
|
||||
`,
|
||||
green: css`
|
||||
&& {
|
||||
background-color: #aae31f;
|
||||
color: ${colorsRaw.green};
|
||||
}
|
||||
`,
|
||||
lightRed: css`
|
||||
&& {
|
||||
background-color: ${colorsRaw.redLight};
|
||||
color: ${colorsRaw.red};
|
||||
}
|
||||
`,
|
||||
lightBlue: css`
|
||||
&& {
|
||||
background-color: ${colorsRaw.blueLight};
|
||||
color: ${colorsRaw.blue};
|
||||
}
|
||||
`,
|
||||
lightTeal: css`
|
||||
&& {
|
||||
background-color: ${colorsRaw.tealLight};
|
||||
color: #1195aa;
|
||||
}
|
||||
`,
|
||||
teal: css`
|
||||
&& {
|
||||
background-color: ${colorsRaw.teal};
|
||||
color: ${colorsRaw.white};
|
||||
}
|
||||
`,
|
||||
disabled: css`
|
||||
&& {
|
||||
background-color: ${colorsRaw.grayLight};
|
||||
color: ${colorsRaw.gray};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const caret = css`
|
||||
color: ${colorsRaw.white};
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 5px solid transparent;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const components = {
|
||||
card,
|
||||
caretDown: css`
|
||||
${caret};
|
||||
border-top: 6px solid currentColor;
|
||||
border-bottom: 0;
|
||||
`,
|
||||
caretRight: css`
|
||||
${caret};
|
||||
border-left: 6px solid currentColor;
|
||||
border-right: 0;
|
||||
`,
|
||||
badge: css`
|
||||
&& {
|
||||
${backgroundBadge};
|
||||
color: ${colors.infoText};
|
||||
background-color: ${colors.infoBackground};
|
||||
}
|
||||
`,
|
||||
badgeSuccess: css`
|
||||
&& {
|
||||
${backgroundBadge};
|
||||
color: ${colors.successText};
|
||||
background-color: ${colors.successBackground};
|
||||
}
|
||||
`,
|
||||
badgeDanger: css`
|
||||
&& {
|
||||
${backgroundBadge};
|
||||
color: ${colorsRaw.red};
|
||||
background-color: #fbe0d7;
|
||||
}
|
||||
`,
|
||||
textBadge: css`
|
||||
&& {
|
||||
${textBadge};
|
||||
color: ${colors.infoText};
|
||||
}
|
||||
`,
|
||||
textBadgeSuccess: css`
|
||||
&& {
|
||||
${textBadge};
|
||||
color: ${colors.successText};
|
||||
}
|
||||
`,
|
||||
textBadgeDanger: css`
|
||||
&& {
|
||||
${textBadge};
|
||||
color: ${colorsRaw.red};
|
||||
}
|
||||
`,
|
||||
loaderSize: css`
|
||||
&& {
|
||||
width: 2.28571429rem;
|
||||
height: 2.28571429rem;
|
||||
}
|
||||
`,
|
||||
cardTop: css`
|
||||
&& {
|
||||
${card};
|
||||
max-width: 100%;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
`,
|
||||
cardTopHeading: css`
|
||||
&& {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 37px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
`,
|
||||
cardTopDescription: css`
|
||||
&& {
|
||||
color: ${colors.text};
|
||||
font-size: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
`,
|
||||
objectWidgetTopBarContainer: css`
|
||||
&& {
|
||||
padding: ${lengths.objectWidgetTopBarContainerPadding};
|
||||
}
|
||||
`,
|
||||
dropdownList: css`
|
||||
&& {
|
||||
${shadows.dropDeep};
|
||||
background-color: ${colorsRaw.white};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
dropdownItem: css`
|
||||
&& {
|
||||
${buttons.button};
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
color: ${colorsRaw.gray};
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #eaebf1;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: max-content;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewControlsText: css`
|
||||
&& {
|
||||
font-size: 14px;
|
||||
color: ${colors.text};
|
||||
margin-right: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export interface OptionStyleState {
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
export interface IndicatorSeparatorStyleState {
|
||||
hasValue: boolean;
|
||||
selectProps: {
|
||||
isClearable: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const zIndex = {
|
||||
zIndex0: 0,
|
||||
zIndex1: 1,
|
||||
zIndex2: 2,
|
||||
zIndex10: 10,
|
||||
zIndex100: 100,
|
||||
zIndex200: 200,
|
||||
zIndex299: 299,
|
||||
zIndex300: 300,
|
||||
zIndex1000: 1000,
|
||||
zIndex1001: 1001,
|
||||
zIndex1002: 1002,
|
||||
};
|
||||
|
||||
const reactSelectStyles = {
|
||||
control: (styles: CSSProperties) => ({
|
||||
...styles,
|
||||
border: 0,
|
||||
boxShadow: 'none',
|
||||
padding: '9px 0 9px 12px',
|
||||
}),
|
||||
option: (styles: CSSProperties, state: OptionStyleState) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isSelected
|
||||
? `${colors.active}`
|
||||
: state.isFocused
|
||||
? `${colors.activeBackground}`
|
||||
: 'transparent',
|
||||
paddingLeft: '22px',
|
||||
}),
|
||||
menu: (styles: CSSProperties) => ({ ...styles, right: 0, zIndex: zIndex.zIndex300 }),
|
||||
container: (styles: CSSProperties) => ({ ...styles, padding: '0' }),
|
||||
indicatorSeparator: (styles: CSSProperties, state: IndicatorSeparatorStyleState) =>
|
||||
state.hasValue && state.selectProps.isClearable
|
||||
? { ...styles, backgroundColor: `${colors.textFieldBorder}` }
|
||||
: { display: 'none' },
|
||||
dropdownIndicator: (styles: CSSProperties) => ({ ...styles, color: `${colors.controlLabel}` }),
|
||||
clearIndicator: (styles: CSSProperties) => ({ ...styles, color: `${colors.controlLabel}` }),
|
||||
multiValue: (styles: CSSProperties) => ({
|
||||
...styles,
|
||||
backgroundColor: colors.background,
|
||||
}),
|
||||
multiValueLabel: (styles: CSSProperties) => ({
|
||||
...styles,
|
||||
color: colors.textLead,
|
||||
fontWeight: 500,
|
||||
}),
|
||||
multiValueRemove: (styles: CSSProperties) => ({
|
||||
...styles,
|
||||
color: colors.controlLabel,
|
||||
':hover': {
|
||||
color: colors.errorText,
|
||||
backgroundColor: colors.errorBackground,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
function GlobalStyles() {
|
||||
return (
|
||||
<Global
|
||||
styles={css`
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
${quantifier} {
|
||||
background-color: ${colors.background};
|
||||
margin: 0;
|
||||
|
||||
.ol-viewport {
|
||||
position: absolute !important;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
fonts,
|
||||
colorsRaw,
|
||||
colors,
|
||||
lengths,
|
||||
components,
|
||||
buttons,
|
||||
text,
|
||||
shadows,
|
||||
borders,
|
||||
transitions,
|
||||
effects,
|
||||
zIndex,
|
||||
reactSelectStyles,
|
||||
GlobalStyles,
|
||||
};
|
80
packages/core/src/components/page/Page.tsx
Normal file
80
packages/core/src/components/page/Page.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getAdditionalLink } from '@staticcms/core/lib/registry';
|
||||
import MainView from '../App/MainView';
|
||||
import Sidebar from '../Collection/Sidebar';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
|
||||
const StyledPageContent = styled('div')`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Page = ({ collections, isSearchEnabled, searchTerm, filterTerm }: PageProps) => {
|
||||
const { id } = useParams();
|
||||
const Content = useMemo(() => {
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const page = getAdditionalLink(id);
|
||||
if (!page) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return page.data;
|
||||
}, [id]);
|
||||
|
||||
const pageContent = useMemo(() => {
|
||||
if (!Content) {
|
||||
return <StyledPageContent>Page not found</StyledPageContent>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPageContent>
|
||||
<Content />
|
||||
</StyledPageContent>
|
||||
);
|
||||
}, [Content]);
|
||||
|
||||
return (
|
||||
<MainView>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={false}
|
||||
isSearchEnabled={isSearchEnabled}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
{pageContent}
|
||||
</MainView>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { collections } = state;
|
||||
const isSearchEnabled = state.config.config && state.config.config.search != false;
|
||||
|
||||
return {
|
||||
collections,
|
||||
isSearchEnabled,
|
||||
searchTerm: '',
|
||||
filterTerm: '',
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type PageProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Page) as ComponentType<PageProps>);
|
90
packages/core/src/components/snackbar/Snackbars.tsx
Normal file
90
packages/core/src/components/snackbar/Snackbars.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import { removeSnackbarById, selectSnackbars } from '@staticcms/core/store/slices/snackbars';
|
||||
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { SnackbarMessage } from '@staticcms/core/store/slices/snackbars';
|
||||
import type { SyntheticEvent } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface SnackbarsProps {}
|
||||
|
||||
const Snackbars = ({ t }: TranslatedProps<SnackbarsProps>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messageInfo, setMessageInfo] = useState<SnackbarMessage | undefined>(undefined);
|
||||
|
||||
const snackbars = useAppSelector(selectSnackbars);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (snackbars.length && !messageInfo) {
|
||||
// Set a new snack when we don't have an active one
|
||||
const snackbar = { ...snackbars[0] };
|
||||
setMessageInfo(snackbar);
|
||||
dispatch(removeSnackbarById(snackbar.id));
|
||||
setOpen(true);
|
||||
} else if (snackbars.length && messageInfo && open) {
|
||||
// Close an active snack when a new one is added
|
||||
setOpen(false);
|
||||
}
|
||||
}, [snackbars, messageInfo, open, dispatch]);
|
||||
|
||||
const handleClose = useCallback((_event?: SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExited = () => {
|
||||
setMessageInfo(undefined);
|
||||
};
|
||||
|
||||
const renderAlert = useCallback(
|
||||
(data: SnackbarMessage) => {
|
||||
const { type, message } = data;
|
||||
|
||||
let renderedMessage: string;
|
||||
if (typeof message === 'string') {
|
||||
renderedMessage = message;
|
||||
} else {
|
||||
const { key, options } = message;
|
||||
renderedMessage = t(key, options);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert key="message" onClose={handleClose} severity={type} sx={{ width: '100%' }}>
|
||||
{renderedMessage}
|
||||
</Alert>
|
||||
);
|
||||
},
|
||||
[handleClose, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
key={messageInfo ? messageInfo.id : undefined}
|
||||
open={open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleClose}
|
||||
TransitionProps={{ onExited: handleExited }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
action={
|
||||
<IconButton aria-label="close" color="inherit" sx={{ p: 0.5 }} onClick={handleClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{messageInfo ? renderAlert(messageInfo) : undefined}
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Snackbars);
|
Reference in New Issue
Block a user