feat: ui overhaul (#676)
This commit is contained in:
committed by
GitHub
parent
5c86462859
commit
66b81e9228
@ -1,6 +1,4 @@
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
@ -19,19 +17,19 @@ import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
import { loginUser as loginUserAction } from '@staticcms/core/actions/auth';
|
||||
import { discardDraft } from '@staticcms/core/actions/entries';
|
||||
import { currentBackend } from '@staticcms/core/backend';
|
||||
import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles';
|
||||
import { useAppDispatch } from '@staticcms/core/store/hooks';
|
||||
import { getDefaultPath } from '../../lib/util/collection.util';
|
||||
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 { changeTheme } from '../actions/globalUI';
|
||||
import { getDefaultPath } from '../lib/util/collection.util';
|
||||
import { selectTheme } from '../reducers/selectors/globalUI';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import CollectionRoute from './collections/CollectionRoute';
|
||||
import { Alert } from './common/alert/Alert';
|
||||
import { Confirm } from './common/confirm/Confirm';
|
||||
import Loader from './common/progress/Loader';
|
||||
import EditorRoute from './entry-editor/EditorRoute';
|
||||
import MediaPage from './media-library/MediaPage';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import Page from './page/Page';
|
||||
import Snackbars from './snackbar/Snackbars';
|
||||
|
||||
import type { Credentials, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
@ -40,35 +38,16 @@ import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
0: '#000',
|
||||
'1.0': '#000',
|
||||
},
|
||||
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;
|
||||
`;
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
|
||||
function CollectionSearchRedirect() {
|
||||
const { name } = useParams();
|
||||
@ -87,24 +66,43 @@ const App = ({
|
||||
collections,
|
||||
loginUser,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
t,
|
||||
scrollSyncEnabled,
|
||||
}: TranslatedProps<AppProps>) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const mode = useAppSelector(selectTheme);
|
||||
|
||||
const theme = React.useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: 'rgb(37 99 235)',
|
||||
},
|
||||
...(mode === 'dark' && {
|
||||
background: {
|
||||
paper: 'rgb(15 23 42)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
[mode],
|
||||
);
|
||||
|
||||
const configError = useCallback(
|
||||
(error?: string) => {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<div>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{error ?? config.error}</ErrorCodeBlock>
|
||||
<div>{error ?? config.error}</div>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[config.error, t],
|
||||
@ -140,20 +138,18 @@ const App = ({
|
||||
}
|
||||
|
||||
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={() => navigate('/', { replace: true })}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<AuthComponent
|
||||
key="auth-page"
|
||||
onLogin={handleLogin}
|
||||
error={auth.error}
|
||||
inProgress={auth.isFetching}
|
||||
siteId={config.config.backend.site_domain}
|
||||
base_url={config.config.backend.base_url}
|
||||
authEndpoint={config.config.backend.auth_endpoint}
|
||||
config={config.config}
|
||||
clearHash={() => navigate('/', { replace: true })}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]);
|
||||
|
||||
@ -173,6 +169,21 @@ const App = ({
|
||||
dispatch(discardDraft());
|
||||
}, [dispatch, pathname, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||
if (
|
||||
localStorage.getItem('color-theme') === 'dark' ||
|
||||
(!('color-theme' in localStorage) &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
document.documentElement.classList.add('dark');
|
||||
dispatch(changeTheme('dark'));
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
dispatch(changeTheme('light'));
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!user) {
|
||||
return authenticationPage;
|
||||
@ -189,11 +200,8 @@ const App = ({
|
||||
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" element={<CollectionRoute />} />
|
||||
<Route path="/collections/:name" element={<CollectionRoute />} />
|
||||
<Route
|
||||
path="/collections/:name/new"
|
||||
element={<EditorRoute collections={collections} newRecord />}
|
||||
@ -204,26 +212,18 @@ const App = ({
|
||||
/>
|
||||
<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 />}
|
||||
element={<CollectionRoute isSearchResults isSingleSearchResult />}
|
||||
/>
|
||||
<Route path="/collections/:name/filter/:filterTerm" element={<CollectionRoute />} />
|
||||
<Route path="/search/:searchTerm" element={<CollectionRoute isSearchResults />} />
|
||||
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
|
||||
<Route path="/page/:id" element={<Page />} />
|
||||
<Route path="/media" element={<MediaPage />} />
|
||||
<Route element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
</>
|
||||
);
|
||||
}, [authenticationPage, collections, defaultPath, isFetching, useMediaLibrary, user]);
|
||||
}, [authenticationPage, collections, defaultPath, isFetching, user]);
|
||||
|
||||
if (!config.config) {
|
||||
return configError(t('app.app.configNotFound'));
|
||||
@ -238,35 +238,28 @@ const App = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles key="global-styles" />
|
||||
<ThemeProvider theme={theme}>
|
||||
<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">
|
||||
<div key="cms-root" id="cms-root" className="h-full">
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ScrollSync>
|
||||
</>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const { auth, config, collections, globalUI, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const useMediaLibrary = !mediaLibrary.externalLibrary;
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
@ -274,7 +267,6 @@ function mapStateToProps(state: RootState) {
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
@ -1,213 +0,0 @@
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '@staticcms/core/actions/auth';
|
||||
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, getNewEntryUrl } 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 navigate = useNavigate();
|
||||
|
||||
const creatableCollections = 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>
|
||||
{creatableCollections.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',
|
||||
}}
|
||||
>
|
||||
{creatableCollections.map(collection => (
|
||||
<MenuItem
|
||||
key={collection.name}
|
||||
onClick={() => navigate(getNewEntryUrl(collection.name))}
|
||||
>
|
||||
{collection.label_singular || collection.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{isTestRepo && (
|
||||
<Button
|
||||
href="https://staticcms.org/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>);
|
@ -1,49 +0,0 @@
|
||||
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;
|
@ -1,78 +0,0 @@
|
||||
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);
|
@ -1,128 +0,0 @@
|
||||
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, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
|
||||
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
import { getPreviewCard } from '@staticcms/core/lib/registry';
|
||||
import {
|
||||
selectEntryCollectionTitle,
|
||||
selectFields,
|
||||
selectTemplateName,
|
||||
} from '@staticcms/core/lib/util/collection.util';
|
||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import useWidgetsFor from '../../common/widget/useWidgetsFor';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
|
||||
|
||||
export interface EntryCardProps {
|
||||
entry: Entry;
|
||||
imageFieldName?: string | null | undefined;
|
||||
collection: Collection;
|
||||
collectionLabel?: string;
|
||||
viewStyle?: CollectionViewStyle;
|
||||
}
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
imageFieldName,
|
||||
}: EntryCardProps) => {
|
||||
const entryData = entry.data;
|
||||
|
||||
const path = useMemo(
|
||||
() => `/collections/${collection.name}/entries/${entry.slug}`,
|
||||
[collection.name, entry.slug],
|
||||
);
|
||||
|
||||
const imageField = useMemo(
|
||||
() =>
|
||||
'fields' in collection
|
||||
? (collection.fields?.find(
|
||||
f => f.name === imageFieldName && f.widget === 'image',
|
||||
) as FileOrImageField)
|
||||
: undefined,
|
||||
[collection, imageFieldName],
|
||||
);
|
||||
|
||||
const image = useMemo(() => {
|
||||
let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined;
|
||||
|
||||
if (i) {
|
||||
i = encodeURI(i.trim());
|
||||
}
|
||||
|
||||
return i;
|
||||
}, [entryData, imageFieldName]);
|
||||
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
|
||||
const fields = selectFields(collection, entry.slug);
|
||||
const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
|
||||
|
||||
const config = useAppSelector(selectConfig);
|
||||
|
||||
const { widgetFor, widgetsFor } = useWidgetsFor(config, collection, fields, entry);
|
||||
|
||||
const PreviewCardComponent = useMemo(
|
||||
() => getPreviewCard(selectTemplateName(collection, entry.slug)) ?? null,
|
||||
[collection, entry.slug],
|
||||
);
|
||||
|
||||
if (PreviewCardComponent) {
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea
|
||||
component={Link}
|
||||
to={path}
|
||||
sx={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
<PreviewCardComponent
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
entry={entry}
|
||||
viewStyle={viewStyle === VIEW_STYLE_LIST ? 'list' : 'grid'}
|
||||
widgetFor={widgetFor}
|
||||
widgetsFor={widgetsFor}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryCard;
|
@ -1,86 +0,0 @@
|
||||
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 = Boolean(viewFilter.id && 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);
|
@ -1,80 +0,0 @@
|
||||
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);
|
@ -1,188 +0,0 @@
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
|
||||
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 navigate = useNavigate();
|
||||
function searchCollections(query: string, collection?: string) {
|
||||
if (collection) {
|
||||
navigate(`/collections/${collection}/search/${query}`);
|
||||
} else {
|
||||
navigate(`/search/${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -1,122 +0,0 @@
|
||||
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);
|
@ -1,44 +0,0 @@
|
||||
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;
|
@ -1,243 +0,0 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
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 {
|
||||
getI18nInfo,
|
||||
getLocaleDataPath,
|
||||
hasI18n,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isFieldTranslatable,
|
||||
} from '@staticcms/core/lib/i18n';
|
||||
import EditorControl from './EditorControl';
|
||||
|
||||
import type { ButtonProps } from '@mui/material/Button';
|
||||
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;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const DefaultLocaleWritingIn = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36.5px;
|
||||
`;
|
||||
|
||||
interface LocaleDropdownProps {
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
dropdownText: string;
|
||||
color: ButtonProps['color'];
|
||||
canChangeLocale: boolean;
|
||||
onLocaleChange?: (locale: string) => void;
|
||||
}
|
||||
|
||||
const LocaleDropdown = ({
|
||||
locales,
|
||||
defaultLocale,
|
||||
dropdownText,
|
||||
color,
|
||||
canChangeLocale,
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const handleLocaleChange = useCallback(
|
||||
(locale: string) => {
|
||||
onLocaleChange?.(locale);
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, onLocaleChange],
|
||||
);
|
||||
|
||||
if (!canChangeLocale) {
|
||||
return <DefaultLocaleWritingIn>{dropdownText}</DefaultLocaleWritingIn>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
color={color}
|
||||
>
|
||||
{dropdownText}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{locales
|
||||
.filter(locale => locale !== defaultLocale)
|
||||
.map(locale => (
|
||||
<MenuItem
|
||||
key={locale}
|
||||
onClick={() => handleLocaleChange(locale)}
|
||||
sx={{ minWidth: '80px' }}
|
||||
>
|
||||
{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,
|
||||
locale,
|
||||
canChangeLocale = false,
|
||||
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]);
|
||||
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry || entry.partial === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlPaneContainer>
|
||||
{i18n?.locales && locale ? (
|
||||
<LocaleRowWrapper>
|
||||
<LocaleDropdown
|
||||
locales={i18n.locales}
|
||||
defaultLocale={i18n.defaultLocale}
|
||||
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
|
||||
locale: locale?.toUpperCase(),
|
||||
})}
|
||||
color="primary"
|
||||
canChangeLocale={canChangeLocale}
|
||||
onLocaleChange={onLocaleChange}
|
||||
/>
|
||||
</LocaleRowWrapper>
|
||||
) : null}
|
||||
{fields.map(field => {
|
||||
const isTranslatable = isFieldTranslatable(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}
|
||||
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;
|
||||
canChangeLocale?: boolean;
|
||||
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);
|
@ -1,382 +0,0 @@
|
||||
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);
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const PreviewPaneContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<PreviewPaneContainerProps>(
|
||||
({ $blockEntry }) => `
|
||||
height: 100%;
|
||||
pointer-events: ${$blockEntry ? 'none' : 'auto'};
|
||||
overflow-y: auto;
|
||||
`,
|
||||
);
|
||||
|
||||
interface ControlPaneContainerProps {
|
||||
$hidden?: boolean;
|
||||
}
|
||||
|
||||
const ControlPaneContainer = styled(
|
||||
PreviewPaneContainer,
|
||||
transientOptions,
|
||||
)<ControlPaneContainerProps>(
|
||||
({ $hidden = false }) => `
|
||||
padding: 24px 16px 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
display: ${$hidden ? 'none' : '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) => 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<void>;
|
||||
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<string>(locales?.[1] ?? 'en');
|
||||
|
||||
const handleOnPersist = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
// await switchToDefaultLocale();
|
||||
onPersist({ createNew, duplicate });
|
||||
},
|
||||
[onPersist],
|
||||
);
|
||||
|
||||
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 key={defaultLocale} id="control-pane">
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={defaultLocale}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const editorLocale = useMemo(
|
||||
() =>
|
||||
(locales ?? [])
|
||||
.filter(locale => locale !== defaultLocale)
|
||||
.map(locale => (
|
||||
<ControlPaneContainer key={locale} $hidden={locale !== selectedLocale}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={locale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
canChangeLocale
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
)),
|
||||
[
|
||||
collection,
|
||||
defaultLocale,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
handleLocaleChange,
|
||||
locales,
|
||||
selectedLocale,
|
||||
submitted,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const previewEntry = collectionI18nEnabled
|
||||
? getPreviewEntry(entry, selectedLocale[0], 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;
|
@ -1,44 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { FrameContextConsumer } from 'react-frame-component';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import EditorPreviewContent from './EditorPreviewContent';
|
||||
|
||||
import type {
|
||||
EntryData,
|
||||
TemplatePreviewComponent,
|
||||
TemplatePreviewProps,
|
||||
UnknownField,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { FC } from 'react';
|
||||
|
||||
interface PreviewFrameContentProps {
|
||||
previewComponent: TemplatePreviewComponent<EntryData, UnknownField>;
|
||||
previewProps: Omit<TemplatePreviewProps<EntryData, UnknownField>, 'document' | 'window'>;
|
||||
}
|
||||
|
||||
const PreviewFrameContent: FC<PreviewFrameContentProps> = ({ previewComponent, previewProps }) => {
|
||||
const ref = useRef<HTMLElement>();
|
||||
|
||||
return (
|
||||
<FrameContextConsumer>
|
||||
{context => {
|
||||
if (!ref.current) {
|
||||
ref.current = context.document?.scrollingElement as HTMLElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollSyncPane key="preview-frame-scroll-sync" attachTo={ref}>
|
||||
<EditorPreviewContent
|
||||
key="preview-frame-content"
|
||||
previewComponent={previewComponent}
|
||||
previewProps={{ ...previewProps, document: context.document, window: context.window }}
|
||||
/>
|
||||
</ScrollSyncPane>
|
||||
);
|
||||
}}
|
||||
</FrameContextConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewFrameContent;
|
@ -1,305 +0,0 @@
|
||||
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);
|
@ -1,4 +1,3 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import cleanStack from 'clean-stack';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import truncate from 'lodash/truncate';
|
||||
@ -6,11 +5,10 @@ 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';
|
||||
import type { ComponentClass, ReactNode } from 'react';
|
||||
|
||||
const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?';
|
||||
|
||||
@ -38,14 +36,14 @@ ${config}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildIssueTemplate(config: Config) {
|
||||
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,
|
||||
config?.backend?.name ?? 'Unknown',
|
||||
navigator.userAgent,
|
||||
yaml.stringify(config),
|
||||
);
|
||||
@ -53,7 +51,7 @@ function buildIssueTemplate(config: Config) {
|
||||
return template;
|
||||
}
|
||||
|
||||
function buildIssueUrl(title: string, config: Config) {
|
||||
function buildIssueUrl(title: string, config?: Config) {
|
||||
try {
|
||||
const body = buildIssueTemplate(config);
|
||||
|
||||
@ -69,48 +67,6 @@ function buildIssueUrl(title: string, config: Config) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -122,9 +78,9 @@ const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
|
||||
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
|
||||
<CopyButton onClick={() => copyToClipboard(entry)}>
|
||||
<button onClick={() => copyToClipboard(entry)}>
|
||||
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
|
||||
</CopyButton>
|
||||
</button>
|
||||
<pre>
|
||||
<code>{entry}</code>
|
||||
</pre>
|
||||
@ -134,7 +90,7 @@ const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
config: Config;
|
||||
config?: Config;
|
||||
showBackup?: boolean;
|
||||
}
|
||||
|
||||
@ -145,10 +101,7 @@ interface ErrorBoundaryState {
|
||||
backup: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
TranslatedProps<ErrorBoundaryProps>,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = {
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
@ -194,7 +147,7 @@ export class ErrorBoundary extends Component<
|
||||
return this.props.children;
|
||||
}
|
||||
return (
|
||||
<ErrorBoundaryContainer key="error-boundary-container">
|
||||
<div key="error-boundary-container">
|
||||
<h1>{t('ui.errorBoundary.title')}</h1>
|
||||
<p>
|
||||
<span>{t('ui.errorBoundary.details')}</span>
|
||||
@ -211,7 +164,7 @@ export class ErrorBoundary extends Component<
|
||||
{t('ui.errorBoundary.privacyWarning')
|
||||
.split('\n')
|
||||
.map((item, index) => [
|
||||
<PrivacyWarning key={`private-warning-${index}`}>{item}</PrivacyWarning>,
|
||||
<span key={`private-warning-${index}`}>{item}</span>,
|
||||
<br key={`break-${index}`} />,
|
||||
])}
|
||||
</p>
|
||||
@ -219,9 +172,9 @@ export class ErrorBoundary extends Component<
|
||||
<h2>{t('ui.errorBoundary.detailsHeading')}</h2>
|
||||
<p>{errorMessage}</p>
|
||||
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
|
||||
</ErrorBoundaryContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(ErrorBoundary);
|
||||
export default translate()(ErrorBoundary) as ComponentClass<ErrorBoundaryProps>;
|
63
packages/core/src/components/MainView.tsx
Normal file
63
packages/core/src/components/MainView.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import classNames from '../lib/util/classNames.util';
|
||||
import Navbar from './navbar/Navbar';
|
||||
import Sidebar from './navbar/Sidebar';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Breadcrumb } from '../interface';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: '#000',
|
||||
'1.0': '#000',
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
interface MainViewProps {
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
showQuickCreate?: boolean;
|
||||
navbarActions?: ReactNode;
|
||||
showLeftNav?: boolean;
|
||||
noMargin?: boolean;
|
||||
noScroll?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MainView = ({
|
||||
children,
|
||||
breadcrumbs,
|
||||
showQuickCreate = false,
|
||||
showLeftNav = false,
|
||||
noMargin = false,
|
||||
noScroll = false,
|
||||
navbarActions,
|
||||
}: MainViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
breadcrumbs={breadcrumbs}
|
||||
showQuickCreate={showQuickCreate}
|
||||
navbarActions={navbarActions}
|
||||
/>
|
||||
<div className="flex bg-slate-50 dark:bg-slate-900">
|
||||
{showLeftNav ? <Sidebar /> : null}
|
||||
<div
|
||||
className={classNames(
|
||||
showLeftNav ? 'w-main left-64' : 'w-full',
|
||||
!noMargin && 'px-5 py-4',
|
||||
noScroll ? 'overflow-hidden' : 'overflow-y-auto',
|
||||
'h-main relative',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainView;
|
@ -1,24 +0,0 @@
|
||||
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;
|
@ -1,398 +0,0 @@
|
||||
import fuzzy from 'fuzzy';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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 MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent';
|
||||
import { selectMediaFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
|
||||
import alert from '../UI/Alert';
|
||||
import confirm from '../UI/Confirm';
|
||||
import MediaLibraryModal from './MediaLibraryModal';
|
||||
|
||||
import type { MediaFile } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
/**
|
||||
* 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: mediaConfig,
|
||||
loadMedia,
|
||||
dynamicSearchQuery,
|
||||
page,
|
||||
persistMedia,
|
||||
deleteMedia,
|
||||
insertMedia,
|
||||
closeMediaLibrary,
|
||||
collection,
|
||||
field,
|
||||
}: 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('');
|
||||
loadMedia();
|
||||
} else if (prevIsVisible && !isVisible) {
|
||||
window.dispatchEvent(new MediaLibraryCloseEvent());
|
||||
}
|
||||
|
||||
setPrevIsVisible(isVisible);
|
||||
}, [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 | null>(null);
|
||||
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 mediaConfig.max_file_size === 'number' ? mediaConfig.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 = '';
|
||||
}
|
||||
},
|
||||
[mediaConfig.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.onDeleteBody',
|
||||
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: MediaFile[]): MediaFile[] => {
|
||||
/**
|
||||
* 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 });
|
||||
return matches.map((match, queryIndex) => {
|
||||
const file = files[match.index];
|
||||
return { ...file, queryIndex };
|
||||
}) as MediaFile[];
|
||||
}, []);
|
||||
|
||||
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 ?? undefined}
|
||||
handleFilter={filterImages}
|
||||
handleQuery={queryFilter}
|
||||
toTableData={toTableData}
|
||||
handleClose={handleClose}
|
||||
handleSearchChange={handleSearchChange}
|
||||
handleSearchKeyDown={handleSearchKeyDown}
|
||||
handlePersist={handlePersist}
|
||||
handleDelete={handleDelete}
|
||||
handleInsert={handleInsert}
|
||||
handleDownload={handleDownload}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
handleAssetClick={handleAssetClick}
|
||||
handleLoadMore={handleLoadMore}
|
||||
displayURLs={displayURLs}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
collection={collection}
|
||||
field={field}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
collection: mediaLibrary.collection,
|
||||
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(MediaLibrary);
|
@ -1,146 +0,0 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { borders, colors, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
|
||||
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
import transientOptions from '@staticcms/core/lib/util/transientOptions';
|
||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
|
||||
import type { MediaLibraryDisplayURL } from '@staticcms/core/reducers/mediaLibrary';
|
||||
import type { Field, Collection } from '@staticcms/core/interface';
|
||||
|
||||
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;
|
||||
collection?: Collection;
|
||||
field?: Field;
|
||||
}
|
||||
|
||||
const MediaLibraryCard = ({
|
||||
isSelected = false,
|
||||
displayURL,
|
||||
text,
|
||||
onClick,
|
||||
draftText,
|
||||
width,
|
||||
height,
|
||||
margin,
|
||||
type,
|
||||
isViewableImage,
|
||||
isDraft,
|
||||
collection,
|
||||
field,
|
||||
loadDisplayURL,
|
||||
}: MediaLibraryCardProps) => {
|
||||
const entry = useAppSelector(selectEditingDraft);
|
||||
const url = useMediaAsset(displayURL.url, collection, field, entry);
|
||||
|
||||
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;
|
@ -1,231 +0,0 @@
|
||||
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 { Collection, Field, MediaFile } from '@staticcms/core/interface';
|
||||
import type {
|
||||
MediaLibraryDisplayURL,
|
||||
MediaLibraryState,
|
||||
} from '@staticcms/core/reducers/mediaLibrary';
|
||||
import type { GridChildComponentProps } from 'react-window';
|
||||
|
||||
export interface MediaLibraryCardItem {
|
||||
displayURL?: MediaLibraryDisplayURL;
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
draft: boolean;
|
||||
isViewableImage?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MediaLibraryCardGridProps {
|
||||
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
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'];
|
||||
collection?: Collection;
|
||||
field?: Field;
|
||||
}
|
||||
|
||||
export type CardGridItemData = MediaLibraryCardGridProps & {
|
||||
columnCount: number;
|
||||
gutter: number;
|
||||
};
|
||||
|
||||
const CardWrapper = ({
|
||||
rowIndex,
|
||||
columnIndex,
|
||||
style,
|
||||
data: {
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
columnCount,
|
||||
gutter,
|
||||
collection,
|
||||
field,
|
||||
},
|
||||
}: 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: 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}
|
||||
collection={collection}
|
||||
field={field}
|
||||
/>
|
||||
</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,
|
||||
scrollContainerRef,
|
||||
} = 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={scrollContainerRef}>
|
||||
<Grid
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={
|
||||
{
|
||||
...props,
|
||||
gutter,
|
||||
columnCount,
|
||||
} as CardGridItemData
|
||||
}
|
||||
style={{ overflow: 'hidden', overflowY: 'scroll' }}
|
||||
>
|
||||
{CardWrapper}
|
||||
</Grid>
|
||||
</StyledCardGridContainer>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
};
|
||||
|
||||
const PaginatedGrid = ({
|
||||
scrollContainerRef,
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
cardMargin,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
isPaginating,
|
||||
paginatingMessage,
|
||||
collection,
|
||||
field,
|
||||
}: MediaLibraryCardGridProps) => {
|
||||
return (
|
||||
<StyledCardGridContainer ref={scrollContainerRef}>
|
||||
<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}
|
||||
collection={collection}
|
||||
field={field}
|
||||
/>
|
||||
))}
|
||||
{!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;
|
@ -1,206 +0,0 @@
|
||||
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 { Collection, Field, MediaFile, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { MediaLibraryState } from '@staticcms/core/reducers/mediaLibrary';
|
||||
import type { ChangeEvent, ChangeEventHandler, FC, KeyboardEventHandler } from 'react';
|
||||
|
||||
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 = `278px`;
|
||||
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;
|
||||
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
handleAssetClick: (asset: MediaFile) => void;
|
||||
handleLoadMore: () => void;
|
||||
loadDisplayURL: (file: MediaFile) => void;
|
||||
displayURLs: MediaLibraryState['displayURLs'];
|
||||
collection?: Collection;
|
||||
field?: Field;
|
||||
}
|
||||
|
||||
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,
|
||||
scrollContainerRef,
|
||||
handleAssetClick,
|
||||
handleLoadMore,
|
||||
loadDisplayURL,
|
||||
displayURLs,
|
||||
collection,
|
||||
field,
|
||||
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
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
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}
|
||||
collection={collection}
|
||||
field={field}
|
||||
/>
|
||||
</DialogContent>
|
||||
</StyledModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(MediaLibraryModal) as FC<MediaLibraryModalProps>;
|
@ -1,43 +0,0 @@
|
||||
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;
|
@ -1,165 +0,0 @@
|
||||
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;
|
@ -1,21 +1,14 @@
|
||||
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>
|
||||
<div>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,86 +0,0 @@
|
||||
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;
|
@ -1,55 +0,0 @@
|
||||
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;
|
@ -1,29 +0,0 @@
|
||||
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;
|
@ -1,97 +0,0 @@
|
||||
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;
|
@ -1,42 +0,0 @@
|
||||
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;
|
@ -1,15 +0,0 @@
|
||||
import bitbucket from './bitbucket.svg';
|
||||
import github from './github.svg';
|
||||
import gitlab from './gitlab.svg';
|
||||
import gitea from './gitea.svg';
|
||||
import staticCms from './static-cms-logo.svg';
|
||||
|
||||
const images = {
|
||||
bitbucket,
|
||||
github,
|
||||
gitlab,
|
||||
gitea,
|
||||
'static-cms': staticCms,
|
||||
};
|
||||
|
||||
export default images;
|
@ -1,3 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 758 B |
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
|
||||
<g>
|
||||
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8 c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4 c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2 c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5 c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5 c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3 c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1 C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4 c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7 S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55 c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8 l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4 c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1 c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9 c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3 c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3 c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29 c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8 C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.5 KiB |
@ -1 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 670 B |
@ -1 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 889 B |
@ -1,137 +0,0 @@
|
||||
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;
|
||||
z-index: 1;
|
||||
width: 220px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
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} data-testid="list-item-title">
|
||||
{title}
|
||||
</StyledTitle>
|
||||
{listeners ? <DragHandle listeners={listeners} /> : null}
|
||||
{onRemove ? (
|
||||
<TopBarButton data-testid="remove-button" onClick={onRemove}>
|
||||
<CloseIcon />
|
||||
</TopBarButton>
|
||||
) : null}
|
||||
</TopBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItemTopBar;
|
@ -1,74 +0,0 @@
|
||||
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;
|
@ -1,171 +0,0 @@
|
||||
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;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const ObjectWidgetTopBar = ({
|
||||
allowAdd,
|
||||
types,
|
||||
onAdd,
|
||||
onAddType,
|
||||
onCollapseToggle,
|
||||
collapsed,
|
||||
heading,
|
||||
label,
|
||||
hasError = false,
|
||||
t,
|
||||
testId,
|
||||
}: 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"
|
||||
data-testid="add-button"
|
||||
>
|
||||
{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 data-testid={testId}>
|
||||
<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;
|
@ -1,60 +0,0 @@
|
||||
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;
|
@ -1,45 +0,0 @@
|
||||
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;
|
@ -1,75 +0,0 @@
|
||||
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);
|
@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface WidgetPreviewContainerProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const WidgetPreviewContainer = ({ children }: WidgetPreviewContainerProps) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
|
||||
export default WidgetPreviewContainer;
|
@ -1,2 +0,0 @@
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as SettingsDropdown } from './SettingsDropdown';
|
@ -1,550 +0,0 @@
|
||||
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,
|
||||
};
|
@ -1,4 +1,3 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
@ -9,7 +8,6 @@ import {
|
||||
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 {
|
||||
@ -23,11 +21,11 @@ import {
|
||||
selectEntriesSort,
|
||||
selectViewStyle,
|
||||
} from '@staticcms/core/reducers/selectors/entries';
|
||||
import Card from '../common/card/Card';
|
||||
import CollectionControls from './CollectionControls';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import Sidebar from './Sidebar';
|
||||
import CollectionHeader from './CollectionHeader';
|
||||
import EntriesCollection from './entries/EntriesCollection';
|
||||
import EntriesSearch from './entries/EntriesSearch';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
@ -40,24 +38,11 @@ import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
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,
|
||||
// TODO isSearchEnabled,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
@ -211,49 +196,45 @@ const CollectionView = ({
|
||||
};
|
||||
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
|
||||
|
||||
const collectionDescription = collection?.description;
|
||||
|
||||
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>
|
||||
</>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center mb-4">
|
||||
{isSearchResults ? (
|
||||
<>
|
||||
<div className="flex-grow">
|
||||
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
|
||||
</div>
|
||||
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CollectionHeader 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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{collectionDescription ? (
|
||||
<div className="flex flex-grow mb-4">
|
||||
<Card className="flex-grow px-3.5 py-2.5 text-sm">{collectionDescription}</Card>
|
||||
</div>
|
||||
) : null}
|
||||
{entries}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -281,7 +262,7 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionV
|
||||
const sortableFields = selectSortableFields(collection, t);
|
||||
const viewFilters = selectViewFilters(collection);
|
||||
const viewGroups = selectViewGroups(collection);
|
||||
const filter = selectEntriesFilter(state, collection?.name);
|
||||
const filter = selectEntriesFilter(collection?.name)(state);
|
||||
const group = selectEntriesGroup(state, collection?.name);
|
||||
const viewStyle = selectViewStyle(state);
|
||||
|
@ -1,12 +1,11 @@
|
||||
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 ViewStyleControl from '../common/view-style/ViewStyleControl';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
import type {
|
||||
FilterMap,
|
||||
GroupMap,
|
||||
@ -18,21 +17,9 @@ import type {
|
||||
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;
|
||||
viewStyle: ViewStyle;
|
||||
onChangeViewStyle: (viewStyle: ViewStyle) => void;
|
||||
sortableFields?: SortableField[];
|
||||
onSortClick?: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort?: SortMap | undefined;
|
||||
@ -59,7 +46,7 @@ const CollectionControls = ({
|
||||
group,
|
||||
}: TranslatedProps<CollectionControlsProps>) => {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<div className="flex gap-2 items-center relative z-20">
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{viewGroups && onGroupClick && group
|
||||
? viewGroups.length > 0 && (
|
||||
@ -81,7 +68,7 @@ const CollectionControls = ({
|
||||
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
|
||||
)
|
||||
: null}
|
||||
</CollectionControlsContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,52 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import useIcon from '@staticcms/core/lib/hooks/useIcon';
|
||||
import Button from '../common/button/Button';
|
||||
|
||||
import type { Collection, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
interface CollectionHeaderProps {
|
||||
collection: Collection;
|
||||
newEntryUrl?: string;
|
||||
}
|
||||
|
||||
const CollectionHeader = ({
|
||||
collection,
|
||||
newEntryUrl,
|
||||
t,
|
||||
}: TranslatedProps<CollectionHeaderProps>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const collectionLabel = collection.label;
|
||||
const collectionLabelSingular = collection.label_singular;
|
||||
|
||||
const onNewClick = useCallback(() => {
|
||||
if (newEntryUrl) {
|
||||
navigate(newEntryUrl);
|
||||
}
|
||||
}, [navigate, newEntryUrl]);
|
||||
|
||||
const icon = useIcon(collection.icon);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-grow gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center text-gray-800 dark:text-gray-300">
|
||||
<div className="mr-2 flex">{icon}</div>
|
||||
{collectionLabel}
|
||||
</h2>
|
||||
{newEntryUrl ? (
|
||||
<Button onClick={onNewClick}>
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionHeader);
|
@ -1,30 +1,26 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
selectCollection,
|
||||
selectCollections,
|
||||
} from '@staticcms/core/reducers/selectors/collections';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import { getDefaultPath } from '../../lib/util/collection.util';
|
||||
import MainView from '../App/MainView';
|
||||
import MainView from '../MainView';
|
||||
import Collection from './Collection';
|
||||
|
||||
import type { Collections } from '@staticcms/core/interface';
|
||||
|
||||
interface CollectionRouteProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const CollectionRoute = ({
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
collections,
|
||||
}: CollectionRouteProps) => {
|
||||
const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => {
|
||||
const { name, searchTerm, filterTerm } = useParams();
|
||||
const collection = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const collectionSelector = useMemo(() => selectCollection(name), [name]);
|
||||
const collection = useAppSelector(collectionSelector);
|
||||
const collections = useAppSelector(selectCollections);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
@ -37,7 +33,7 @@ const CollectionRoute = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<MainView>
|
||||
<MainView breadcrumbs={[{ name: collection?.label }]} showQuickCreate showLeftNav>
|
||||
<Collection
|
||||
name={name}
|
||||
searchTerm={searchTerm}
|
@ -1,64 +1,10 @@
|
||||
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 PopperUnstyled from '@mui/base/PopperUnstyled';
|
||||
import { Search as SearchIcon } from '@styled-icons/material/Search';
|
||||
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;
|
||||
`;
|
||||
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
|
||||
interface CollectionSearchProps {
|
||||
collections: Collections;
|
||||
@ -191,27 +137,116 @@ const CollectionSearch = ({
|
||||
);
|
||||
|
||||
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
|
||||
<div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<SearchIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
className="
|
||||
block
|
||||
w-full
|
||||
p-1.5
|
||||
pl-10
|
||||
text-sm
|
||||
text-gray-900
|
||||
border
|
||||
border-gray-300
|
||||
rounded-lg
|
||||
bg-gray-50
|
||||
focus-visible:outline-none
|
||||
focus:ring-4
|
||||
focus:ring-gray-200
|
||||
dark:bg-gray-700
|
||||
dark:border-gray-600
|
||||
dark:placeholder-gray-400
|
||||
dark:text-white
|
||||
dark:focus:ring-slate-700
|
||||
"
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
</div>
|
||||
<PopperUnstyled
|
||||
open={open}
|
||||
component="div"
|
||||
placement="top"
|
||||
anchorEl={anchorEl}
|
||||
tabIndex={0}
|
||||
className="
|
||||
absolute
|
||||
overflow-auto
|
||||
rounded-md
|
||||
bg-white
|
||||
text-base
|
||||
shadow-lg
|
||||
ring-1
|
||||
ring-black
|
||||
ring-opacity-5
|
||||
focus:outline-none
|
||||
sm:text-sm
|
||||
z-40
|
||||
dark:bg-slate-700
|
||||
"
|
||||
>
|
||||
<div
|
||||
key="edit-content"
|
||||
contentEditable={false}
|
||||
className="
|
||||
flex
|
||||
flex-col
|
||||
min-w-[200px]
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-md
|
||||
text-slate-500
|
||||
dark:text-slate-400
|
||||
py-2
|
||||
px-3
|
||||
"
|
||||
>
|
||||
{t('collection.sidebar.searchIn')}
|
||||
</div>
|
||||
<div
|
||||
className="
|
||||
cursor-pointer
|
||||
hover:bg-blue-500
|
||||
hover:color-gray-100
|
||||
py-2
|
||||
px-3
|
||||
"
|
||||
onClick={e => handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</div>
|
||||
{collections.map((collection, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={e => handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
className="
|
||||
cursor-pointer
|
||||
hover:bg-blue-500
|
||||
hover:color-gray-100
|
||||
py-2
|
||||
px-3
|
||||
"
|
||||
>
|
||||
{collection.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopperUnstyled>
|
||||
{/* <Popover
|
||||
id="search-popover"
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
@ -224,30 +259,27 @@ const CollectionSearch = ({
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
sx={{
|
||||
width: 300
|
||||
}}
|
||||
>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
$isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
<div>
|
||||
<div>{t('collection.sidebar.searchIn')}</div>
|
||||
<div onClick={e => handleSuggestionClick(e, -1)} onMouseDown={e => e.preventDefault()}>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
</div>
|
||||
{collections.map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
<div
|
||||
key={idx}
|
||||
$isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.label}
|
||||
</SuggestionItem>
|
||||
</div>
|
||||
))}
|
||||
</Suggestions>
|
||||
</StyledPopover>
|
||||
</SearchContainer>
|
||||
</div>
|
||||
</Popover> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
66
packages/core/src/components/collections/FilterControl.tsx
Normal file
66
packages/core/src/components/collections/FilterControl.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import Menu from '../common/menu/Menu';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
import MenuGroup from '../common/menu/MenuGroup';
|
||||
|
||||
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
|
||||
import type { ChangeEvent, MouseEvent } from 'react';
|
||||
|
||||
interface FilterControlProps {
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (viewFilter: ViewFilter) => void;
|
||||
}
|
||||
|
||||
const FilterControl = ({
|
||||
viewFilters,
|
||||
t,
|
||||
onFilterClick,
|
||||
filter,
|
||||
}: TranslatedProps<FilterControlProps>) => {
|
||||
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
|
||||
|
||||
const handleFilterClick = useCallback(
|
||||
(viewFilter: ViewFilter) => (event: MouseEvent | ChangeEvent) => {
|
||||
event.stopPropagation();
|
||||
onFilterClick(viewFilter);
|
||||
},
|
||||
[onFilterClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
label={t('collection.collectionTop.filterBy')}
|
||||
variant={anyActive ? 'contained' : 'outlined'}
|
||||
>
|
||||
<MenuGroup>
|
||||
{viewFilters.map(viewFilter => {
|
||||
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
|
||||
const labelId = `filter-list-label-${viewFilter.label}`;
|
||||
return (
|
||||
<MenuItemButton key={viewFilter.id} onClick={handleFilterClick(viewFilter)}>
|
||||
<input
|
||||
id={labelId}
|
||||
type="checkbox"
|
||||
value=""
|
||||
className=""
|
||||
checked={checked}
|
||||
onChange={handleFilterClick(viewFilter)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={labelId}
|
||||
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
{viewFilter.label}
|
||||
</label>
|
||||
</MenuItemButton>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(FilterControl);
|
45
packages/core/src/components/collections/GroupControl.tsx
Normal file
45
packages/core/src/components/collections/GroupControl.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Check as CheckIcon } from '@styled-icons/material/Check';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import Menu from '../common/menu/Menu';
|
||||
import MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
|
||||
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
|
||||
|
||||
interface GroupControlProps {
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (viewGroup: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const GroupControl = ({
|
||||
viewGroups,
|
||||
group,
|
||||
t,
|
||||
onGroupClick,
|
||||
}: TranslatedProps<GroupControlProps>) => {
|
||||
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
label={t('collection.collectionTop.groupBy')}
|
||||
variant={activeGroup ? 'contained' : 'outlined'}
|
||||
>
|
||||
<MenuGroup>
|
||||
{viewGroups.map(viewGroup => (
|
||||
<MenuItemButton
|
||||
key={viewGroup.id}
|
||||
onClick={() => onGroupClick(viewGroup)}
|
||||
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
|
||||
>
|
||||
{viewGroup.label}
|
||||
</MenuItemButton>
|
||||
))}
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(GroupControl);
|
@ -1,78 +1,17 @@
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Article as ArticleIcon } from '@styled-icons/material/Article';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { dirname, sep } from 'path';
|
||||
import React, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { colors, components } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import useEntries from '@staticcms/core/lib/hooks/useEntries';
|
||||
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
|
||||
import { stringTemplate } from '@staticcms/core/lib/widgets';
|
||||
import NavLink from '../navbar/NavLink';
|
||||
|
||||
import type { Collection, Entry } from '@staticcms/core/interface';
|
||||
|
||||
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;
|
||||
@ -122,19 +61,19 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
|
||||
|
||||
return (
|
||||
<Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
<NavLink
|
||||
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>
|
||||
{/* TODO $activeClassName="sidebar-active" */}
|
||||
{/* TODO $depth={depth} */}
|
||||
<ArticleIcon className="h-5 w-5" />
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
{hasChildren && (node.expanded ? <div /> : <div />)}
|
||||
</div>
|
||||
</NavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
84
packages/core/src/components/collections/SortControl.tsx
Normal file
84
packages/core/src/components/collections/SortControl.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
|
||||
import { KeyboardArrowUp as KeyboardArrowUpIcon } from '@styled-icons/material/KeyboardArrowUp';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import {
|
||||
SORT_DIRECTION_ASCENDING,
|
||||
SORT_DIRECTION_DESCENDING,
|
||||
SORT_DIRECTION_NONE,
|
||||
} from '@staticcms/core/constants';
|
||||
import Menu from '../common/menu/Menu';
|
||||
import MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
|
||||
import type {
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
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 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 (
|
||||
<Menu
|
||||
label={t('collection.collectionTop.sortBy')}
|
||||
variant={selectedSort.key ? 'contained' : 'outlined'}
|
||||
>
|
||||
<MenuGroup>
|
||||
{fields.map(field => {
|
||||
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
|
||||
const nextSortDir = nextSortDirection(sortDir);
|
||||
return (
|
||||
<MenuItemButton
|
||||
key={field.name}
|
||||
onClick={() => onSortClick(field.name, nextSortDir)}
|
||||
active={field.name === selectedSort.key}
|
||||
endIcon={
|
||||
field.name === selectedSort.key
|
||||
? selectedSort.direction === SORT_DIRECTION_ASCENDING
|
||||
? KeyboardArrowUpIcon
|
||||
: KeyboardArrowDownIcon
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{field.label ?? field.name}
|
||||
</MenuItemButton>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(SortControl);
|
@ -1,28 +1,18 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import Loader from '@staticcms/core/components/UI/Loader';
|
||||
import Loader from '@staticcms/core/components/common/progress/Loader';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
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;
|
||||
viewStyle: ViewStyle;
|
||||
cursor: Cursor;
|
||||
handleCursorActions?: (action: string) => void;
|
||||
}
|
||||
@ -83,13 +73,13 @@ const Entries = ({
|
||||
/>
|
||||
)}
|
||||
{isFetching && page !== undefined && entries.length > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
<div>{t('collection.entries.loadingEntries')}</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
return <div>{t('collection.entries.noEntries')}</div>;
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
@ -1,10 +1,8 @@
|
||||
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 { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries';
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import useEntries from '@staticcms/core/lib/hooks/useEntries';
|
||||
import useGroups from '@staticcms/core/lib/hooks/useGroups';
|
||||
import { Cursor } from '@staticcms/core/lib/util';
|
||||
@ -13,21 +11,13 @@ import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/
|
||||
import Entries from './Entries';
|
||||
import { useAppDispatch } from '@staticcms/core/store/hooks';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { t } from 'react-polyglot';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
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));
|
||||
}
|
||||
@ -118,8 +108,8 @@ const EntriesCollection = ({
|
||||
{groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<div key={group.id} id={group.id}>
|
||||
<h2>{title}</h2>
|
||||
<Entries
|
||||
collection={collection}
|
||||
entries={getGroupEntries(filteredEntries, group.paths)}
|
||||
@ -130,7 +120,7 @@ const EntriesCollection = ({
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
</GroupContainer>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>;
|
||||
@ -153,7 +143,7 @@ const EntriesCollection = ({
|
||||
|
||||
interface EntriesCollectionOwnProps {
|
||||
collection: Collection;
|
||||
viewStyle: CollectionViewStyle;
|
||||
viewStyle: ViewStyle;
|
||||
readyToLoad: boolean;
|
||||
filterTerm: string;
|
||||
}
|
@ -10,7 +10,7 @@ import { Cursor } from '@staticcms/core/lib/util';
|
||||
import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/entries';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
import type { Collections } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
@ -25,7 +25,6 @@ const EntriesSearch = ({
|
||||
searchEntries,
|
||||
clearSearch,
|
||||
}: EntriesSearchProps) => {
|
||||
console.log('collections', collections);
|
||||
const collectionNames = useMemo(() => Object.keys(collections), [collections]);
|
||||
|
||||
const getCursor = useCallback(() => {
|
||||
@ -72,7 +71,7 @@ const EntriesSearch = ({
|
||||
interface EntriesSearchOwnProps {
|
||||
searchTerm: string;
|
||||
collections: Collections;
|
||||
viewStyle: CollectionViewStyle;
|
||||
viewStyle: ViewStyle;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
@ -81,7 +80,6 @@ function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
console.log('searched entries', entries);
|
||||
return { isFetching, page, collections, viewStyle, entries, searchTerm };
|
||||
}
|
||||
|
160
packages/core/src/components/collections/entries/EntryCard.tsx
Normal file
160
packages/core/src/components/collections/entries/EntryCard.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import get from 'lodash/get';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/views';
|
||||
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
import { getFieldPreview, getPreviewCard } from '@staticcms/core/lib/registry';
|
||||
import {
|
||||
selectEntryCollectionTitle,
|
||||
selectFields,
|
||||
selectTemplateName,
|
||||
} from '@staticcms/core/lib/util/collection.util';
|
||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import Card from '../../common/card/Card';
|
||||
import CardActionArea from '../../common/card/CardActionArea';
|
||||
import CardContent from '../../common/card/CardContent';
|
||||
import CardMedia from '../../common/card/CardMedia';
|
||||
import TableCell from '../../common/table/TableCell';
|
||||
import TableRow from '../../common/table/TableRow';
|
||||
import useWidgetsFor from '../../common/widget/useWidgetsFor';
|
||||
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
|
||||
|
||||
export interface EntryCardProps {
|
||||
entry: Entry;
|
||||
imageFieldName?: string | null | undefined;
|
||||
collection: Collection;
|
||||
collectionLabel?: string;
|
||||
viewStyle: ViewStyle;
|
||||
summaryFields: string[];
|
||||
}
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
imageFieldName,
|
||||
summaryFields,
|
||||
}: EntryCardProps) => {
|
||||
const entryData = entry.data;
|
||||
|
||||
const path = useMemo(
|
||||
() => `/collections/${collection.name}/entries/${entry.slug}`,
|
||||
[collection.name, entry.slug],
|
||||
);
|
||||
|
||||
const imageField = useMemo(
|
||||
() =>
|
||||
'fields' in collection
|
||||
? (collection.fields?.find(
|
||||
f => f.name === imageFieldName && f.widget === 'image',
|
||||
) as FileOrImageField)
|
||||
: undefined,
|
||||
[collection, imageFieldName],
|
||||
);
|
||||
|
||||
const image = useMemo(() => {
|
||||
let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined;
|
||||
|
||||
if (i) {
|
||||
i = encodeURI(i.trim());
|
||||
}
|
||||
|
||||
return i;
|
||||
}, [entryData, imageFieldName]);
|
||||
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
|
||||
const fields = selectFields(collection, entry.slug);
|
||||
const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
|
||||
|
||||
const config = useAppSelector(selectConfig);
|
||||
|
||||
const { widgetFor, widgetsFor } = useWidgetsFor(config, collection, fields, entry);
|
||||
|
||||
const templateName = useMemo(
|
||||
() => selectTemplateName(collection, entry.slug),
|
||||
[collection, entry.slug],
|
||||
);
|
||||
|
||||
const PreviewCardComponent = useMemo(() => getPreviewCard(templateName) ?? null, [templateName]);
|
||||
|
||||
const theme = useAppSelector(selectTheme);
|
||||
|
||||
if (viewStyle === VIEW_STYLE_LIST) {
|
||||
return (
|
||||
<TableRow
|
||||
className="
|
||||
hover:bg-gray-200
|
||||
dark:hover:bg-slate-700/70
|
||||
"
|
||||
>
|
||||
{collectionLabel ? (
|
||||
<TableCell key="collectionLabel" to={path}>
|
||||
{collectionLabel}
|
||||
</TableCell>
|
||||
) : null}
|
||||
{summaryFields.map(fieldName => {
|
||||
if (fieldName === 'summary') {
|
||||
return (
|
||||
<TableCell key={fieldName} to={path}>
|
||||
{summary}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
const field = fields.find(f => f.name === fieldName);
|
||||
const value = get(entry.data, fieldName);
|
||||
const FieldPreviewComponent = getFieldPreview(templateName, fieldName);
|
||||
|
||||
return (
|
||||
<TableCell key={fieldName} to={path}>
|
||||
{field && FieldPreviewComponent ? (
|
||||
<FieldPreviewComponent
|
||||
collection={collection}
|
||||
field={field}
|
||||
value={value}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (PreviewCardComponent) {
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea to={path}>
|
||||
<PreviewCardComponent
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
entry={entry}
|
||||
widgetFor={widgetFor}
|
||||
widgetsFor={widgetsFor}
|
||||
theme={theme}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea to={path}>
|
||||
{image && imageField ? <CardMedia height="140" image={imageUrl} /> : null}
|
||||
<CardContent>{summary}</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryCard;
|
@ -1,45 +1,17 @@
|
||||
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, selectInferredField } from '@staticcms/core/lib/util/collection.util';
|
||||
import Table from '../../common/table/Table';
|
||||
import EntryCard from './EntryCard';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
import type { Collection, Collections, Entry, Field } 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;
|
||||
viewStyle: ViewStyle;
|
||||
cursor?: Cursor;
|
||||
handleCursorActions?: (action: string) => void;
|
||||
page?: number;
|
||||
@ -97,6 +69,20 @@ const EntryListing = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const summaryFields = useMemo(() => {
|
||||
let fields: string[] | undefined;
|
||||
if ('collection' in otherProps) {
|
||||
fields = otherProps.collection.summary_fields;
|
||||
}
|
||||
|
||||
return fields ?? ['summary'];
|
||||
}, [otherProps]);
|
||||
|
||||
const isSingleCollectionInList = useMemo(
|
||||
() => !('collections' in otherProps) || Object.keys(otherProps.collections).length === 1,
|
||||
[otherProps],
|
||||
);
|
||||
|
||||
const renderedCards = useMemo(() => {
|
||||
if ('collection' in otherProps) {
|
||||
const inferredFields = inferFields(otherProps.collection);
|
||||
@ -107,16 +93,17 @@ const EntryListing = ({
|
||||
viewStyle={viewStyle}
|
||||
entry={entry}
|
||||
key={entry.slug}
|
||||
summaryFields={summaryFields}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
|
||||
return entries.map(entry => {
|
||||
const collectionName = entry.collection;
|
||||
const collection = Object.values(otherProps.collections).find(
|
||||
coll => coll.name === collectionName,
|
||||
);
|
||||
|
||||
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
|
||||
const inferredFields = inferFields(collection);
|
||||
return collection ? (
|
||||
@ -124,19 +111,38 @@ const EntryListing = ({
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
imageFieldName={inferredFields.imageField}
|
||||
viewStyle={viewStyle}
|
||||
collectionLabel={collectionLabel}
|
||||
key={entry.slug}
|
||||
summaryFields={summaryFields}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}, [entries, inferFields, otherProps, viewStyle]);
|
||||
}, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, viewStyle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid $layout={viewStyle}>
|
||||
if (viewStyle === 'VIEW_STYLE_LIST') {
|
||||
return (
|
||||
<Table columns={!isSingleCollectionInList ? ['Collection', ...summaryFields] : summaryFields}>
|
||||
{renderedCards}
|
||||
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
|
||||
</CardsGrid>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
grid
|
||||
gap-4
|
||||
sm:grid-cols-1
|
||||
md:grid-cols-1
|
||||
lg:grid-cols-2
|
||||
xl:grid-cols-3
|
||||
2xl:grid-cols-4
|
||||
"
|
||||
>
|
||||
{renderedCards}
|
||||
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,221 @@
|
||||
import { Combobox, Transition } from '@headlessui/react';
|
||||
import { Check as CheckIcon } from '@styled-icons/material/Check';
|
||||
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
|
||||
import React, { forwardRef, Fragment, useCallback } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
|
||||
export interface Option<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
function getOptionLabelAndValue<T>(option: T | Option<T>): Option<T> {
|
||||
if (option && typeof option === 'object' && 'label' in option && 'value' in option) {
|
||||
return option;
|
||||
}
|
||||
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
export type AutocompleteChangeEventHandler<T> = (value: T | T[]) => void;
|
||||
|
||||
export interface AutocompleteProps<T> {
|
||||
label: ReactNode | ReactNode[];
|
||||
value: T | T[] | null;
|
||||
options: T[] | Option<T>[];
|
||||
disabled?: boolean;
|
||||
displayValue: (item: T | T[] | null) => string;
|
||||
onQuery: (query: string) => void;
|
||||
onChange: AutocompleteChangeEventHandler<T>;
|
||||
}
|
||||
|
||||
const Autocomplete = function <T>(
|
||||
{ label, value, options, disabled, displayValue, onQuery, onChange }: AutocompleteProps<T>,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
const handleChange = useCallback(
|
||||
(selectedValue: T) => {
|
||||
if (Array.isArray(value)) {
|
||||
const newValue = [...value];
|
||||
const index = newValue.indexOf(selectedValue);
|
||||
if (index > -1) {
|
||||
newValue.splice(index, 1);
|
||||
} else {
|
||||
newValue.push(selectedValue);
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(selectedValue);
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Combobox value={value} onChange={handleChange} disabled={disabled}>
|
||||
<div className="relative mt-1">
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
items-center
|
||||
text-sm
|
||||
font-medium
|
||||
relative
|
||||
min-h-8
|
||||
p-0
|
||||
w-full
|
||||
text-gray-900
|
||||
dark:text-gray-100
|
||||
"
|
||||
data-testid="autocomplete-input-wrapper"
|
||||
>
|
||||
{label}
|
||||
<Combobox.Input<'input', T | T[] | null>
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
`
|
||||
w-full
|
||||
bg-transparent
|
||||
border-none
|
||||
py-2
|
||||
pl-3
|
||||
pr-10
|
||||
text-sm
|
||||
leading-5
|
||||
focus:ring-0
|
||||
outline-none
|
||||
basis-60
|
||||
flex-grow
|
||||
`,
|
||||
disabled
|
||||
? `
|
||||
text-gray-300
|
||||
dark:text-gray-500
|
||||
`
|
||||
: `
|
||||
text-gray-900
|
||||
dark:text-gray-100
|
||||
`,
|
||||
)}
|
||||
data-testid="autocomplete-input"
|
||||
displayValue={displayValue}
|
||||
onChange={event => onQuery(event.target.value)}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<KeyboardArrowDownIcon
|
||||
className={classNames(
|
||||
`
|
||||
h-5
|
||||
w-5
|
||||
text-gray-400
|
||||
`,
|
||||
disabled &&
|
||||
`
|
||||
text-gray-300/75
|
||||
dark:text-gray-600/75
|
||||
`,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => onQuery('')}
|
||||
>
|
||||
<Combobox.Options
|
||||
data-testid="autocomplete-options"
|
||||
className={`
|
||||
absolute
|
||||
mt-1
|
||||
max-h-60
|
||||
w-full
|
||||
overflow-auto
|
||||
rounded-md
|
||||
bg-white
|
||||
py-1
|
||||
text-base
|
||||
shadow-lg
|
||||
ring-1
|
||||
ring-black
|
||||
ring-opacity-5
|
||||
focus:outline-none
|
||||
sm:text-sm
|
||||
z-30
|
||||
dark:bg-slate-700
|
||||
`}
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">
|
||||
Nothing found.
|
||||
</div>
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(option);
|
||||
|
||||
const selected = Array.isArray(value)
|
||||
? value.includes(optionValue)
|
||||
: value === optionValue;
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={index}
|
||||
data-testid={`autocomplete-option-${optionValue}`}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
`
|
||||
relative
|
||||
select-none
|
||||
py-2
|
||||
pl-10
|
||||
pr-4
|
||||
cursor-pointer
|
||||
text-gray-900
|
||||
dark:text-gray-100
|
||||
`,
|
||||
(selected || active) &&
|
||||
`
|
||||
bg-gray-100
|
||||
dark:bg-slate-600
|
||||
`,
|
||||
)
|
||||
}
|
||||
value={optionValue}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'block truncate',
|
||||
selected ? 'font-medium' : 'font-normal',
|
||||
)}
|
||||
>
|
||||
{optionLabel}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-500">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(Autocomplete) as <T>(
|
||||
props: AutocompleteProps<T> & { ref: Ref<HTMLButtonElement> },
|
||||
) => JSX.Element;
|
127
packages/core/src/components/common/button/Button.tsx
Normal file
127
packages/core/src/components/common/button/Button.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import useButtonClassNames from './useButtonClassNames';
|
||||
|
||||
import type { CSSProperties, FC, MouseEventHandler, ReactNode, Ref } from 'react';
|
||||
|
||||
export interface BaseBaseProps {
|
||||
variant?: 'contained' | 'outlined' | 'text';
|
||||
color?: 'primary' | 'success' | 'error';
|
||||
size?: 'medium' | 'small';
|
||||
rounded?: boolean | 'no-padding';
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode | ReactNode[];
|
||||
startIcon?: FC<{ className?: string }>;
|
||||
endIcon?: FC<{ className?: string }>;
|
||||
title?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export interface ButtonProps extends BaseBaseProps {
|
||||
onClick?: MouseEventHandler;
|
||||
disabled?: boolean;
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export interface ButtonInternalLinkProps extends BaseBaseProps {
|
||||
to: string;
|
||||
linkRef?: Ref<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
export interface ButtonExternalLinkProps extends BaseBaseProps {
|
||||
href: string;
|
||||
linkRef?: Ref<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
export type LinkProps = ButtonInternalLinkProps | ButtonExternalLinkProps;
|
||||
|
||||
export type ButtonLinkProps = ButtonProps | LinkProps;
|
||||
|
||||
const Button: FC<ButtonLinkProps> = ({
|
||||
variant = 'contained',
|
||||
color = 'primary',
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
startIcon: StartIcon,
|
||||
endIcon: EndIcon,
|
||||
title,
|
||||
'data-testid': dataTestId,
|
||||
...otherProps
|
||||
}) => {
|
||||
const buttonClassName = useButtonClassNames(variant, color, size, rounded);
|
||||
|
||||
const buttonClassNames = useMemo(
|
||||
() => classNames(buttonClassName, className),
|
||||
[buttonClassName, className],
|
||||
);
|
||||
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{StartIcon ? <StartIcon className="w-5 h-5 mr-2" /> : null}
|
||||
{children}
|
||||
{EndIcon ? <EndIcon className="w-5 h-5 ml-2" /> : null}
|
||||
</>
|
||||
),
|
||||
[EndIcon, StartIcon, children],
|
||||
);
|
||||
|
||||
if ('to' in otherProps) {
|
||||
return (
|
||||
<Link
|
||||
ref={otherProps.linkRef}
|
||||
to={otherProps.to}
|
||||
title={title}
|
||||
data-testid={dataTestId}
|
||||
className={buttonClassNames}
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if ('href' in otherProps) {
|
||||
return (
|
||||
<a
|
||||
ref={otherProps.linkRef}
|
||||
href={otherProps.href}
|
||||
title={title}
|
||||
data-testid={dataTestId}
|
||||
className={buttonClassNames}
|
||||
style={style}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={otherProps.buttonRef}
|
||||
title={title}
|
||||
data-testid={dataTestId}
|
||||
className={buttonClassNames}
|
||||
style={style}
|
||||
disabled={otherProps.disabled}
|
||||
onClick={otherProps.onClick}
|
||||
aria-label={otherProps['aria-label']}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
25
packages/core/src/components/common/button/IconButton.tsx
Normal file
25
packages/core/src/components/common/button/IconButton.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import Button from './Button';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { FC } from 'react';
|
||||
import type { ButtonProps } from './Button';
|
||||
|
||||
export type IconButtonProps = Omit<ButtonProps, 'children'> & {
|
||||
children: FC<{ className?: string }>;
|
||||
};
|
||||
|
||||
const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
className={classNames(size === 'small' && 'px-0.5', size === 'medium' && 'px-1.5', className)}
|
||||
size={size}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
@ -0,0 +1,40 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { BaseBaseProps } from './Button';
|
||||
|
||||
const classes: Record<
|
||||
Required<BaseBaseProps>['variant'],
|
||||
Record<Required<BaseBaseProps>['color'], string>
|
||||
> = {
|
||||
contained: {
|
||||
primary: 'btn-contained-primary',
|
||||
success: 'btn-contained-success',
|
||||
error: 'btn-contained-error',
|
||||
},
|
||||
outlined: {
|
||||
primary: 'btn-outlined-primary',
|
||||
success: 'btn-outlined-success',
|
||||
error: 'btn-outlined-error',
|
||||
},
|
||||
text: {
|
||||
primary: 'btn-text-primary',
|
||||
success: 'btn-text-success',
|
||||
error: 'btn-text-error',
|
||||
},
|
||||
};
|
||||
|
||||
export default function useButtonClassNames(
|
||||
variant: Required<BaseBaseProps>['variant'],
|
||||
color: Required<BaseBaseProps>['color'],
|
||||
size: Required<BaseBaseProps>['size'],
|
||||
rounded: boolean | 'no-padding',
|
||||
) {
|
||||
let mainClass = size === 'small' ? 'btn-sm' : 'btn';
|
||||
if (rounded === 'no-padding') {
|
||||
mainClass = 'btn-rounded-no-padding';
|
||||
} else if (rounded) {
|
||||
mainClass = size === 'small' ? 'btn-rounded-sm' : 'btn-rounded';
|
||||
}
|
||||
|
||||
return useMemo(() => `${mainClass} ${classes[variant][color]}`, [color, mainClass, variant]);
|
||||
}
|
32
packages/core/src/components/common/card/Card.tsx
Normal file
32
packages/core/src/components/common/card/Card.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode | ReactNode[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Card = ({ children, className }: CardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
bg-white border
|
||||
border-gray-200
|
||||
rounded-lg
|
||||
shadow-md
|
||||
dark:bg-slate-800
|
||||
dark:border-gray-700/40
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
30
packages/core/src/components/common/card/CardActionArea.tsx
Normal file
30
packages/core/src/components/common/card/CardActionArea.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface CardActionAreaProps {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const CardActionArea = ({ to, children }: CardActionAreaProps) => {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="
|
||||
h-full
|
||||
w-full
|
||||
relative
|
||||
flex
|
||||
justify-start
|
||||
hover:bg-gray-200
|
||||
dark:hover:bg-slate-700/70
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardActionArea;
|
13
packages/core/src/components/common/card/CardContent.tsx
Normal file
13
packages/core/src/components/common/card/CardContent.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface CardContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const CardContent = ({ children }: CardContentProps) => {
|
||||
return <p className="p-5 font-normal text-gray-700 dark:text-gray-300">{children}</p>;
|
||||
};
|
||||
|
||||
export default CardContent;
|
17
packages/core/src/components/common/card/CardHeader.tsx
Normal file
17
packages/core/src/components/common/card/CardHeader.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const CardHeader = ({ children }: CardHeaderProps) => {
|
||||
return (
|
||||
<h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardHeader;
|
24
packages/core/src/components/common/card/CardMedia.tsx
Normal file
24
packages/core/src/components/common/card/CardMedia.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardMediaProps {
|
||||
image: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const CardMedia = ({ image, width, height, alt = '' }: CardMediaProps) => {
|
||||
return (
|
||||
<img
|
||||
className="rounded-t-lg bg-cover bg-no-repeat bg-center w-full object-cover"
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
height: height ? `${height}px` : undefined,
|
||||
}}
|
||||
src={image}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardMedia;
|
@ -1,14 +1,10 @@
|
||||
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 Button from '../button/Button';
|
||||
import Modal from '../modal/Modal';
|
||||
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
@ -84,27 +80,53 @@ const ConfirmDialog = ({ t }: TranslateProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleCancel}
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
<Modal
|
||||
open
|
||||
onClose={handleCancel}
|
||||
className="
|
||||
w-[540px]
|
||||
|
||||
"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
px-6
|
||||
py-4
|
||||
text-xl
|
||||
bold
|
||||
"
|
||||
>
|
||||
<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>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="
|
||||
px-6
|
||||
pb-4
|
||||
text-sm
|
||||
text-slate-500
|
||||
dark:text-slate-400
|
||||
"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
<div
|
||||
className="
|
||||
p-2
|
||||
flex
|
||||
justify-end
|
||||
gap-2
|
||||
"
|
||||
>
|
||||
<Button onClick={handleCancel} variant="text">
|
||||
{cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color={color}>
|
||||
{confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
27
packages/core/src/components/common/field/ErrorMessage.tsx
Normal file
27
packages/core/src/components/common/field/ErrorMessage.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { FC } from 'react';
|
||||
import type { FieldError } from '@staticcms/core/interface';
|
||||
|
||||
export interface ErrorMessageProps {
|
||||
errors: FieldError[];
|
||||
}
|
||||
|
||||
const ErrorMessage: FC<ErrorMessageProps> = ({ errors }) => {
|
||||
return errors.length ? (
|
||||
<div
|
||||
key="error"
|
||||
data-testid="error"
|
||||
className="flex
|
||||
w-full
|
||||
text-xs
|
||||
text-red-500
|
||||
px-3
|
||||
pt-1"
|
||||
>
|
||||
{errors[0].message}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
158
packages/core/src/components/common/field/Field.tsx
Normal file
158
packages/core/src/components/common/field/Field.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import useCursor from '@staticcms/core/lib/hooks/useCursor';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import ErrorMessage from './ErrorMessage';
|
||||
import Hint from './Hint';
|
||||
import Label from './Label';
|
||||
|
||||
import type { FieldError } from '@staticcms/core/interface';
|
||||
import type { FC, MouseEvent, ReactNode } from 'react';
|
||||
|
||||
export interface FieldProps {
|
||||
label?: string;
|
||||
inputRef?: React.MutableRefObject<HTMLElement | null>;
|
||||
children: ReactNode | ReactNode[];
|
||||
errors: FieldError[];
|
||||
variant?: 'default' | 'inline';
|
||||
cursor?: 'default' | 'pointer' | 'text';
|
||||
hint?: string;
|
||||
forSingleList?: boolean;
|
||||
noPadding?: boolean;
|
||||
noHightlight?: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const Field: FC<FieldProps> = ({
|
||||
inputRef,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
variant = 'default',
|
||||
cursor = 'default',
|
||||
hint,
|
||||
forSingleList,
|
||||
noPadding = false,
|
||||
noHightlight = false,
|
||||
disabled,
|
||||
}) => {
|
||||
const finalCursor = useCursor(cursor, disabled);
|
||||
|
||||
const hasErrors = useMemo(() => errors.length > 0, [errors.length]);
|
||||
|
||||
const handleOnClick = (event: MouseEvent) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target !== inputRef?.current) {
|
||||
inputRef?.current?.focus();
|
||||
inputRef?.current?.click();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const renderedLabel = useMemo(
|
||||
() =>
|
||||
label ? (
|
||||
<Label
|
||||
key="label"
|
||||
hasErrors={hasErrors}
|
||||
variant={variant}
|
||||
cursor={finalCursor}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
) : null,
|
||||
[finalCursor, disabled, hasErrors, label, variant],
|
||||
);
|
||||
|
||||
const renderedHint = useMemo(
|
||||
() =>
|
||||
hint ? (
|
||||
<Hint
|
||||
key="hint"
|
||||
hasErrors={hasErrors}
|
||||
variant={variant}
|
||||
cursor={finalCursor}
|
||||
disabled={disabled}
|
||||
>
|
||||
{hint}
|
||||
</Hint>
|
||||
) : null,
|
||||
[disabled, finalCursor, hasErrors, hint, variant],
|
||||
);
|
||||
|
||||
const renderedErrorMessage = useMemo(() => <ErrorMessage errors={errors} />, [errors]);
|
||||
|
||||
const rootClassNames = useMemo(
|
||||
() =>
|
||||
classNames(
|
||||
`
|
||||
relative
|
||||
flex
|
||||
border-b
|
||||
border-slate-400
|
||||
focus-within:border-blue-800
|
||||
dark:focus-within:border-blue-100
|
||||
`,
|
||||
!noHightlight &&
|
||||
!disabled &&
|
||||
`
|
||||
focus-within:bg-slate-100
|
||||
dark:focus-within:bg-slate-800
|
||||
hover:bg-slate-100
|
||||
dark:hover:bg-slate-800
|
||||
`,
|
||||
!noPadding && 'pb-3',
|
||||
finalCursor === 'pointer' && 'cursor-pointer',
|
||||
finalCursor === 'text' && 'cursor-text',
|
||||
finalCursor === 'default' && 'cursor-default',
|
||||
!hasErrors && 'group/active',
|
||||
),
|
||||
[finalCursor, disabled, hasErrors, noHightlight, noPadding],
|
||||
);
|
||||
|
||||
const wrapperClassNames = useMemo(
|
||||
() =>
|
||||
classNames(
|
||||
`
|
||||
flex
|
||||
flex-col
|
||||
w-full
|
||||
`,
|
||||
forSingleList && 'mr-14',
|
||||
),
|
||||
[forSingleList],
|
||||
);
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<div data-testid="inline-field" className={rootClassNames} onClick={handleOnClick}>
|
||||
<div data-testid="inline-field-wrapper" className={wrapperClassNames}>
|
||||
<div className="flex items-center justify-center p-3 pb-0">
|
||||
{renderedLabel}
|
||||
{renderedHint}
|
||||
{children}
|
||||
</div>
|
||||
{renderedErrorMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="field" className={rootClassNames} onClick={handleOnClick}>
|
||||
<div data-testid="field-wrapper" className={wrapperClassNames}>
|
||||
{renderedLabel}
|
||||
{children}
|
||||
{renderedHint}
|
||||
{renderedErrorMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Field;
|
65
packages/core/src/components/common/field/Hint.tsx
Normal file
65
packages/core/src/components/common/field/Hint.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
|
||||
import useCursor from '@staticcms/core/lib/hooks/useCursor';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { FC } from 'react';
|
||||
|
||||
export interface HintProps {
|
||||
children: string;
|
||||
hasErrors: boolean;
|
||||
variant?: 'default' | 'inline';
|
||||
cursor?: 'default' | 'pointer' | 'text';
|
||||
className?: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const Hint: FC<HintProps> = ({
|
||||
children,
|
||||
hasErrors,
|
||||
variant = 'default',
|
||||
cursor = 'default',
|
||||
className,
|
||||
disabled,
|
||||
}) => {
|
||||
const finalCursor = useCursor(cursor, disabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="hint"
|
||||
className={classNames(
|
||||
`
|
||||
w-full
|
||||
flex
|
||||
text-xs
|
||||
italic
|
||||
`,
|
||||
!disabled &&
|
||||
`
|
||||
group-focus-within/active:text-blue-500
|
||||
group-hover/active:text-blue-500
|
||||
`,
|
||||
finalCursor === 'pointer' && 'cursor-pointer',
|
||||
finalCursor === 'text' && 'cursor-text',
|
||||
finalCursor === 'default' && 'cursor-default',
|
||||
hasErrors
|
||||
? 'text-red-500'
|
||||
: disabled
|
||||
? `
|
||||
text-slate-300
|
||||
dark:text-slate-600
|
||||
`
|
||||
: `
|
||||
text-slate-500
|
||||
dark:text-slate-400
|
||||
`,
|
||||
variant === 'default' && 'px-3 pt-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hint;
|
71
packages/core/src/components/common/field/Label.tsx
Normal file
71
packages/core/src/components/common/field/Label.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
import useCursor from '@staticcms/core/lib/hooks/useCursor';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { FC } from 'react';
|
||||
|
||||
export interface LabelProps {
|
||||
htmlFor?: string;
|
||||
children: string;
|
||||
hasErrors?: boolean;
|
||||
variant?: 'default' | 'inline';
|
||||
cursor?: 'default' | 'pointer' | 'text';
|
||||
className?: string;
|
||||
disabled: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const Label: FC<LabelProps> = ({
|
||||
htmlFor,
|
||||
children,
|
||||
hasErrors = false,
|
||||
variant = 'default',
|
||||
cursor = 'default',
|
||||
className,
|
||||
disabled,
|
||||
'data-testid': dataTestId,
|
||||
}) => {
|
||||
const finalCursor = useCursor(cursor, disabled);
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
data-testid={dataTestId ?? 'label'}
|
||||
className={classNames(
|
||||
`
|
||||
w-full
|
||||
flex
|
||||
text-xs
|
||||
font-bold
|
||||
dark:font-semibold
|
||||
`,
|
||||
!disabled &&
|
||||
`
|
||||
group-focus-within/active:text-blue-500
|
||||
group-hover/active:text-blue-500
|
||||
`,
|
||||
finalCursor === 'pointer' && 'cursor-pointer',
|
||||
finalCursor === 'text' && 'cursor-text',
|
||||
finalCursor === 'default' && 'cursor-default',
|
||||
hasErrors
|
||||
? 'text-red-500'
|
||||
: disabled
|
||||
? `
|
||||
text-slate-300
|
||||
dark:text-slate-600
|
||||
`
|
||||
: `
|
||||
text-slate-500
|
||||
dark:text-slate-400
|
||||
`,
|
||||
variant === 'default' && 'px-3 pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default Label;
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
|
||||
@ -9,24 +10,42 @@ import type { Collection, MediaField } from '@staticcms/core/interface';
|
||||
export interface ImageProps<F extends MediaField> {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
collection?: Collection<F>;
|
||||
field?: F;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const Image = <F extends MediaField>({ src, alt, collection, field }: ImageProps<F>) => {
|
||||
const Image = <F extends MediaField>({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
collection,
|
||||
field,
|
||||
'data-testid': dataTestId,
|
||||
}: ImageProps<F>) => {
|
||||
const entry = useAppSelector(selectEditingDraft);
|
||||
|
||||
const assetSource = useMediaAsset(src, collection, field, entry);
|
||||
|
||||
return <img key="image" role="presentation" src={assetSource} alt={alt} />;
|
||||
return (
|
||||
<img
|
||||
key="image"
|
||||
role="presentation"
|
||||
src={assetSource}
|
||||
alt={alt}
|
||||
data-testid={dataTestId ?? 'image'}
|
||||
className={classNames('object-cover max-w-full overflow-hidden', className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const withMdxImage = <F extends MediaField>({
|
||||
collection,
|
||||
field,
|
||||
}: Omit<ImageProps<F>, 'src' | 'alt'>) => {
|
||||
const MdxImage = ({ src, alt }: Pick<ImageProps<F>, 'src' | 'alt'>) => (
|
||||
<Image src={src} alt={alt} collection={collection} field={field} />
|
||||
}: Pick<ImageProps<F>, 'collection' | 'field'>) => {
|
||||
const MdxImage = (props: Omit<ImageProps<F>, 'collection' | 'field'>) => (
|
||||
<Image {...props} collection={collection} field={field} />
|
||||
);
|
||||
|
||||
return MdxImage;
|
||||
|
48
packages/core/src/components/common/link/Link.tsx
Normal file
48
packages/core/src/components/common/link/Link.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
|
||||
import type { Collection, MediaField } from '@staticcms/core/interface';
|
||||
|
||||
export interface LinkProps<F extends MediaField> {
|
||||
href: string | undefined | null;
|
||||
children: string;
|
||||
collection?: Collection<F>;
|
||||
field?: F;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const Link = <F extends MediaField>({
|
||||
href,
|
||||
children,
|
||||
collection,
|
||||
field,
|
||||
'data-testid': dataTestId,
|
||||
}: LinkProps<F>) => {
|
||||
const entry = useAppSelector(selectEditingDraft);
|
||||
|
||||
const assetSource = useMediaAsset(href, collection, field, entry);
|
||||
|
||||
return (
|
||||
<a key="link" href={assetSource} data-testid={dataTestId ?? 'link'}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const withMdxLink = <F extends MediaField>({
|
||||
collection,
|
||||
field,
|
||||
}: Pick<LinkProps<F>, 'collection' | 'field'>) => {
|
||||
const MdxLink = ({ children, ...props }: Omit<LinkProps<F>, 'collection' | 'field'>) => (
|
||||
<Link {...props} collection={collection} field={field}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return MdxLink;
|
||||
};
|
||||
|
||||
export default Link;
|
120
packages/core/src/components/common/menu/Menu.tsx
Normal file
120
packages/core/src/components/common/menu/Menu.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import ClickAwayListener from '@mui/base/ClickAwayListener';
|
||||
import MenuUnstyled from '@mui/base/MenuUnstyled';
|
||||
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import useButtonClassNames from '../button/useButtonClassNames';
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import type { BaseBaseProps } from '../button/Button';
|
||||
|
||||
export interface MenuProps {
|
||||
label: ReactNode;
|
||||
startIcon?: FC<{ className?: string }>;
|
||||
variant?: BaseBaseProps['variant'];
|
||||
color?: BaseBaseProps['color'];
|
||||
size?: BaseBaseProps['size'];
|
||||
rounded?: boolean | 'no-padding';
|
||||
className?: string;
|
||||
children: ReactNode | ReactNode[];
|
||||
hideDropdownIcon?: boolean;
|
||||
keepMounted?: boolean;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const Menu = ({
|
||||
label,
|
||||
startIcon: StartIcon,
|
||||
variant = 'contained',
|
||||
color = 'primary',
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
className,
|
||||
children,
|
||||
hideDropdownIcon = false,
|
||||
keepMounted = false,
|
||||
disabled = false,
|
||||
'data-testid': dataTestId,
|
||||
}: MenuProps) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||
const isOpen = Boolean(anchorEl);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (isOpen) {
|
||||
setAnchorEl(null);
|
||||
} else {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}
|
||||
},
|
||||
[isOpen],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const buttonClassName = useButtonClassNames(variant, color, size, rounded);
|
||||
|
||||
const menuButtonClassNames = useMemo(
|
||||
() => classNames(className, buttonClassName),
|
||||
[buttonClassName, className],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart" onClickAway={handleClose}>
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleButtonClick}
|
||||
aria-controls={isOpen ? 'simple-menu' : undefined}
|
||||
aria-expanded={isOpen || undefined}
|
||||
aria-haspopup="menu"
|
||||
data-testid={dataTestId}
|
||||
className={menuButtonClassNames}
|
||||
disabled={disabled}
|
||||
>
|
||||
{StartIcon ? <StartIcon className="-ml-0.5 mr-1.5 h-5 w-5" /> : null}
|
||||
{label}
|
||||
{!hideDropdownIcon ? (
|
||||
<KeyboardArrowDownIcon className="-mr-0.5 ml-2 h-5 w-5" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
<MenuUnstyled
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
keepMounted={keepMounted}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: `
|
||||
absolute
|
||||
right-0
|
||||
z-40
|
||||
w-56
|
||||
origin-top-right
|
||||
rounded-md
|
||||
bg-white
|
||||
dark:bg-slate-800
|
||||
shadow-lg
|
||||
border
|
||||
border-gray-200
|
||||
focus:outline-none
|
||||
divide-y
|
||||
divide-gray-100
|
||||
dark:border-gray-700
|
||||
dark:divide-gray-600
|
||||
`,
|
||||
onClick: handleClose,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MenuUnstyled>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
24
packages/core/src/components/common/menu/MenuGroup.tsx
Normal file
24
packages/core/src/components/common/menu/MenuGroup.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface MenuGroupProps {
|
||||
children: ReactNode | ReactNode[];
|
||||
}
|
||||
|
||||
const MenuGroup = ({ children }: MenuGroupProps) => {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
py-1
|
||||
border-b
|
||||
border-gray-200
|
||||
dark:border-slate-700
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuGroup;
|
79
packages/core/src/components/common/menu/MenuItemButton.tsx
Normal file
79
packages/core/src/components/common/menu/MenuItemButton.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import MenuItemUnstyled from '@mui/base/MenuItemUnstyled';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { FC, MouseEvent, ReactNode } from 'react';
|
||||
|
||||
export interface MenuItemButtonProps {
|
||||
active?: boolean;
|
||||
onClick: (event: MouseEvent) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
startIcon?: FC<{ className?: string }>;
|
||||
endIcon?: FC<{ className?: string }>;
|
||||
color?: 'default' | 'error';
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const MenuItemButton = ({
|
||||
active = false,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
startIcon: StartIcon,
|
||||
endIcon: EndIcon,
|
||||
color = 'default',
|
||||
'data-testid': dataTestId,
|
||||
}: MenuItemButtonProps) => {
|
||||
return (
|
||||
<MenuItemUnstyled
|
||||
slotProps={{
|
||||
root: {
|
||||
className: classNames(
|
||||
className,
|
||||
active ? 'bg-slate-200 dark:bg-slate-600' : '',
|
||||
`
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
w-full
|
||||
text-left
|
||||
disabled:text-gray-300
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
cursor-pointer
|
||||
hover:bg-gray-200
|
||||
dark:hover:bg-slate-600
|
||||
dark:disabled:text-gray-700
|
||||
`,
|
||||
color === 'default' &&
|
||||
`
|
||||
text-gray-700
|
||||
dark:text-gray-300
|
||||
`,
|
||||
color === 'error' &&
|
||||
`
|
||||
text-red-500
|
||||
dark:text-red-500
|
||||
`,
|
||||
),
|
||||
},
|
||||
}}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
{StartIcon ? <StartIcon className="h-5 w-5" /> : null}
|
||||
{children}
|
||||
</div>
|
||||
{EndIcon ? <EndIcon className="h-5 w-5" /> : null}
|
||||
</MenuItemUnstyled>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItemButton;
|
62
packages/core/src/components/common/menu/MenuItemLink.tsx
Normal file
62
packages/core/src/components/common/menu/MenuItemLink.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import MenuItemUnstyled from '@mui/base/MenuItemUnstyled';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
export interface MenuItemLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
startIcon?: FC<{ className?: string }>;
|
||||
endIcon?: FC<{ className?: string }>;
|
||||
}
|
||||
|
||||
const MenuItemLink = ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
active = false,
|
||||
startIcon: StartIcon,
|
||||
endIcon: EndIcon,
|
||||
}: MenuItemLinkProps) => {
|
||||
return (
|
||||
<MenuItemUnstyled
|
||||
component={NavLink}
|
||||
to={href}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: classNames(
|
||||
className,
|
||||
active ? 'bg-slate-100 dark:bg-slate-900' : '',
|
||||
`
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
text-gray-700
|
||||
dark:text-gray-300
|
||||
w-full
|
||||
text-left
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
hover:bg-slate-100
|
||||
dark:hover:bg-slate-900
|
||||
`,
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
{StartIcon ? <StartIcon className="h-5 w-5" /> : null}
|
||||
{children}
|
||||
</div>
|
||||
{EndIcon ? <EndIcon className="h-5 w-5" /> : null}
|
||||
</MenuItemUnstyled>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItemLink;
|
32
packages/core/src/components/common/modal/Backdrop.tsx
Normal file
32
packages/core/src/components/common/modal/Backdrop.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
const Backdrop = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ open?: boolean; className: string; ownerState: unknown }
|
||||
>((props, ref) => {
|
||||
const { open, className, ownerState: _ownerState, ...other } = props;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
fixed
|
||||
inset-0
|
||||
bg-black
|
||||
bg-opacity-50
|
||||
dark:bg-opacity-60
|
||||
z-50
|
||||
`,
|
||||
open && 'MuiBackdrop-open',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Backdrop.displayName = 'Backdrop';
|
||||
|
||||
export default Backdrop;
|
68
packages/core/src/components/common/modal/Modal.tsx
Normal file
68
packages/core/src/components/common/modal/Modal.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import ModalUnstyled from '@mui/base/ModalUnstyled';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import Backdrop from './Backdrop';
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Modal: FC<ModalProps> = ({ open, children, className, onClose }) => {
|
||||
const handleClose = useCallback(() => {
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<ModalUnstyled
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
slots={{
|
||||
backdrop: Backdrop,
|
||||
}}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: `
|
||||
fixed
|
||||
inset-0
|
||||
overflow-y-auto
|
||||
z-50
|
||||
flex
|
||||
min-h-full
|
||||
items-center
|
||||
justify-center
|
||||
text-center
|
||||
`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
transform
|
||||
overflow-visible
|
||||
rounded-lg
|
||||
text-left
|
||||
align-middle
|
||||
shadow-xl
|
||||
transition-all
|
||||
bg-white
|
||||
dark:bg-slate-800
|
||||
z-[51]
|
||||
outline-none
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ModalUnstyled>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
76
packages/core/src/components/common/pill/Pill.tsx
Normal file
76
packages/core/src/components/common/pill/Pill.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
interface PillProps {
|
||||
children: ReactNode | ReactNode[];
|
||||
noWrap?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
color?: 'default' | 'primary';
|
||||
}
|
||||
|
||||
const Pill: FC<PillProps> = ({
|
||||
children,
|
||||
noWrap,
|
||||
className,
|
||||
disabled = false,
|
||||
color = 'default',
|
||||
}) => {
|
||||
const colorClassNames = useMemo(() => {
|
||||
switch (color) {
|
||||
case 'primary':
|
||||
return disabled
|
||||
? `
|
||||
bg-blue-300/75
|
||||
text-gray-100/75
|
||||
dark:bg-blue-700/25
|
||||
dark:text-gray-500
|
||||
`
|
||||
: `
|
||||
bg-blue-700
|
||||
text-gray-100
|
||||
dark:bg-blue-700
|
||||
dark:text-gray-100
|
||||
`;
|
||||
default:
|
||||
return disabled
|
||||
? `
|
||||
bg-gray-100
|
||||
text-gray-400/75
|
||||
dark:bg-gray-800/75
|
||||
dark:text-gray-500
|
||||
`
|
||||
: `
|
||||
bg-gray-200
|
||||
text-gray-900
|
||||
dark:bg-gray-700
|
||||
dark:text-gray-100
|
||||
`;
|
||||
}
|
||||
}, [color, disabled]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
`
|
||||
text-xs
|
||||
font-medium
|
||||
mr-2
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
`,
|
||||
noWrap && 'whitespace-nowrap',
|
||||
colorClassNames,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pill;
|
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { FC } from 'react';
|
||||
|
||||
export interface CircularProgressProps {
|
||||
className?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const CircularProgress: FC<CircularProgressProps> = ({ className, 'data-testid': dataTestId }) => {
|
||||
return (
|
||||
<div role="status" className={className} data-testid={dataTestId}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CircularProgress;
|
@ -1,20 +1,6 @@
|
||||
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;
|
||||
`;
|
||||
import CircularProgress from './CircularProgress';
|
||||
|
||||
export interface LoaderProps {
|
||||
children: string | string[] | undefined;
|
||||
@ -49,10 +35,10 @@ const Loader = ({ children }: LoaderProps) => {
|
||||
}, [children, currentItem]);
|
||||
|
||||
return (
|
||||
<StyledLoader>
|
||||
<div className="absolute inset-0 flex flex-col gap-2 items-center justify-center">
|
||||
<CircularProgress />
|
||||
<Typography>{text}</Typography>
|
||||
</StyledLoader>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
59
packages/core/src/components/common/select/Option.tsx
Normal file
59
packages/core/src/components/common/select/Option.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import OptionUnstyled from '@mui/base/OptionUnstyled';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface OptionProps<T> {
|
||||
selectedValue: T | null | T[];
|
||||
value: T | null;
|
||||
children: ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const Option = function <T>({
|
||||
selectedValue,
|
||||
value,
|
||||
children,
|
||||
'data-testid': dataTestId,
|
||||
}: OptionProps<T>) {
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
Array.isArray(selectedValue) && isNotNullish(value)
|
||||
? selectedValue.includes(value)
|
||||
: selectedValue === value,
|
||||
[selectedValue, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<OptionUnstyled
|
||||
value={value}
|
||||
data-testid={dataTestId}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: classNames(
|
||||
`
|
||||
relative
|
||||
select-none
|
||||
py-2
|
||||
px-4
|
||||
cursor-pointer
|
||||
text-gray-900
|
||||
hover:bg-blue-500
|
||||
dark:text-gray-100
|
||||
`,
|
||||
selected ? 'bg-blue-400/75' : '',
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span className={classNames('block truncate', selected ? 'font-medium' : 'font-normal')}>
|
||||
{children}
|
||||
</span>
|
||||
</OptionUnstyled>
|
||||
);
|
||||
};
|
||||
|
||||
export default Option;
|
176
packages/core/src/components/common/select/Select.tsx
Normal file
176
packages/core/src/components/common/select/Select.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import SelectUnstyled from '@mui/base/SelectUnstyled';
|
||||
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
|
||||
import Option from './Option';
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, MouseEvent, ReactNode, Ref } from 'react';
|
||||
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
function getOptionLabelAndValue(option: number | string | Option): Option {
|
||||
if (option && typeof option === 'object' && 'label' in option && 'value' in option) {
|
||||
return option;
|
||||
}
|
||||
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
export type SelectChangeEventHandler = (value: number | string | (number | string)[]) => void;
|
||||
|
||||
export interface SelectProps {
|
||||
label?: ReactNode | ReactNode[];
|
||||
placeholder?: string;
|
||||
value: number | string | (number | string)[];
|
||||
options: (number | string)[] | Option[];
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: SelectChangeEventHandler;
|
||||
}
|
||||
|
||||
const Select = forwardRef(
|
||||
(
|
||||
{ label, placeholder, value, options, required = false, disabled, onChange }: SelectProps,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
const handleChange = useCallback(
|
||||
(_event: MouseEvent | KeyboardEvent | FocusEvent | null, selectedValue: number | string) => {
|
||||
if (Array.isArray(value)) {
|
||||
const newValue = [...value];
|
||||
const index = newValue.indexOf(selectedValue);
|
||||
if (index > -1) {
|
||||
newValue.splice(index, 1);
|
||||
} else if (typeof selectedValue === 'number' || isNotEmpty(selectedValue)) {
|
||||
newValue.push(selectedValue);
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(selectedValue);
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<SelectUnstyled<any>
|
||||
renderValue={() => {
|
||||
return (
|
||||
<>
|
||||
{label ?? placeholder}
|
||||
<span
|
||||
className="
|
||||
pointer-events-none
|
||||
absolute
|
||||
inset-y-0
|
||||
right-0
|
||||
flex
|
||||
items-center
|
||||
pr-2
|
||||
"
|
||||
>
|
||||
<KeyboardArrowDownIcon
|
||||
className={classNames(
|
||||
`
|
||||
h-5
|
||||
w-5
|
||||
text-gray-400
|
||||
`,
|
||||
disabled &&
|
||||
`
|
||||
text-gray-300/75
|
||||
dark:text-gray-600/75
|
||||
`,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
root: {
|
||||
ref,
|
||||
className: classNames(
|
||||
`
|
||||
flex
|
||||
items-center
|
||||
text-sm
|
||||
font-medium
|
||||
relative
|
||||
min-h-8
|
||||
px-4
|
||||
py-1.5
|
||||
w-full
|
||||
text-gray-900
|
||||
dark:text-gray-100
|
||||
`,
|
||||
disabled &&
|
||||
`
|
||||
text-gray-300/75
|
||||
dark:text-gray-600/75
|
||||
`,
|
||||
),
|
||||
},
|
||||
popper: {
|
||||
className: `
|
||||
absolute
|
||||
max-h-60
|
||||
overflow-auto
|
||||
rounded-md
|
||||
bg-white
|
||||
py-1
|
||||
text-base
|
||||
shadow-lg
|
||||
ring-1
|
||||
ring-black
|
||||
ring-opacity-5
|
||||
focus:outline-none
|
||||
sm:text-sm
|
||||
z-50
|
||||
dark:bg-slate-700
|
||||
`,
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
data-testid="select-input"
|
||||
>
|
||||
{!Array.isArray(value) && !required ? (
|
||||
<Option value="" selectedValue={value}>
|
||||
<i>None</i>
|
||||
</Option>
|
||||
) : null}
|
||||
{options.map((option, index) => {
|
||||
const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(option);
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={index}
|
||||
value={optionValue}
|
||||
selectedValue={value}
|
||||
data-testid={`select-option-${optionValue}`}
|
||||
>
|
||||
{optionLabel}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</SelectUnstyled>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export default Select;
|
103
packages/core/src/components/common/switch/Switch.tsx
Normal file
103
packages/core/src/components/common/switch/Switch.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { ChangeEvent, ChangeEventHandler } from 'react';
|
||||
|
||||
export interface SwitchProps {
|
||||
label?: string;
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const Switch = forwardRef<HTMLInputElement | null, SwitchProps>(
|
||||
({ label, value, disabled, onChange }, ref) => {
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(event);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={classNames(
|
||||
`
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
cursor-pointer
|
||||
`,
|
||||
disabled && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
data-testid="switch-input"
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
className="sr-only peer"
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
onClick={() => false}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
w-11
|
||||
h-6
|
||||
bg-slate-200
|
||||
rounded-full
|
||||
peer
|
||||
peer-focus:ring-4
|
||||
peer-focus:ring-blue-300
|
||||
dark:peer-focus:ring-blue-800
|
||||
dark:bg-slate-700
|
||||
peer-checked:after:translate-x-full
|
||||
after:content-['']
|
||||
after:absolute after:top-0.5
|
||||
after:left-[2px]
|
||||
after:border
|
||||
after:rounded-full
|
||||
after:h-5
|
||||
after:w-5
|
||||
after:transition-all
|
||||
dark:border-gray-600
|
||||
`,
|
||||
disabled
|
||||
? `
|
||||
peer-checked:bg-blue-600/25
|
||||
after:bg-gray-500/75
|
||||
after:border-gray-500/75
|
||||
peer-checked:after:border-gray-500/75
|
||||
`
|
||||
: `
|
||||
peer-checked:bg-blue-600
|
||||
after:bg-white
|
||||
after:border-gray-300
|
||||
peer-checked:after:border-white
|
||||
`,
|
||||
)}
|
||||
/>
|
||||
{label ? (
|
||||
<span
|
||||
className="
|
||||
ml-3
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-900
|
||||
dark:text-gray-300
|
||||
"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export default Switch;
|
29
packages/core/src/components/common/table/Table.tsx
Normal file
29
packages/core/src/components/common/table/Table.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import TableHeaderCell from './TableHeaderCell';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TableCellProps {
|
||||
columns: ReactNode[];
|
||||
children: ReactNode[];
|
||||
}
|
||||
|
||||
const TableCell = ({ columns, children }: TableCellProps) => {
|
||||
return (
|
||||
<div className="relative overflow-x-auto shadow-md sm:rounded-lg border border-slate-200 dark:border-gray-700">
|
||||
<table className="w-full text-sm text-left text-gray-500 dark:text-gray-300 ">
|
||||
<thead className="text-xs text-gray-700 uppercase bg-slate-50 dark:bg-slate-700 dark:text-gray-300">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<TableHeaderCell key={index}>{column}</TableHeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableCell;
|
49
packages/core/src/components/common/table/TableCell.tsx
Normal file
49
packages/core/src/components/common/table/TableCell.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TableCellProps {
|
||||
children: ReactNode;
|
||||
emphasis?: boolean;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const TableCell = ({ children, emphasis = false, to }: TableCellProps) => {
|
||||
const content = useMemo(() => {
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="
|
||||
w-full
|
||||
h-full
|
||||
flex
|
||||
px-4
|
||||
py-3
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}, [children, to]);
|
||||
|
||||
return (
|
||||
<td
|
||||
className={classNames(
|
||||
!to ? 'px-4 py-3' : 'p-0',
|
||||
'text-gray-500 dark:text-gray-300',
|
||||
emphasis && 'font-medium text-gray-900 whitespace-nowrap dark:text-white',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableCell;
|
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TableHeaderCellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
|
||||
return (
|
||||
<th scope="col" className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableHeaderCell;
|
31
packages/core/src/components/common/table/TableRow.tsx
Normal file
31
packages/core/src/components/common/table/TableRow.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TableRowProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TableRow = ({ children, className }: TableRowProps) => {
|
||||
return (
|
||||
<tr
|
||||
className={classNames(
|
||||
`
|
||||
border-b
|
||||
bg-white
|
||||
hover:bg-slate-50
|
||||
dark:bg-slate-800
|
||||
dark:border-gray-700
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableRow;
|
92
packages/core/src/components/common/text-field/TextArea.tsx
Normal file
92
packages/core/src/components/common/text-field/TextArea.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import InputUnstyled from '@mui/base/InputUnstyled';
|
||||
import React, { forwardRef, useCallback, useState } from 'react';
|
||||
|
||||
import type { ChangeEventHandler, RefObject } from 'react';
|
||||
|
||||
export interface TextAreaProps {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const MIN_TEXT_AREA_HEIGHT = 80;
|
||||
const MIN_BOTTOM_PADDING = 12;
|
||||
|
||||
function getHeight(rawHeight: string): number {
|
||||
return Number(rawHeight.replace('px', ''));
|
||||
}
|
||||
|
||||
const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
|
||||
({ value, disabled, 'data-testid': dataTestId, onChange }, ref) => {
|
||||
const [lastAutogrowHeight, setLastAutogrowHeight] = useState(MIN_TEXT_AREA_HEIGHT);
|
||||
|
||||
const autoGrow = useCallback(() => {
|
||||
const textarea = (ref as RefObject<HTMLInputElement | null>)?.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHeight = getHeight(textarea.style.height);
|
||||
|
||||
textarea.style.height = '5px';
|
||||
|
||||
let newHeight = textarea.scrollHeight;
|
||||
if (newHeight < MIN_TEXT_AREA_HEIGHT) {
|
||||
newHeight = MIN_TEXT_AREA_HEIGHT;
|
||||
}
|
||||
|
||||
if (currentHeight !== lastAutogrowHeight && currentHeight >= newHeight) {
|
||||
textarea.style.height = `${currentHeight}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
setLastAutogrowHeight(newHeight);
|
||||
|
||||
if (newHeight > MIN_TEXT_AREA_HEIGHT - MIN_BOTTOM_PADDING) {
|
||||
textarea.style.paddingBottom = `${MIN_BOTTOM_PADDING}px`;
|
||||
}
|
||||
}, [lastAutogrowHeight, ref]);
|
||||
|
||||
return (
|
||||
<InputUnstyled
|
||||
multiline
|
||||
minRows={4}
|
||||
onInput={autoGrow}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId ?? 'textarea-input'}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: `
|
||||
flex
|
||||
w-full
|
||||
`,
|
||||
},
|
||||
input: {
|
||||
ref,
|
||||
className: `
|
||||
w-full
|
||||
min-h-[80px]
|
||||
px-3
|
||||
bg-transparent
|
||||
outline-none
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-900
|
||||
dark:text-gray-100
|
||||
disabled:text-gray-300
|
||||
dark:disabled:text-gray-500
|
||||
`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
export default TextArea;
|
91
packages/core/src/components/common/text-field/TextField.tsx
Normal file
91
packages/core/src/components/common/text-field/TextField.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import InputUnstyled from '@mui/base/InputUnstyled';
|
||||
import React from 'react';
|
||||
|
||||
import useCursor from '@staticcms/core/lib/hooks/useCursor';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
import type { ChangeEventHandler, FC, MouseEventHandler, Ref } from 'react';
|
||||
|
||||
export interface BaseTextFieldProps {
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onClick?: MouseEventHandler<HTMLInputElement>;
|
||||
cursor?: 'default' | 'pointer' | 'text';
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export interface NumberTextFieldProps extends BaseTextFieldProps {
|
||||
value: string | number;
|
||||
type: 'number';
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
step?: string | number;
|
||||
}
|
||||
|
||||
export interface TextTextFieldProps extends BaseTextFieldProps {
|
||||
value: string;
|
||||
type: 'text';
|
||||
}
|
||||
|
||||
export type TextFieldProps = TextTextFieldProps | NumberTextFieldProps;
|
||||
|
||||
const TextField: FC<TextFieldProps> = ({
|
||||
value,
|
||||
type,
|
||||
'data-testid': dataTestId,
|
||||
cursor = 'default',
|
||||
inputRef,
|
||||
readonly,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onClick,
|
||||
...otherProps
|
||||
}) => {
|
||||
const finalCursor = useCursor(cursor, disabled);
|
||||
|
||||
return (
|
||||
<InputUnstyled
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
data-testid={dataTestId ?? `${type}-input`}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: `
|
||||
flex
|
||||
w-full
|
||||
`,
|
||||
},
|
||||
input: {
|
||||
ref: inputRef,
|
||||
className: classNames(
|
||||
`
|
||||
w-full
|
||||
h-6
|
||||
px-3
|
||||
bg-transparent
|
||||
outline-none
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-900
|
||||
disabled:text-gray-300
|
||||
dark:text-gray-100
|
||||
dark:disabled:text-gray-500
|
||||
`,
|
||||
finalCursor === 'pointer' && 'cursor-pointer',
|
||||
finalCursor === 'text' && 'cursor-text',
|
||||
finalCursor === 'default' && 'cursor-default',
|
||||
),
|
||||
...otherProps,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextField;
|
@ -0,0 +1,39 @@
|
||||
import { Grid as GridIcon } from '@styled-icons/bootstrap/Grid';
|
||||
import { TableRows as TableRowsIcon } from '@styled-icons/material-rounded/TableRows';
|
||||
import React from 'react';
|
||||
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/views';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
import IconButton from '../button/IconButton';
|
||||
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
|
||||
interface ViewStyleControlPros {
|
||||
viewStyle: ViewStyle;
|
||||
onChangeViewStyle: (viewStyle: ViewStyle) => void;
|
||||
}
|
||||
|
||||
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mr-1">
|
||||
<IconButton
|
||||
variant="text"
|
||||
className={classNames(viewStyle === VIEW_STYLE_LIST && 'text-blue-500 dark:text-blue-500')}
|
||||
aria-label="table view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<TableRowsIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="text"
|
||||
className={classNames(viewStyle === VIEW_STYLE_GRID && 'text-blue-500 dark:text-blue-500')}
|
||||
aria-label="grid view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<GridIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewStyleControl;
|
@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { getAsset } from '@staticcms/core/actions/media';
|
||||
import { useInferredFields } from '@staticcms/core/lib/util/collection.util';
|
||||
import { useAppDispatch } from '@staticcms/core/store/hooks';
|
||||
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import getWidgetFor from './widgetFor';
|
||||
|
||||
import type {
|
||||
@ -23,28 +23,21 @@ export default function useWidgetsFor(
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
): {
|
||||
widgetFor: WidgetFor<EntryData>;
|
||||
widgetsFor: WidgetsFor<EntryData>;
|
||||
widgetFor: WidgetFor;
|
||||
widgetsFor: WidgetsFor;
|
||||
} {
|
||||
const inferredFields = useInferredFields(collection);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleGetAsset = useCallback(
|
||||
(path: string, field?: Field) => {
|
||||
return dispatch(getAsset(collection, entry, path, field));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collection],
|
||||
);
|
||||
const theme = useAppSelector(selectTheme);
|
||||
|
||||
const widgetFor = useCallback(
|
||||
(name: string): ReturnType<WidgetFor<EntryData>> => {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
return getWidgetFor(config, collection, name, fields, entry, inferredFields, handleGetAsset);
|
||||
return getWidgetFor(config, collection, name, fields, entry, theme, inferredFields);
|
||||
},
|
||||
[collection, config, entry, fields, handleGetAsset, inferredFields],
|
||||
[collection, config, entry, fields, inferredFields, theme],
|
||||
);
|
||||
|
||||
/**
|
||||
@ -93,8 +86,8 @@ export default function useWidgetsFor(
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
handleGetAsset,
|
||||
nestedFields,
|
||||
val,
|
||||
index,
|
||||
@ -125,8 +118,8 @@ export default function useWidgetsFor(
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
handleGetAsset,
|
||||
nestedFields,
|
||||
value,
|
||||
index,
|
||||
@ -137,11 +130,11 @@ export default function useWidgetsFor(
|
||||
}, {} as Record<string, ReactNode>),
|
||||
};
|
||||
},
|
||||
[collection, config, entry, fields, handleGetAsset, inferredFields],
|
||||
[collection, config, entry, fields, inferredFields, theme],
|
||||
);
|
||||
|
||||
return {
|
||||
widgetFor,
|
||||
widgetsFor,
|
||||
widgetFor: widgetFor as WidgetFor,
|
||||
widgetsFor: widgetsFor as WidgetsFor,
|
||||
};
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ import type {
|
||||
Entry,
|
||||
EntryData,
|
||||
Field,
|
||||
GetAssetFunction,
|
||||
InferredField,
|
||||
ListField,
|
||||
RenderedField,
|
||||
@ -32,8 +31,8 @@ export default function getWidgetFor(
|
||||
name: string,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
theme: 'dark' | 'light',
|
||||
inferredFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[] = fields,
|
||||
values: EntryData = entry.data,
|
||||
idx: number | null = null,
|
||||
@ -56,8 +55,8 @@ export default function getWidgetFor(
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
getAsset,
|
||||
field.fields,
|
||||
value as EntryData | EntryData[],
|
||||
),
|
||||
@ -70,8 +69,8 @@ export default function getWidgetFor(
|
||||
collection,
|
||||
field,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
getAsset,
|
||||
value as EntryData[],
|
||||
),
|
||||
};
|
||||
@ -97,14 +96,22 @@ export default function getWidgetFor(
|
||||
renderedValue = (
|
||||
<div key={field.name}>
|
||||
<>
|
||||
<strong>{field.label ?? field.name}:</strong> {value}
|
||||
<strong
|
||||
className="
|
||||
text-slate-500
|
||||
dark:text-slate-400
|
||||
"
|
||||
>
|
||||
{field.label ?? field.name}:
|
||||
</strong>{' '}
|
||||
{value}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderedValue
|
||||
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset, idx)
|
||||
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, theme, idx)
|
||||
: null;
|
||||
}
|
||||
|
||||
@ -116,8 +123,8 @@ function getNestedWidgets(
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
theme: 'dark' | 'light',
|
||||
inferredFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[],
|
||||
values: EntryData | EntryData[],
|
||||
) {
|
||||
@ -129,8 +136,8 @@ function getNestedWidgets(
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
value,
|
||||
),
|
||||
@ -143,8 +150,8 @@ function getNestedWidgets(
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
values,
|
||||
);
|
||||
@ -158,13 +165,13 @@ function getTypedNestedWidgets(
|
||||
collection: Collection,
|
||||
field: ListField,
|
||||
entry: Entry,
|
||||
theme: 'dark' | 'light',
|
||||
inferredFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
values: EntryData[],
|
||||
) {
|
||||
return values
|
||||
?.flatMap((value, index) => {
|
||||
const itemType = getTypedFieldForValue(field, value ?? {}, index);
|
||||
const [_, itemType] = getTypedFieldForValue(field, value ?? {}, index);
|
||||
if (!itemType) {
|
||||
return null;
|
||||
}
|
||||
@ -174,8 +181,8 @@ function getTypedNestedWidgets(
|
||||
collection,
|
||||
itemType.fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
getAsset,
|
||||
itemType.fields,
|
||||
value,
|
||||
index,
|
||||
@ -192,8 +199,8 @@ function widgetsForNestedFields(
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
theme: 'dark' | 'light',
|
||||
inferredFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[],
|
||||
values: EntryData,
|
||||
idx: number | null = null,
|
||||
@ -206,8 +213,8 @@ function widgetsForNestedFields(
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
theme,
|
||||
inferredFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
values,
|
||||
idx,
|
||||
@ -222,7 +229,7 @@ function getWidget(
|
||||
collection: Collection,
|
||||
value: ValueOrNestedValue | ReactNode,
|
||||
entry: Entry,
|
||||
getAsset: GetAssetFunction,
|
||||
theme: 'dark' | 'light',
|
||||
idx: number | null = null,
|
||||
) {
|
||||
if (!field.widget) {
|
||||
@ -244,7 +251,6 @@ function getWidget(
|
||||
previewComponent={widget.preview as WidgetPreviewComponent}
|
||||
key={key}
|
||||
field={field as RenderedField}
|
||||
getAsset={getAsset}
|
||||
config={config}
|
||||
collection={collection}
|
||||
value={
|
||||
@ -258,6 +264,7 @@ function getWidget(
|
||||
: value
|
||||
}
|
||||
entry={entry}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { logoutUser } from '@staticcms/core/actions/auth';
|
||||
import {
|
||||
createDraftDuplicateFromEntry,
|
||||
createEmptyDraft,
|
||||
@ -23,8 +22,9 @@ import { selectFields } from '@staticcms/core/lib/util/collection.util';
|
||||
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||
import { selectEntry } from '@staticcms/core/reducers/selectors/entries';
|
||||
import { useAppDispatch } from '@staticcms/core/store/hooks';
|
||||
import confirm from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import confirm from '../common/confirm/Confirm';
|
||||
import Loader from '../common/progress/Loader';
|
||||
import MediaLibraryModal from '../media-library/MediaLibraryModal';
|
||||
import EditorInterface from './EditorInterface';
|
||||
|
||||
import type {
|
||||
@ -43,16 +43,14 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
|
||||
entryDraft,
|
||||
fields,
|
||||
collection,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isModification,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
showDelete,
|
||||
slug,
|
||||
localBackup,
|
||||
scrollSyncActive,
|
||||
t,
|
||||
}) => {
|
||||
const [version, setVersion] = useState(0);
|
||||
@ -264,10 +262,6 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
|
||||
};
|
||||
}, [collection.name, history, navigationBlocker]);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
dispatch(logoutUser());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleToggleScroll = useCallback(async () => {
|
||||
await dispatch(toggleScroll());
|
||||
}, [dispatch]);
|
||||
@ -287,30 +281,30 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
|
||||
}
|
||||
|
||||
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={handleLogout}
|
||||
editorBackLink={editorBackLink}
|
||||
toggleScroll={handleToggleScroll}
|
||||
scrollSyncEnabled={scrollSyncEnabled}
|
||||
loadScroll={handleLoadScroll}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
<>
|
||||
<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}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
isNewEntry={!slug}
|
||||
isModification={isModification}
|
||||
toggleScroll={handleToggleScroll}
|
||||
scrollSyncActive={scrollSyncActive}
|
||||
loadScroll={handleLoadScroll}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
<MediaLibraryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -322,33 +316,18 @@ interface CollectionViewOwnProps {
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) {
|
||||
const { collections, entryDraft, auth, config, entries, scroll } = state;
|
||||
const { collections, entryDraft, 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,
|
||||
@ -356,15 +335,13 @@ function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) {
|
||||
entryDraft,
|
||||
fields,
|
||||
entry,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
localBackup,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
scrollSyncActive: scroll.isScrolling,
|
||||
};
|
||||
}
|
||||
|
282
packages/core/src/components/entry-editor/EditorInterface.tsx
Normal file
282
packages/core/src/components/entry-editor/EditorInterface.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n';
|
||||
import {
|
||||
getFileFromSlug,
|
||||
selectEntryCollectionTitle,
|
||||
} from '@staticcms/core/lib/util/collection.util';
|
||||
import MainView from '../MainView';
|
||||
import EditorControlPane from './editor-control-pane/EditorControlPane';
|
||||
import EditorPreviewPane from './editor-preview-pane/EditorPreviewPane';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
const I18N_VISIBLE = 'cms.i18n-visible';
|
||||
|
||||
interface EditorContentProps {
|
||||
i18nActive: boolean;
|
||||
previewActive: boolean;
|
||||
editor: JSX.Element;
|
||||
editorSideBySideLocale: JSX.Element;
|
||||
editorWithPreview: JSX.Element;
|
||||
}
|
||||
|
||||
const EditorContent = ({
|
||||
i18nActive,
|
||||
previewActive,
|
||||
editor,
|
||||
editorSideBySideLocale,
|
||||
editorWithPreview,
|
||||
}: EditorContentProps) => {
|
||||
if (i18nActive) {
|
||||
return editorSideBySideLocale;
|
||||
} else if (previewActive) {
|
||||
return editorWithPreview;
|
||||
} else {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-editor-only max-w-full">{editor}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface EditorInterfaceProps {
|
||||
draftKey: string;
|
||||
entry: Entry;
|
||||
collection: Collection;
|
||||
fields: Field[] | undefined;
|
||||
fieldsErrors: FieldsErrors;
|
||||
onPersist: (opts?: EditorPersistOptions) => void;
|
||||
onDelete: () => Promise<void>;
|
||||
onDuplicate: () => void;
|
||||
showDelete: boolean;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
isNewEntry: boolean;
|
||||
isModification: boolean;
|
||||
toggleScroll: () => Promise<void>;
|
||||
scrollSyncActive: boolean;
|
||||
loadScroll: () => void;
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
const EditorInterface = ({
|
||||
collection,
|
||||
entry,
|
||||
fields = [],
|
||||
fieldsErrors,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onPersist,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
draftKey, // TODO Review usage
|
||||
scrollSyncActive,
|
||||
t,
|
||||
loadScroll,
|
||||
toggleScroll,
|
||||
submitted,
|
||||
}: TranslatedProps<EditorInterfaceProps>) => {
|
||||
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
|
||||
const [selectedLocale, setSelectedLocale] = useState<string>(locales?.[1] ?? 'en');
|
||||
|
||||
const [previewActive, setPreviewActive] = useState(
|
||||
localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
|
||||
);
|
||||
const [i18nActive, setI18nActive] = useState(
|
||||
Boolean(localStorage.getItem(I18N_VISIBLE) !== 'false' && locales && locales.length > 0),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadScroll();
|
||||
}, [loadScroll]);
|
||||
|
||||
const handleOnPersist = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
// await switchToDefaultLocale();
|
||||
onPersist({ createNew, duplicate });
|
||||
},
|
||||
[onPersist],
|
||||
);
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
const newPreviewActive = !previewActive;
|
||||
setPreviewActive(newPreviewActive);
|
||||
localStorage.setItem(PREVIEW_VISIBLE, `${newPreviewActive}`);
|
||||
}, [previewActive]);
|
||||
|
||||
const handleToggleScrollSync = useCallback(() => {
|
||||
toggleScroll();
|
||||
}, [toggleScroll]);
|
||||
|
||||
const handleToggleI18n = useCallback(() => {
|
||||
const newI18nActive = !i18nActive;
|
||||
setI18nActive(newI18nActive);
|
||||
localStorage.setItem(I18N_VISIBLE, `${newI18nActive}`);
|
||||
}, [i18nActive]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: string) => {
|
||||
setSelectedLocale(locale);
|
||||
}, []);
|
||||
|
||||
const [showPreviewToggle, 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 collectHasI18n = hasI18n(collection);
|
||||
|
||||
const editor = (
|
||||
<div
|
||||
key={defaultLocale}
|
||||
id="control-pane"
|
||||
className="
|
||||
w-full
|
||||
overflow-y-auto
|
||||
styled-scrollbars
|
||||
"
|
||||
>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={defaultLocale}
|
||||
submitted={submitted}
|
||||
hideBorder={!previewActive && !i18nActive}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorLocale = useMemo(
|
||||
() => (
|
||||
<div key={selectedLocale}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={selectedLocale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
canChangeLocale
|
||||
hideBorder
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
|
||||
);
|
||||
|
||||
const previewEntry = collectHasI18n
|
||||
? getPreviewEntry(entry, selectedLocale[0], defaultLocale)
|
||||
: entry;
|
||||
|
||||
const editorWithPreview = (
|
||||
<div className="grid grid-cols-editor h-full">
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<EditorPreviewPane
|
||||
collection={collection}
|
||||
previewInFrame={previewInFrame}
|
||||
entry={previewEntry}
|
||||
fields={fields}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorSideBySideLocale = (
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>
|
||||
<>{editorLocale}</>
|
||||
</ScrollSyncPane>
|
||||
</div>
|
||||
);
|
||||
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
|
||||
return (
|
||||
<MainView
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: collection.label,
|
||||
to: `/collections/${collection.name}`,
|
||||
},
|
||||
{
|
||||
name: isNewEntry
|
||||
? t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collection.label_singular || collection.label,
|
||||
})
|
||||
: summary,
|
||||
},
|
||||
]}
|
||||
noMargin
|
||||
noScroll
|
||||
navbarActions={
|
||||
<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}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
showPreviewToggle={showPreviewToggle}
|
||||
previewActive={previewActive}
|
||||
scrollSyncActive={scrollSyncActive}
|
||||
showI18nToggle={collectHasI18n}
|
||||
i18nActive={i18nActive}
|
||||
togglePreview={handleTogglePreview}
|
||||
toggleScrollSync={handleToggleScrollSync}
|
||||
toggleI18n={handleToggleI18n}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EditorContent
|
||||
key={draftKey}
|
||||
i18nActive={i18nActive}
|
||||
previewActive={previewActive && !i18nActive}
|
||||
editor={editor}
|
||||
editorSideBySideLocale={editorSideBySideLocale}
|
||||
editorWithPreview={editorWithPreview}
|
||||
/>
|
||||
</MainView>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
193
packages/core/src/components/entry-editor/EditorToolbar.tsx
Normal file
193
packages/core/src/components/entry-editor/EditorToolbar.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { DocumentAdd as DocumentAddIcon } from '@styled-icons/fluentui-system-regular/DocumentAdd';
|
||||
import { DocumentDuplicate as DocumentDuplicateIcon } from '@styled-icons/heroicons-outline/DocumentDuplicate';
|
||||
import { Eye as EyeIcon } from '@styled-icons/heroicons-outline/Eye';
|
||||
import { GlobeAlt as GlobeAltIcon } from '@styled-icons/heroicons-outline/GlobeAlt';
|
||||
import { Trash as TrashIcon } from '@styled-icons/heroicons-outline/Trash';
|
||||
import { Height as HeightIcon } from '@styled-icons/material-rounded/Height';
|
||||
import { Check as CheckIcon } from '@styled-icons/material/Check';
|
||||
import { MoreVert as MoreVertIcon } from '@styled-icons/material/MoreVert';
|
||||
import { Publish as PublishIcon } from '@styled-icons/material/Publish';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util';
|
||||
import Menu from '../common/menu/Menu';
|
||||
import MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
|
||||
import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
|
||||
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;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
collection: Collection;
|
||||
isNewEntry: boolean;
|
||||
isModification?: boolean;
|
||||
showPreviewToggle: boolean;
|
||||
previewActive: boolean;
|
||||
scrollSyncActive: boolean;
|
||||
showI18nToggle: boolean;
|
||||
i18nActive: boolean;
|
||||
togglePreview: MouseEventHandler;
|
||||
toggleScrollSync: MouseEventHandler;
|
||||
toggleI18n: MouseEventHandler;
|
||||
}
|
||||
|
||||
const EditorToolbar = ({
|
||||
hasChanged,
|
||||
// TODO displayUrl,
|
||||
collection,
|
||||
onDuplicate,
|
||||
// TODO isPersisting,
|
||||
onPersist,
|
||||
onPersistAndDuplicate,
|
||||
onPersistAndNew,
|
||||
isNewEntry,
|
||||
showDelete,
|
||||
onDelete,
|
||||
t,
|
||||
showPreviewToggle,
|
||||
previewActive,
|
||||
scrollSyncActive,
|
||||
showI18nToggle,
|
||||
i18nActive,
|
||||
togglePreview,
|
||||
toggleScrollSync,
|
||||
toggleI18n,
|
||||
}: 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 menuItems = useMemo(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
|
||||
if (!isPublished) {
|
||||
items.push(
|
||||
<MenuItemButton key="publishNow" onClick={() => onPersist()} startIcon={PublishIcon}>
|
||||
{t('editor.editorToolbar.publishNow')}
|
||||
</MenuItemButton>,
|
||||
);
|
||||
|
||||
if (canCreate) {
|
||||
items.push(
|
||||
<MenuItemButton
|
||||
key="publishAndCreateNew"
|
||||
onClick={onPersistAndNew}
|
||||
startIcon={DocumentAddIcon}
|
||||
>
|
||||
{t('editor.editorToolbar.publishAndCreateNew')}
|
||||
</MenuItemButton>,
|
||||
<MenuItemButton
|
||||
key="publishAndDuplicate"
|
||||
onClick={onPersistAndDuplicate}
|
||||
startIcon={DocumentDuplicateIcon}
|
||||
>
|
||||
{t('editor.editorToolbar.publishAndDuplicate')}
|
||||
</MenuItemButton>,
|
||||
);
|
||||
}
|
||||
} else if (canCreate) {
|
||||
items.push(
|
||||
<MenuItemButton key="duplicate" onClick={onDuplicate} startIcon={DocumentDuplicateIcon}>
|
||||
{t('editor.editorToolbar.duplicate')}
|
||||
</MenuItemButton>,
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [canCreate, isPublished, onDuplicate, onPersist, onPersistAndDuplicate, onPersistAndNew, t]);
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div className="flex gap-2">
|
||||
{showI18nToggle || showPreviewToggle || showDelete ? (
|
||||
<Menu
|
||||
key="extra-menu"
|
||||
label={<MoreVertIcon className="w-5 h-5" />}
|
||||
variant="text"
|
||||
className="px-1.5"
|
||||
hideDropdownIcon
|
||||
>
|
||||
<MenuGroup>
|
||||
{showI18nToggle && (
|
||||
<MenuItemButton
|
||||
onClick={toggleI18n}
|
||||
startIcon={GlobeAltIcon}
|
||||
endIcon={i18nActive ? CheckIcon : undefined}
|
||||
>
|
||||
{t('editor.editorInterface.sideBySideI18n')}
|
||||
</MenuItemButton>
|
||||
)}
|
||||
{showPreviewToggle && (
|
||||
<>
|
||||
<MenuItemButton
|
||||
onClick={togglePreview}
|
||||
disabled={i18nActive}
|
||||
startIcon={EyeIcon}
|
||||
endIcon={previewActive && !i18nActive ? CheckIcon : undefined}
|
||||
>
|
||||
{t('editor.editorInterface.preview')}
|
||||
</MenuItemButton>
|
||||
<MenuItemButton
|
||||
onClick={toggleScrollSync}
|
||||
disabled={i18nActive || !previewActive}
|
||||
startIcon={HeightIcon}
|
||||
endIcon={
|
||||
scrollSyncActive && !(i18nActive || !previewActive) ? CheckIcon : undefined
|
||||
}
|
||||
>
|
||||
{t('editor.editorInterface.toggleScrollSync')}
|
||||
</MenuItemButton>
|
||||
</>
|
||||
)}
|
||||
</MenuGroup>
|
||||
{showDelete && canDelete ? (
|
||||
<MenuGroup key="delete-button">
|
||||
<MenuItemButton onClick={onDelete} startIcon={TrashIcon} color="error">
|
||||
{t('editor.editorToolbar.deleteEntry')}
|
||||
</MenuItemButton>
|
||||
</MenuGroup>
|
||||
) : null}
|
||||
</Menu>
|
||||
) : null}
|
||||
<Menu
|
||||
label={isPublished ? 'Published' : 'Publish'}
|
||||
color={isPublished ? 'success' : 'primary'}
|
||||
>
|
||||
<MenuGroup>{menuItems}</MenuGroup>
|
||||
</Menu>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
showI18nToggle,
|
||||
showPreviewToggle,
|
||||
showDelete,
|
||||
toggleI18n,
|
||||
i18nActive,
|
||||
t,
|
||||
togglePreview,
|
||||
previewActive,
|
||||
toggleScrollSync,
|
||||
scrollSyncActive,
|
||||
canDelete,
|
||||
onDelete,
|
||||
isPublished,
|
||||
menuItems,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(EditorToolbar) as FC<EditorToolbarProps>;
|
@ -1,4 +1,3 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { isEqual } from 'lodash';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React, { createElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
@ -9,7 +8,6 @@ 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,
|
||||
@ -17,8 +15,6 @@ import {
|
||||
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 { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n';
|
||||
@ -27,13 +23,13 @@ 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/selectors/entryDraft';
|
||||
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
||||
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
|
||||
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
||||
|
||||
import type {
|
||||
Field,
|
||||
FieldsErrors,
|
||||
GetAssetFunction,
|
||||
I18nSettings,
|
||||
TranslatedProps,
|
||||
UnknownField,
|
||||
@ -44,98 +40,6 @@ 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,
|
||||
@ -144,12 +48,9 @@ const EditorControl = ({
|
||||
field,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
getAsset,
|
||||
isDisabled = false,
|
||||
isParentDuplicate = false,
|
||||
isFieldDuplicate: deprecatedIsFieldDuplicate,
|
||||
isParentHidden = false,
|
||||
isFieldHidden: deprecatedIsFieldHidden,
|
||||
disabled = false,
|
||||
parentDuplicate = false,
|
||||
parentHidden = false,
|
||||
locale,
|
||||
mediaPaths,
|
||||
openMediaLibrary,
|
||||
@ -160,6 +61,7 @@ const EditorControl = ({
|
||||
t,
|
||||
value,
|
||||
forList = false,
|
||||
forSingleList = false,
|
||||
changeDraftField,
|
||||
i18n,
|
||||
fieldName,
|
||||
@ -170,7 +72,8 @@ const EditorControl = ({
|
||||
|
||||
const widgetName = field.widget;
|
||||
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue>;
|
||||
const fieldHint = field.hint;
|
||||
|
||||
const theme = useAppSelector(selectTheme);
|
||||
|
||||
const path = useMemo(
|
||||
() =>
|
||||
@ -185,35 +88,28 @@ const EditorControl = ({
|
||||
|
||||
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],
|
||||
const duplicate = useMemo(
|
||||
() => parentDuplicate || isFieldDuplicate(field, locale, i18n?.defaultLocale),
|
||||
[field, i18n?.defaultLocale, parentDuplicate, locale],
|
||||
);
|
||||
|
||||
const isDuplicate = useMemo(
|
||||
() => isParentDuplicate || isFieldDuplicate(field, locale, i18n?.defaultLocale),
|
||||
[field, i18n?.defaultLocale, isParentDuplicate, locale],
|
||||
);
|
||||
const isHidden = useMemo(
|
||||
() => isParentHidden || isFieldHidden(field, locale, i18n?.defaultLocale),
|
||||
[field, i18n?.defaultLocale, isParentHidden, locale],
|
||||
const hidden = useMemo(
|
||||
() => parentHidden || isFieldHidden(field, locale, i18n?.defaultLocale),
|
||||
[field, i18n?.defaultLocale, parentHidden, locale],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!dirty && !submitted) || isHidden) {
|
||||
if ((!dirty && !submitted) || hidden || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validateValue = async () => {
|
||||
console.log('VALIDATING', field.name);
|
||||
const errors = await validate(field, value, widget, t);
|
||||
dispatch(changeDraftFieldValidation(path, errors, i18n));
|
||||
};
|
||||
|
||||
validateValue();
|
||||
}, [dirty, dispatch, field, i18n, isHidden, path, submitted, t, value, widget]);
|
||||
}, [dirty, dispatch, field, i18n, hidden, path, submitted, t, value, widget, disabled]);
|
||||
|
||||
const handleChangeDraftField = useCallback(
|
||||
(value: ValueOrNestedValue) => {
|
||||
@ -257,70 +153,52 @@ const EditorControl = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlContainer $isHidden={isHidden}>
|
||||
<>
|
||||
{createElement(widget.control, {
|
||||
key: `${id}-${version}`,
|
||||
collection,
|
||||
config,
|
||||
entry,
|
||||
field: field as UnknownField,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
getAsset: handleGetAsset,
|
||||
isDisabled: isDisabled || isDuplicate,
|
||||
isDuplicate,
|
||||
isFieldDuplicate: deprecatedIsFieldDuplicate,
|
||||
isHidden,
|
||||
isFieldHidden: deprecatedIsFieldHidden,
|
||||
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>
|
||||
<div>
|
||||
{createElement(widget.control, {
|
||||
key: `${id}-${version}`,
|
||||
collection,
|
||||
config,
|
||||
entry,
|
||||
field: field as UnknownField,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
disabled: disabled || duplicate || hidden,
|
||||
duplicate,
|
||||
hidden,
|
||||
label: getFieldLabel(field, t),
|
||||
locale,
|
||||
mediaPaths,
|
||||
onChange: handleChangeDraftField,
|
||||
clearMediaControl,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
path,
|
||||
query,
|
||||
t,
|
||||
value: finalValue,
|
||||
forList,
|
||||
forSingleList,
|
||||
i18n,
|
||||
hasErrors,
|
||||
errors,
|
||||
theme,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
collection,
|
||||
config,
|
||||
path,
|
||||
errors,
|
||||
isHidden,
|
||||
widget.control,
|
||||
field,
|
||||
hidden,
|
||||
widget.control,
|
||||
id,
|
||||
version,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
handleGetAsset,
|
||||
isDisabled,
|
||||
disabled,
|
||||
duplicate,
|
||||
t,
|
||||
locale,
|
||||
mediaPaths,
|
||||
@ -329,12 +207,14 @@ const EditorControl = ({
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
path,
|
||||
query,
|
||||
finalValue,
|
||||
forList,
|
||||
forSingleList,
|
||||
i18n,
|
||||
hasErrors,
|
||||
fieldHint,
|
||||
errors,
|
||||
]);
|
||||
};
|
||||
|
||||
@ -342,21 +222,14 @@ interface EditorControlOwnProps {
|
||||
field: Field;
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
isDisabled?: boolean;
|
||||
isParentDuplicate?: boolean;
|
||||
/**
|
||||
* @deprecated use isDuplicate instead
|
||||
*/
|
||||
isFieldDuplicate?: (field: Field) => boolean;
|
||||
isParentHidden?: boolean;
|
||||
/**
|
||||
* @deprecated use isHidden instead
|
||||
*/
|
||||
isFieldHidden?: (field: Field) => boolean;
|
||||
disabled?: boolean;
|
||||
parentDuplicate?: boolean;
|
||||
parentHidden?: boolean;
|
||||
locale?: string;
|
||||
parentPath: string;
|
||||
value: ValueOrNestedValue;
|
||||
forList?: boolean;
|
||||
forSingleList?: boolean;
|
||||
i18n: I18nSettings | undefined;
|
||||
fieldName?: string;
|
||||
}
|
||||
@ -384,7 +257,6 @@ const mapDispatchToProps = {
|
||||
removeMediaControl: removeMediaControlAction,
|
||||
removeInsertedMedia: removeInsertedMediaAction,
|
||||
query: queryAction,
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user