feat: ui overhaul (#676)

This commit is contained in:
Daniel Lautzenheiser
2023-03-30 13:29:09 -04:00
committed by GitHub
parent 5c86462859
commit 66b81e9228
385 changed files with 20607 additions and 16493 deletions

View File

@ -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,
};
}

View File

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

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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>;

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

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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>;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -1,2 +0,0 @@
export { default as ErrorBoundary } from './ErrorBoundary';
export { default as SettingsDropdown } from './SettingsDropdown';

View File

@ -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,
};

View File

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

View File

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

View File

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

View File

@ -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}

View File

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

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

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

View File

@ -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}

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

View File

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

View File

@ -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;
}

View File

@ -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 };
}

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

View File

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

View File

@ -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;

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

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

View File

@ -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]);
}

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

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

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

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

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

View File

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

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

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

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

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

View File

@ -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;

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

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

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

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

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

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

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

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

View File

@ -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;

View File

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

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

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

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

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

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

View File

@ -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;

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

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

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

View File

@ -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;

View File

@ -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,
};
}

View File

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

View File

@ -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,
};
}

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

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

View File

@ -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