feat: mobile support (#831)

This commit is contained in:
Daniel Lautzenheiser 2023-05-31 11:55:53 -04:00 committed by GitHub
parent 7744df1103
commit 83c9d91b88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 2210 additions and 1056 deletions

View File

@ -870,7 +870,7 @@ collections:
- value: 2 - value: 2
label: Another fancy label label: Another fancy label
- value: c - value: c
label: And one more fancy label label: And one more fancy label test test test test test test test
- label: Value and Label With Default - label: Value and Label With Default
name: value_and_label_with_default name: value_and_label_with_default
widget: select widget: select

View File

@ -84,9 +84,9 @@
"@styled-icons/material-rounded": "10.47.0", "@styled-icons/material-rounded": "10.47.0",
"@styled-icons/remix-editor": "10.46.0", "@styled-icons/remix-editor": "10.46.0",
"@styled-icons/simple-icons": "10.46.0", "@styled-icons/simple-icons": "10.46.0",
"@udecode/plate": "21.1.4", "@udecode/plate": "21.3.2",
"@udecode/plate-juice": "21.0.0", "@udecode/plate-juice": "21.3.2",
"@udecode/plate-serializer-md": "21.0.0", "@udecode/plate-serializer-md": "21.3.2",
"@uiw/codemirror-extensions-langs": "4.19.16", "@uiw/codemirror-extensions-langs": "4.19.16",
"@uiw/react-codemirror": "4.19.16", "@uiw/react-codemirror": "4.19.16",
"ajv": "8.12.0", "ajv": "8.12.0",
@ -156,10 +156,10 @@
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"scheduler": "0.23.0", "scheduler": "0.23.0",
"semaphore": "1.1.0", "semaphore": "1.1.0",
"slate": "0.91.4", "slate": "0.94.1",
"slate-history": "0.86.0", "slate-history": "0.93.0",
"slate-hyperscript": "0.77.0", "slate-hyperscript": "0.77.0",
"slate-react": "0.91.10", "slate-react": "0.95.0",
"stream-browserify": "3.0.0", "stream-browserify": "3.0.0",
"styled-components": "5.3.10", "styled-components": "5.3.10",
"symbol-observable": "4.0.0", "symbol-observable": "4.0.0",
@ -271,6 +271,7 @@
"tailwindcss": "3.3.1", "tailwindcss": "3.3.1",
"to-string-loader": "1.2.0", "to-string-loader": "1.2.0",
"ts-jest": "29.1.0", "ts-jest": "29.1.0",
"ts-node": "10.9.1",
"tsconfig-paths-webpack-plugin": "4.0.1", "tsconfig-paths-webpack-plugin": "4.0.1",
"typescript": "5.0.4", "typescript": "5.0.4",
"webpack": "5.80.0", "webpack": "5.80.0",

View File

@ -16,6 +16,7 @@ import addExtensions from './extensions';
import { getPhrases } from './lib/phrases'; import { getPhrases } from './lib/phrases';
import { selectLocale } from './reducers/selectors/config'; import { selectLocale } from './reducers/selectors/config';
import { store } from './store'; import { store } from './store';
import useMeta from './lib/hooks/useMeta';
import type { AnyAction } from '@reduxjs/toolkit'; import type { AnyAction } from '@reduxjs/toolkit';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
@ -45,6 +46,8 @@ import ReactDOM from 'react-dom';
ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint = true; ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint = true;
const TranslatedApp = ({ locale, config }: AppRootProps) => { const TranslatedApp = ({ locale, config }: AppRootProps) => {
useMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' });
if (!config) { if (!config) {
return null; return null;
} }

View File

@ -2,11 +2,12 @@ import React from 'react';
import TopBarProgress from 'react-topbar-progress-indicator'; import TopBarProgress from 'react-topbar-progress-indicator';
import classNames from '../lib/util/classNames.util'; import classNames from '../lib/util/classNames.util';
import BottomNavigation from './navbar/BottomNavigation';
import Navbar from './navbar/Navbar'; import Navbar from './navbar/Navbar';
import Sidebar from './navbar/Sidebar'; import Sidebar from './navbar/Sidebar';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Breadcrumb } from '../interface'; import type { Breadcrumb, Collection } from '../interface';
TopBarProgress.config({ TopBarProgress.config({
barColors: { barColors: {
@ -25,6 +26,7 @@ interface MainViewProps {
noMargin?: boolean; noMargin?: boolean;
noScroll?: boolean; noScroll?: boolean;
children: ReactNode; children: ReactNode;
collection?: Collection;
} }
const MainView = ({ const MainView = ({
@ -35,6 +37,7 @@ const MainView = ({
noMargin = false, noMargin = false,
noScroll = false, noScroll = false,
navbarActions, navbarActions,
collection,
}: MainViewProps) => { }: MainViewProps) => {
return ( return (
<> <>
@ -48,11 +51,12 @@ const MainView = ({
<div <div
id="main-view" id="main-view"
className={classNames( className={classNames(
showLeftNav ? 'w-main left-64' : 'w-full', showLeftNav ? ' w-full left-0 md:w-main' : 'w-full',
!noMargin && 'px-5 py-4', !noMargin && 'px-5 py-4',
noScroll ? 'overflow-hidden' : 'overflow-y-auto', noScroll ? 'overflow-hidden' : 'overflow-y-auto',
` `
h-main h-main-mobile
md:h-main
relative relative
styled-scrollbars styled-scrollbars
`, `,
@ -61,6 +65,7 @@ const MainView = ({
{children} {children}
</div> </div>
</div> </div>
<BottomNavigation collection={collection} />
</> </>
); );
}; };

View File

@ -1,9 +1,10 @@
import React from 'react'; import React, { useMemo } from 'react';
import ViewStyleControl from '../common/view-style/ViewStyleControl';
import FilterControl from './FilterControl'; import FilterControl from './FilterControl';
import GroupControl from './GroupControl'; import GroupControl from './GroupControl';
import MobileCollectionControls from './mobile/MobileCollectionControls';
import SortControl from './SortControl'; import SortControl from './SortControl';
import ViewStyleControl from '../common/view-style/ViewStyleControl';
import type { ViewStyle } from '@staticcms/core/constants/views'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { import type {
@ -12,7 +13,6 @@ import type {
SortableField, SortableField,
SortDirection, SortDirection,
SortMap, SortMap,
TranslatedProps,
ViewFilter, ViewFilter,
ViewGroup, ViewGroup,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
@ -41,34 +41,69 @@ const CollectionControls = ({
viewGroups, viewGroups,
onFilterClick, onFilterClick,
onGroupClick, onGroupClick,
t,
filter, filter,
group, group,
}: TranslatedProps<CollectionControlsProps>) => { }: CollectionControlsProps) => {
const showGroupControl = useMemo(
() => Boolean(viewGroups && onGroupClick && group && viewGroups.length > 0),
[group, onGroupClick, viewGroups],
);
const showFilterControl = useMemo(
() => Boolean(viewFilters && onFilterClick && filter && viewFilters.length > 0),
[filter, onFilterClick, viewFilters],
);
const showSortControl = useMemo(
() => Boolean(sortableFields && onSortClick && sort && sortableFields.length > 0),
[onSortClick, sort, sortableFields],
);
return ( return (
<div className="flex gap-2 items-center relative z-20"> <>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} /> <div
{viewGroups && onGroupClick && group className="
? viewGroups.length > 0 && ( flex
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} /> items-center
) relative
: null} z-20
{viewFilters && onFilterClick && filter w-full
? viewFilters.length > 0 && ( justify-end
<FilterControl gap-1.5
viewFilters={viewFilters} sm:w-auto
onFilterClick={onFilterClick} sm:justify-normal
t={t} lg:gap-2
filter={filter} flex-[1_0_0%]
/> "
) >
: null} <ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{sortableFields && onSortClick && sort {showGroupControl || showFilterControl || showFilterControl ? (
? sortableFields.length > 0 && ( <MobileCollectionControls
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} /> showFilterControl={showFilterControl}
) viewFilters={viewFilters}
: null} onFilterClick={onFilterClick}
</div> filter={filter}
showGroupControl={showGroupControl}
viewGroups={viewGroups}
onGroupClick={onGroupClick}
group={group}
showSortControl={showSortControl}
fields={sortableFields}
sort={sort}
onSortClick={onSortClick}
/>
) : null}
{showGroupControl ? (
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} group={group} />
) : null}
{showFilterControl ? (
<FilterControl viewFilters={viewFilters} onFilterClick={onFilterClick} filter={filter} />
) : null}
{showSortControl ? (
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
) : null}
</div>
</>
); );
}; };

View File

@ -11,26 +11,25 @@ import {
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate'; import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate';
import Button from '../common/button/Button'; import Button from '../common/button/Button';
import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl';
import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react';
interface CollectionHeaderProps { interface CollectionHeaderProps {
collection: Collection; collection: Collection;
newEntryUrl?: string;
} }
const CollectionHeader = ({ const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collection, t }) => {
collection,
newEntryUrl,
t,
}: TranslatedProps<CollectionHeaderProps>) => {
const collectionLabel = collection.label; const collectionLabel = collection.label;
const collectionLabelSingular = collection.label_singular; const collectionLabelSingular = collection.label_singular;
const icon = useIcon(collection.icon);
const params = useParams(); const params = useParams();
const filterTerm = useMemo(() => params['*'], [params]); const filterTerm = useMemo(() => params['*'], [params]);
const newEntryUrl = useNewEntryUrl(collection, filterTerm);
const icon = useIcon(collection.icon);
const entries = useEntries(collection); const entries = useEntries(collection);
const pluralLabel = useMemo(() => { const pluralLabel = useMemo(() => {
@ -65,7 +64,18 @@ const CollectionHeader = ({
return ( return (
<> <>
<div className="flex flex-grow gap-4"> <div
className="
flex
flex-grow
gap-4
justify-normal
xs:justify-between
sm:justify-normal
w-full
truncate
"
>
<h2 <h2
className=" className="
text-xl text-xl
@ -75,13 +85,25 @@ const CollectionHeader = ({
text-gray-800 text-gray-800
dark:text-gray-300 dark:text-gray-300
gap-2 gap-2
flex-grow
w-full
md:grow-0
md:w-auto
" "
> >
<div className="flex items-center">{icon}</div> <div className="flex items-center">{icon}</div>
{pluralLabel} <div
className="
w-collection-header
flex-grow
truncate
"
>
{pluralLabel}
</div>
</h2> </h2>
{newEntryUrl ? ( {newEntryUrl ? (
<Button to={newEntryUrl}> <Button to={newEntryUrl} className="hidden md:flex">
{t('collection.collectionTop.newButton', { {t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || pluralLabel, collectionLabel: collectionLabelSingular || pluralLabel,
})} })}
@ -92,4 +114,4 @@ const CollectionHeader = ({
); );
}; };
export default translate()(CollectionHeader); export default translate()(CollectionHeader) as FC<CollectionHeaderProps>;

View File

@ -42,7 +42,14 @@ const SingleCollectionPage: FC<SingleCollectionPageProps> = ({
const breadcrumbs = useBreadcrumbs(collection, filterTerm); const breadcrumbs = useBreadcrumbs(collection, filterTerm);
return ( return (
<MainView breadcrumbs={breadcrumbs} showQuickCreate showLeftNav noScroll noMargin> <MainView
breadcrumbs={breadcrumbs}
collection={collection}
showQuickCreate
showLeftNav
noScroll
noMargin
>
<CollectionView <CollectionView
name={name} name={name}
searchTerm={searchTerm} searchTerm={searchTerm}

View File

@ -136,6 +136,10 @@ const CollectionSearch = ({
[submitSearch], [submitSearch],
); );
const handleClick = useCallback((event: MouseEvent) => {
event.stopPropagation();
}, []);
return ( return (
<div> <div>
<div className="relative"> <div className="relative">
@ -171,6 +175,7 @@ const CollectionSearch = ({
onFocus={handleFocus} onFocus={handleFocus}
value={query} value={query}
onChange={handleQueryChange} onChange={handleQueryChange}
onClick={handleClick}
/> />
</div> </div>
<PopperUnstyled <PopperUnstyled
@ -191,7 +196,7 @@ const CollectionSearch = ({
ring-opacity-5 ring-opacity-5
focus:outline-none focus:outline-none
sm:text-sm sm:text-sm
z-40 z-[1300]
dark:bg-slate-700 dark:bg-slate-700
dark:shadow-lg dark:shadow-lg
" "

View File

@ -9,13 +9,11 @@ import {
sortByField as sortByFieldAction, sortByField as sortByFieldAction,
} from '@staticcms/core/actions/entries'; } from '@staticcms/core/actions/entries';
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants'; import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
import { import {
selectSortableFields, selectSortableFields,
selectViewFilters, selectViewFilters,
selectViewGroups, selectViewGroups,
} from '@staticcms/core/lib/util/collection.util'; } from '@staticcms/core/lib/util/collection.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { import {
selectEntriesFilter, selectEntriesFilter,
selectEntriesGroup, selectEntriesGroup,
@ -42,7 +40,6 @@ import type { ConnectedProps } from 'react-redux';
const CollectionView = ({ const CollectionView = ({
collection, collection,
collections, collections,
collectionName,
isSearchResults, isSearchResults,
isSingleSearchResult, isSingleSearchResult,
searchTerm, searchTerm,
@ -67,16 +64,6 @@ const CollectionView = ({
setPrevCollection(collection); setPrevCollection(collection);
}, [collection]); }, [collection]);
const newEntryUrl = useMemo(() => {
if (!collectionName || !collection) {
return undefined;
}
return 'fields' in collection && collection.create
? `${getNewEntryUrl(collectionName)}${isNotEmpty(filterTerm) ? `/${filterTerm}` : ''}`
: '';
}, [collection, collectionName, filterTerm]);
const searchResultKey = useMemo( const searchResultKey = useMemo(
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`, () => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
[isSingleSearchResult], [isSingleSearchResult],
@ -196,18 +183,18 @@ const CollectionView = ({
const collectionDescription = collection?.description; const collectionDescription = collection?.description;
return ( return (
<div className="flex flex-col h-full px-5 pt-4"> <div className="flex flex-col h-full px-5 pt-4 overflow-hidden">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4 flex-row gap-4 sm:gap-0">
{isSearchResults ? ( {isSearchResults ? (
<> <>
<div className="flex-grow"> <div className="flex-grow">
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div> <div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
</div> </div>
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} /> <CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} />
</> </>
) : ( ) : (
<> <>
<CollectionHeader collection={collection} newEntryUrl={newEntryUrl} /> {collection ? <CollectionHeader collection={collection} /> : null}
<CollectionControls <CollectionControls
viewStyle={viewStyle} viewStyle={viewStyle}
onChangeViewStyle={changeViewStyle} onChangeViewStyle={changeViewStyle}
@ -216,7 +203,6 @@ const CollectionView = ({
sort={sort} sort={sort}
viewFilters={viewFilters ?? []} viewFilters={viewFilters ?? []}
viewGroups={viewGroups ?? []} viewGroups={viewGroups ?? []}
t={t}
onFilterClick={onFilterClick} onFilterClick={onFilterClick}
onGroupClick={onGroupClick} onGroupClick={onGroupClick}
filter={filter} filter={filter}
@ -270,7 +256,6 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionV
filterTerm, filterTerm,
collection, collection,
collections, collections,
collectionName: name,
sort, sort,
sortableFields, sortableFields,
viewFilters, viewFilters,

View File

@ -6,19 +6,21 @@ import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; import MenuItemButton from '../common/menu/MenuItemButton';
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface'; import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
import type { MouseEvent } from 'react'; import type { FC, MouseEvent } from 'react';
interface FilterControlProps { export interface FilterControlProps {
filter: Record<string, FilterMap>; filter: Record<string, FilterMap> | undefined;
viewFilters: ViewFilter[]; viewFilters: ViewFilter[] | undefined;
onFilterClick: (viewFilter: ViewFilter) => void; variant?: 'menu' | 'list';
onFilterClick: ((viewFilter: ViewFilter) => void) | undefined;
} }
const FilterControl = ({ const FilterControl = ({
viewFilters, filter = {},
t, viewFilters = [],
variant = 'menu',
onFilterClick, onFilterClick,
filter, t,
}: TranslatedProps<FilterControlProps>) => { }: TranslatedProps<FilterControlProps>) => {
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]); const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
@ -26,15 +28,65 @@ const FilterControl = ({
(viewFilter: ViewFilter) => (event: MouseEvent) => { (viewFilter: ViewFilter) => (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
onFilterClick(viewFilter); onFilterClick?.(viewFilter);
}, },
[onFilterClick], [onFilterClick],
); );
if (variant === 'list') {
return (
<div key="filter-by-list" className="flex flex-col gap-2">
<h3
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.filterBy')}
</h3>
{viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`;
return (
<div
key={viewFilter.id}
className="
ml-1.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
onClick={handleFilterClick(viewFilter)}
>
<input
key={`${labelId}-${checked}`}
id={labelId}
type="checkbox"
value=""
className=""
checked={checked}
readOnly
/>
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300">
{viewFilter.label}
</label>
</div>
);
})}
</div>
);
}
return ( return (
<Menu <Menu
key="filter-by-menu"
label={t('collection.collectionTop.filterBy')} label={t('collection.collectionTop.filterBy')}
variant={anyActive ? 'contained' : 'outlined'} variant={anyActive ? 'contained' : 'outlined'}
rootClassName="hidden lg:block"
> >
<MenuGroup> <MenuGroup>
{viewFilters.map(viewFilter => { {viewFilters.map(viewFilter => {
@ -62,4 +114,4 @@ const FilterControl = ({
); );
}; };
export default translate()(FilterControl); export default translate()(FilterControl) as FC<FilterControlProps>;

View File

@ -1,5 +1,5 @@
import { Check as CheckIcon } from '@styled-icons/material/Check'; import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import Menu from '../common/menu/Menu'; import Menu from '../common/menu/Menu';
@ -7,31 +7,87 @@ import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; import MenuItemButton from '../common/menu/MenuItemButton';
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface'; import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
interface GroupControlProps { export interface GroupControlProps {
group: Record<string, GroupMap>; group: Record<string, GroupMap> | undefined;
viewGroups: ViewGroup[]; viewGroups: ViewGroup[] | undefined;
onGroupClick: (viewGroup: ViewGroup) => void; variant?: 'menu' | 'list';
onGroupClick: ((viewGroup: ViewGroup) => void) | undefined;
} }
const GroupControl = ({ const GroupControl = ({
viewGroups, viewGroups = [],
group, group = {},
t, variant = 'menu',
onGroupClick, onGroupClick,
t,
}: TranslatedProps<GroupControlProps>) => { }: TranslatedProps<GroupControlProps>) => {
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]); const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
const handleGroupClick = useCallback(
(viewGroup: ViewGroup) => (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onGroupClick?.(viewGroup);
},
[onGroupClick],
);
if (variant === 'list') {
return (
<div key="filter-by-list" className="flex flex-col gap-2">
<h3
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.groupBy')}
</h3>
{viewGroups.map(viewGroup => {
const active = Boolean(viewGroup.id && group[viewGroup?.id]?.active) ?? false;
return (
<div
key={viewGroup.id}
className="
ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
onClick={handleGroupClick(viewGroup)}
>
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300">
{viewGroup.label}
</label>
{active ? (
<CheckIcon key="checkmark" className="ml-2 w-6 h-6 text-blue-500" />
) : (
<div key="not-checked" className="ml-2 w-6 h-6" />
)}
</div>
);
})}
</div>
);
}
return ( return (
<Menu <Menu
label={t('collection.collectionTop.groupBy')} label={t('collection.collectionTop.groupBy')}
variant={activeGroup ? 'contained' : 'outlined'} variant={activeGroup ? 'contained' : 'outlined'}
rootClassName="hidden lg:block"
> >
<MenuGroup> <MenuGroup>
{viewGroups.map(viewGroup => ( {viewGroups.map(viewGroup => (
<MenuItemButton <MenuItemButton
key={viewGroup.id} key={viewGroup.id}
onClick={() => onGroupClick(viewGroup)} onClick={() => onGroupClick?.(viewGroup)}
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined} endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
> >
{viewGroup.label} {viewGroup.label}
@ -42,4 +98,4 @@ const GroupControl = ({
); );
}; };
export default translate()(GroupControl); export default translate()(GroupControl) as FC<GroupControlProps>;

View File

@ -11,6 +11,7 @@ import NavLink from '../navbar/NavLink';
import type { Collection, Entry } from '@staticcms/core/interface'; import type { Collection, Entry } from '@staticcms/core/interface';
import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util'; import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util';
import type { MouseEvent } from 'react';
function getNodeTitle(node: TreeNodeData) { function getNodeTitle(node: TreeNodeData) {
const title = node.isRoot const title = node.isRoot
@ -29,6 +30,20 @@ interface TreeNodeProps {
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => { const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
const collectionName = collection.name; const collectionName = collection.name;
const handleClick = useCallback(
(event: MouseEvent | undefined, node: TreeNodeData, expanded: boolean) => {
event?.stopPropagation();
event?.preventDefault();
if (event) {
onToggle({ node, expanded });
} else {
onToggle({ node, expanded: true });
}
},
[onToggle],
);
const sortedData = sortBy(treeData, getNodeTitle); const sortedData = sortBy(treeData, getNodeTitle);
return ( return (
<> <>
@ -50,7 +65,7 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
<div className={classNames(depth !== 0 && 'ml-8')}> <div className={classNames(depth !== 0 && 'ml-8')}>
<NavLink <NavLink
to={to} to={to}
onClick={() => onToggle({ node, expanded: !node.expanded })} onClick={() => handleClick(undefined, node, !node.expanded)}
data-testid={node.path} data-testid={node.path}
icon={<ArticleIcon className={classNames(depth === 0 ? 'h-6 w-6' : 'h-5 w-5')} />} icon={<ArticleIcon className={classNames(depth === 0 ? 'h-6 w-6' : 'h-5 w-5')} />}
> >
@ -58,6 +73,7 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
<div>{title}</div> <div>{title}</div>
{hasChildren && ( {hasChildren && (
<ChevronRightIcon <ChevronRightIcon
onClick={event => handleClick(event, node, !node.expanded)}
className={classNames( className={classNames(
node.expanded && 'rotate-90 transform', node.expanded && 'rotate-90 transform',
` `
@ -135,7 +151,6 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const entries = useEntries(collection); const entries = useEntries(collection);
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries)); const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
const [selected, setSelected] = useState<TreeNodeData | null>(null);
const [useFilter, setUseFilter] = useState(true); const [useFilter, setUseFilter] = useState(true);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null); const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
@ -186,22 +201,15 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const onToggle = useCallback( const onToggle = useCallback(
({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => { ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => {
if (!selected || selected.path === node.path || expanded) { setTreeData(
setTreeData( updateNode(treeData, node, node => ({
updateNode(treeData, node, node => ({ ...node,
...node, expanded,
expanded, })),
})), );
); setUseFilter(false);
setSelected(node);
setUseFilter(false);
} else {
// don't collapse non selected nodes when clicked
setSelected(node);
setUseFilter(false);
}
}, },
[selected, treeData], [treeData],
); );
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />; return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;

View File

@ -1,6 +1,6 @@
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown'; import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import { KeyboardArrowUp as KeyboardArrowUpIcon } from '@styled-icons/material/KeyboardArrowUp'; import { KeyboardArrowUp as KeyboardArrowUpIcon } from '@styled-icons/material/KeyboardArrowUp';
import React, { useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { import {
@ -18,6 +18,7 @@ import type {
SortMap, SortMap,
TranslatedProps, TranslatedProps,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
function nextSortDirection(direction: SortDirection) { function nextSortDirection(direction: SortDirection) {
switch (direction) { switch (direction) {
@ -30,13 +31,20 @@ function nextSortDirection(direction: SortDirection) {
} }
} }
interface SortControlProps { export interface SortControlProps {
fields: SortableField[]; fields: SortableField[] | undefined;
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
sort: SortMap | undefined; sort: SortMap | undefined;
variant?: 'menu' | 'list';
onSortClick: ((key: string, direction?: SortDirection) => Promise<void>) | undefined;
} }
const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortControlProps>) => { const SortControl = ({
fields = [],
sort = {},
variant = 'menu',
onSortClick,
t,
}: TranslatedProps<SortControlProps>) => {
const selectedSort = useMemo(() => { const selectedSort = useMemo(() => {
if (!sort) { if (!sort) {
return { key: undefined, direction: undefined }; return { key: undefined, direction: undefined };
@ -50,10 +58,68 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
return sortValues[0]; return sortValues[0];
}, [sort]); }, [sort]);
const handleSortClick = useCallback(
(key: string, direction?: SortDirection) => (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onSortClick?.(key, direction);
},
[onSortClick],
);
if (variant === 'list') {
return (
<div key="filter-by-list" className="flex flex-col gap-2">
<h3
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.sortBy')}
</h3>
{fields.map(field => {
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
const nextSortDir = nextSortDirection(sortDir);
return (
<div
key={field.name}
className="
ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
onClick={handleSortClick(field.name, nextSortDir)}
>
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300">
{field.label ?? field.name}
</label>
{field.name === selectedSort.key ? (
selectedSort.direction === SORT_DIRECTION_ASCENDING ? (
<KeyboardArrowUpIcon key="checkmark" className="ml-2 w-6 h-6 text-blue-500" />
) : (
<KeyboardArrowDownIcon key="checkmark" className="ml-2 w-6 h-6 text-blue-500" />
)
) : (
<div key="not-checked" className="ml-2 w-6 h-6" />
)}
</div>
);
})}
</div>
);
}
return ( return (
<Menu <Menu
label={t('collection.collectionTop.sortBy')} label={t('collection.collectionTop.sortBy')}
variant={selectedSort.key ? 'contained' : 'outlined'} variant={selectedSort.key ? 'contained' : 'outlined'}
rootClassName="hidden lg:block"
> >
<MenuGroup> <MenuGroup>
{fields.map(field => { {fields.map(field => {
@ -62,7 +128,7 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
return ( return (
<MenuItemButton <MenuItemButton
key={field.name} key={field.name}
onClick={() => onSortClick(field.name, nextSortDir)} onClick={handleSortClick(field.name, nextSortDir)}
active={field.name === selectedSort.key} active={field.name === selectedSort.key}
endIcon={ endIcon={
field.name === selectedSort.key field.name === selectedSort.key
@ -81,4 +147,4 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
); );
}; };
export default translate()(SortControl); export default translate()(SortControl) as FC<SortControlProps>;

View File

@ -81,7 +81,20 @@ const Entries = ({
); );
} }
return <div>{t('collection.entries.noEntries')}</div>; return (
<div
className="
py-2
px-3
rounded-md
bg-yellow-300/75
dark:bg-yellow-800/75
text-sm
"
>
{t('collection.entries.noEntries')}
</div>
);
}; };
export default translate()(Entries); export default translate()(Entries);

View File

@ -6,10 +6,10 @@ import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/e
import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useGroups from '@staticcms/core/lib/hooks/useGroups'; import useGroups from '@staticcms/core/lib/hooks/useGroups';
import { Cursor } from '@staticcms/core/lib/util'; import { Cursor } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors'; import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries'; import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { useAppDispatch } from '@staticcms/core/store/hooks';
import Button from '../../common/button/Button';
import Entries from './Entries'; import Entries from './Entries';
import type { ViewStyle } from '@staticcms/core/constants/views'; import type { ViewStyle } from '@staticcms/core/constants/views';
@ -104,39 +104,64 @@ const EntriesCollection = ({
[collection, dispatch], [collection, dispatch],
); );
const [selectedGroup, setSelectedGroup] = useState(0);
const handleGroupClick = useCallback(
(index: number) => () => {
setSelectedGroup(index);
},
[],
);
if (groups && groups.length > 0) { if (groups && groups.length > 0) {
return ( return (
<> <>
{groups.map((group, index) => { <div
const title = getGroupTitle(group, t); className="
return ( pb-3
<div key={group.id} id={group.id}> "
<h2 >
className={classNames( <div
` className="
px-2 -m-1
pt-4 "
pb-2 >
`, <div
index === 0 && 'pt-0', className="
)} flex
> gap-2
{title} p-1
</h2> overflow-x-auto
<Entries hide-scrollbar
collection={collection} "
entries={getGroupEntries(filteredEntries, group.paths)} >
isFetching={isFetching} {groups.map((group, index) => {
collectionName={collection.label} const title = getGroupTitle(group, t);
viewStyle={viewStyle} return (
cursor={cursor} <Button
handleCursorActions={handleCursorActions} key={index}
page={page} variant={index === selectedGroup ? 'contained' : 'text'}
filterTerm={filterTerm} onClick={handleGroupClick(index)}
/> className="whitespace-nowrap"
>
{title}
</Button>
);
})}
</div> </div>
); </div>
})} </div>
<Entries
key={`entries-with-group-${groups[selectedGroup].id}`}
collection={collection}
entries={getGroupEntries(filteredEntries, groups[selectedGroup].paths)}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
filterTerm={filterTerm}
/>
</> </>
); );
} }

View File

@ -113,52 +113,64 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
if (PreviewCardComponent) { if (PreviewCardComponent) {
return ( return (
<Card> <div className="h-full w-full relative overflow-visible">
<CardActionArea to={path}> <div className="absolute -inset-1 pr-2">
<PreviewCardComponent <div className="p-1 h-full w-full">
collection={collection} <Card>
fields={fields} <CardActionArea to={path}>
entry={entry} <PreviewCardComponent
widgetFor={widgetFor} collection={collection}
widgetsFor={widgetsFor} fields={fields}
theme={theme} entry={entry}
hasLocalBackup={hasLocalBackup} widgetFor={widgetFor}
/> widgetsFor={widgetsFor}
</CardActionArea> theme={theme}
</Card> hasLocalBackup={hasLocalBackup}
/>
</CardActionArea>
</Card>
</div>
</div>
</div>
); );
} }
return ( return (
<Card className="h-full" title={summary}> <div className="h-full w-full relative overflow-visible">
<CardActionArea to={path}> <div className="absolute -inset-1 pr-2">
{image && imageField ? ( <div className="p-1 h-full w-full">
<CardMedia <Card className="h-full" title={summary}>
height="140" <CardActionArea to={path}>
image={image} {image && imageField ? (
collection={collection} <CardMedia
field={imageField} height="140"
entry={entry} image={image}
/> collection={collection}
) : null} field={imageField}
<CardContent> entry={entry}
<div className="flex w-full items-center justify-between"> />
<div className="whitespace-nowrap overflow-hidden text-ellipsis">{summary}</div> ) : null}
{hasLocalBackup ? ( <CardContent>
<InfoIcon <div className="flex w-full items-center justify-between">
className=" <div className="truncate">{summary}</div>
w-5 {hasLocalBackup ? (
h-5 <InfoIcon
text-blue-600 className="
dark:text-blue-300 w-5
" h-5
title={t('ui.localBackup.hasLocalBackup')} text-blue-600
/> dark:text-blue-300
) : null} "
</div> title={t('ui.localBackup.hasLocalBackup')}
</CardContent> />
</CardActionArea> ) : null}
</Card> </div>
</CardContent>
</CardActionArea>
</Card>
</div>
</div>
</div>
); );
}; };

View File

@ -47,7 +47,7 @@ const CardWrapper = ({
? style.left ?? COLLECTION_CARD_MARGIN * columnIndex ? style.left ?? COLLECTION_CARD_MARGIN * columnIndex
: style.left : style.left
}`, }`,
), ) + 4,
[columnIndex, style.left], [columnIndex, style.left],
); );
@ -138,12 +138,22 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
}, [cardHeights, prevCardHeights.length]); }, [cardHeights, prevCardHeights.length]);
return ( return (
<div className="relative w-full h-full"> <div
className="
relative
w-card-grid
h-full
overflow-hidden
-ml-1
"
>
<AutoSizer onResize={handleResize}> <AutoSizer onResize={handleResize}>
{({ height = 0, width = 0 }) => { {({ height = 0, width = 0 }) => {
const calculatedWidth = width - 4;
const columnWidthWithGutter = COLLECTION_CARD_WIDTH + COLLECTION_CARD_MARGIN; const columnWidthWithGutter = COLLECTION_CARD_WIDTH + COLLECTION_CARD_MARGIN;
const columnCount = Math.floor(width / columnWidthWithGutter); const columnCount = Math.max(Math.floor(calculatedWidth / columnWidthWithGutter), 1);
const nonGutterSpace = (width - COLLECTION_CARD_MARGIN * columnCount) / width; const nonGutterSpace =
(calculatedWidth - COLLECTION_CARD_MARGIN * columnCount) / calculatedWidth;
const columnWidth = (1 / columnCount) * nonGutterSpace; const columnWidth = (1 / columnCount) * nonGutterSpace;
const rowCount = Math.ceil(entryData.length / columnCount); const rowCount = Math.ceil(entryData.length / columnCount);
@ -157,7 +167,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
`, `,
)} )}
style={{ style={{
width, width: calculatedWidth,
height, height,
}} }}
> >
@ -165,8 +175,8 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
columnCount={columnCount} columnCount={columnCount}
columnWidth={index => columnWidth={index =>
index + 1 === columnCount index + 1 === columnCount
? width * columnWidth ? calculatedWidth * columnWidth
: width * columnWidth + COLLECTION_CARD_MARGIN : calculatedWidth * columnWidth + COLLECTION_CARD_MARGIN
} }
rowCount={rowCount} rowCount={rowCount}
rowHeight={index => { rowHeight={index => {
@ -189,7 +199,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
return rowHeight; return rowHeight;
}} }}
width={width} width={calculatedWidth}
height={height} height={height}
itemData={ itemData={
{ {
@ -203,7 +213,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
onScroll={onScroll} onScroll={onScroll}
className={classNames( className={classNames(
` `
overflow-hidden !overflow-x-hidden
overflow-y-auto overflow-y-auto
styled-scrollbars styled-scrollbars
`, `,

View File

@ -57,8 +57,8 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
}, [handleScroll]); }, [handleScroll]);
return ( return (
<div className="relative h-full overflow-hidden"> <div className="relative h-full flex-grow">
<div ref={gridContainerRef} className="relative h-full overflow-hidden"> <div ref={gridContainerRef} className="relative h-full">
<EntryListingCardGrid <EntryListingCardGrid
key="grid" key="grid"
entryData={entryData} entryData={entryData}
@ -71,14 +71,14 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
<div <div
key="loading" key="loading"
className=" className="
absolute absolute
inset-0 inset-0
flex flex
items-center items-center
justify-center justify-center
bg-slate-50/50 bg-slate-50/50
dark:bg-slate-900/50 dark:bg-slate-900/50
" "
> >
{t('collection.entries.loadingEntries')} {t('collection.entries.loadingEntries')}
</div> </div>

View File

@ -80,6 +80,7 @@ const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
hover:bg-gray-200 hover:bg-gray-200
dark:hover:bg-slate-700/70 dark:hover:bg-slate-700/70
" "
to={path}
> >
{collectionLabel ? ( {collectionLabel ? (
<TableCell key="collectionLabel" to={path}> <TableCell key="collectionLabel" to={path}>

View File

@ -0,0 +1,40 @@
import { FilterList as FilterListIcon } from '@styled-icons/material/FilterList';
import React, { useCallback, useState } from 'react';
import IconButton from '../../common/button/IconButton';
import MobileCollectionControlsDrawer from './MobileCollectionControlsDrawer';
import type { FC } from 'react';
import type { FilterControlProps } from '../FilterControl';
import type { GroupControlProps } from '../GroupControl';
import type { SortControlProps } from '../SortControl';
export type MobileCollectionControlsProps = Omit<FilterControlProps, 'variant'> &
Omit<GroupControlProps, 'variant'> &
Omit<SortControlProps, 'variant'> & {
showGroupControl: boolean;
showFilterControl: boolean;
showSortControl: boolean;
};
const MobileCollectionControls: FC<MobileCollectionControlsProps> = props => {
const [mobileOpen, setMobileOpen] = useState(false);
const toggleMobileMenu = useCallback(() => {
setMobileOpen(old => !old);
}, []);
return (
<>
<IconButton className="flex lg:hidden" variant="text" onClick={toggleMobileMenu}>
<FilterListIcon className="w-5 h-5" />
</IconButton>
<MobileCollectionControlsDrawer
{...props}
mobileOpen={mobileOpen}
onMobileOpenToggle={toggleMobileMenu}
/>
</>
);
};
export default MobileCollectionControls;

View File

@ -0,0 +1,117 @@
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import React, { useMemo } from 'react';
import FilterControl from '../FilterControl';
import GroupControl from '../GroupControl';
import SortControl from '../SortControl';
import type { FilterControlProps } from '../FilterControl';
import type { GroupControlProps } from '../GroupControl';
import type { SortControlProps } from '../SortControl';
const DRAWER_WIDTH = 240;
export type MobileCollectionControlsDrawerProps = Omit<FilterControlProps, 'variant'> &
Omit<GroupControlProps, 'variant'> &
Omit<SortControlProps, 'variant'> & {
mobileOpen: boolean;
onMobileOpenToggle: () => void;
} & {
showGroupControl: boolean;
showFilterControl: boolean;
showSortControl: boolean;
};
const MobileCollectionControlsDrawer = ({
mobileOpen,
onMobileOpenToggle,
showFilterControl,
filter,
viewFilters,
onFilterClick,
showGroupControl,
group,
viewGroups,
onGroupClick,
showSortControl,
sort,
fields,
onSortClick,
}: MobileCollectionControlsDrawerProps) => {
const iOS = useMemo(
() => typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent),
[],
);
const container = useMemo(
() => (typeof window !== 'undefined' ? window.document.body : undefined),
[],
);
return (
<SwipeableDrawer
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
container={container}
variant="temporary"
open={mobileOpen}
onOpen={onMobileOpenToggle}
onClose={onMobileOpenToggle}
anchor="right"
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
width: '80%',
maxWidth: DRAWER_WIDTH,
'& .MuiBackdrop-root': {
width: '100%',
},
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: '80%',
maxWidth: DRAWER_WIDTH,
},
}}
>
<div
onClick={onMobileOpenToggle}
className="
px-5
py-4
flex
flex-col
gap-6
h-full
w-full
overflow-y-auto
bg-white
dark:bg-slate-800
styled-scrollbars
"
>
{showSortControl ? (
<SortControl fields={fields} sort={sort} onSortClick={onSortClick} variant="list" />
) : null}
{showFilterControl ? (
<FilterControl
viewFilters={viewFilters}
onFilterClick={onFilterClick}
filter={filter}
variant="list"
/>
) : null}
{showGroupControl ? (
<GroupControl
viewGroups={viewGroups}
onGroupClick={onGroupClick}
group={group}
variant="list"
/>
) : null}
</div>
</SwipeableDrawer>
);
};
export default MobileCollectionControlsDrawer;

View File

@ -63,7 +63,8 @@ const Autocomplete = function <T>(
<div <div
className=" className="
flex flex
items-center flex-col
flex-start
text-sm text-sm
font-medium font-medium
relative relative
@ -90,8 +91,8 @@ const Autocomplete = function <T>(
leading-5 leading-5
focus:ring-0 focus:ring-0
outline-none outline-none
basis-60
flex-grow flex-grow
truncate
`, `,
disabled disabled
? ` ? `
@ -194,7 +195,9 @@ const Autocomplete = function <T>(
> >
<span <span
className={classNames( className={classNames(
'block truncate', `
block
`,
selected ? 'font-medium' : 'font-normal', selected ? 'font-medium' : 'font-normal',
)} )}
> >

View File

@ -4,13 +4,13 @@ import Button from './Button';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import type { FC } from 'react'; import type { FC } from 'react';
import type { ButtonProps } from './Button'; import type { ButtonLinkProps } from './Button';
export type IconButtonProps = Omit<ButtonProps, 'children'> & { export type IconButtonProps = Omit<ButtonLinkProps, 'children'> & {
children: FC<{ className?: string }>; children: FC<{ className?: string }>;
}; };
const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonProps) => { const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonLinkProps) => {
return ( return (
<Button <Button
className={classNames(size === 'small' && 'px-0.5', size === 'medium' && 'px-1.5', className)} className={classNames(size === 'small' && 'px-0.5', size === 'medium' && 'px-1.5', className)}

View File

@ -22,6 +22,10 @@ const CardActionArea = ({ to, children }: CardActionAreaProps) => {
justify-start justify-start
hover:bg-gray-200 hover:bg-gray-200
dark:hover:bg-slate-700/70 dark:hover:bg-slate-700/70
focus:outline-none
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
" "
> >
{children} {children}

View File

@ -52,7 +52,7 @@ const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) =>
ref={inputRef} ref={inputRef}
type="checkbox" type="checkbox"
checked={checked} checked={checked}
className="sr-only peer" className="sr-only peer hide-tap"
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
onClick={handleNoop} onClick={handleNoop}

View File

@ -23,6 +23,8 @@ export interface FieldProps {
disabled: boolean; disabled: boolean;
disableClick?: boolean; disableClick?: boolean;
endAdornment?: ReactNode; endAdornment?: ReactNode;
rootClassName?: string;
wrapperClassName?: string;
} }
const Field: FC<FieldProps> = ({ const Field: FC<FieldProps> = ({
@ -39,6 +41,8 @@ const Field: FC<FieldProps> = ({
disabled, disabled,
disableClick = false, disableClick = false,
endAdornment, endAdornment,
rootClassName,
wrapperClassName,
}) => { }) => {
const finalCursor = useCursor(cursor, disabled); const finalCursor = useCursor(cursor, disabled);
@ -104,6 +108,7 @@ const Field: FC<FieldProps> = ({
focus-within:border-blue-800 focus-within:border-blue-800
dark:focus-within:border-blue-100 dark:focus-within:border-blue-100
`, `,
rootClassName,
!noHightlight && !noHightlight &&
!disabled && !disabled &&
` `
@ -118,7 +123,7 @@ const Field: FC<FieldProps> = ({
finalCursor === 'default' && 'cursor-default', finalCursor === 'default' && 'cursor-default',
!hasErrors && 'group/active', !hasErrors && 'group/active',
), ),
[finalCursor, disabled, hasErrors, noHightlight, noPadding], [rootClassName, noHightlight, disabled, noPadding, finalCursor, hasErrors],
); );
const wrapperClassNames = useMemo( const wrapperClassNames = useMemo(
@ -129,9 +134,10 @@ const Field: FC<FieldProps> = ({
flex-col flex-col
w-full w-full
`, `,
wrapperClassName,
forSingleList && 'mr-14', forSingleList && 'mr-14',
), ),
[forSingleList], [forSingleList, wrapperClassName],
); );
if (variant === 'inline') { if (variant === 'inline') {

View File

@ -16,9 +16,14 @@ export interface MenuProps {
color?: BaseBaseProps['color']; color?: BaseBaseProps['color'];
size?: BaseBaseProps['size']; size?: BaseBaseProps['size'];
rounded?: boolean | 'no-padding'; rounded?: boolean | 'no-padding';
className?: string; rootClassName?: string;
iconClassName?: string;
buttonClassName?: string;
labelClassName?: string;
children: ReactNode | ReactNode[]; children: ReactNode | ReactNode[];
hideDropdownIcon?: boolean; hideDropdownIcon?: boolean;
hideDropdownIconOnMobile?: boolean;
hideLabel?: boolean;
keepMounted?: boolean; keepMounted?: boolean;
disabled?: boolean; disabled?: boolean;
'data-testid'?: string; 'data-testid'?: string;
@ -31,9 +36,14 @@ const Menu = ({
color = 'primary', color = 'primary',
size = 'medium', size = 'medium',
rounded = false, rounded = false,
className, rootClassName,
iconClassName,
buttonClassName,
labelClassName,
children, children,
hideDropdownIcon = false, hideDropdownIcon = false,
hideDropdownIconOnMobile = false,
hideLabel = false,
keepMounted = false, keepMounted = false,
disabled = false, disabled = false,
'data-testid': dataTestId, 'data-testid': dataTestId,
@ -56,16 +66,16 @@ const Menu = ({
setAnchorEl(null); setAnchorEl(null);
}, []); }, []);
const buttonClassName = useButtonClassNames(variant, color, size, rounded); const calculatedButtonClassName = useButtonClassNames(variant, color, size, rounded);
const menuButtonClassNames = useMemo( const menuButtonClassNames = useMemo(
() => classNames(className, buttonClassName), () => classNames(calculatedButtonClassName, buttonClassName, 'whitespace-nowrap'),
[buttonClassName, className], [calculatedButtonClassName, buttonClassName],
); );
return ( return (
<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart" onClickAway={handleClose}> <ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart" onClickAway={handleClose}>
<div className="flex"> <div className={classNames('flex', rootClassName)}>
<button <button
type="button" type="button"
onClick={handleButtonClick} onClick={handleButtonClick}
@ -76,10 +86,26 @@ const Menu = ({
className={menuButtonClassNames} className={menuButtonClassNames}
disabled={disabled} disabled={disabled}
> >
{StartIcon ? <StartIcon className="-ml-0.5 mr-1.5 h-5 w-5" /> : null} {StartIcon ? (
{label} <StartIcon
className={classNames(
`-ml-0.5 h-5 w-5`,
!hideLabel && !hideDropdownIcon && 'mr-1.5',
hideDropdownIconOnMobile && '!mr-0 md:!mr-1.5',
iconClassName,
)}
/>
) : null}
{!hideLabel ? <div className={labelClassName}>{label}</div> : null}
{!hideDropdownIcon ? ( {!hideDropdownIcon ? (
<KeyboardArrowDownIcon className="-mr-0.5 ml-2 h-5 w-5" aria-hidden="true" /> <KeyboardArrowDownIcon
className={classNames(
`-mr-0.5 h-5 w-5`,
!hideLabel && 'ml-2',
hideDropdownIconOnMobile && '!hidden md:!block',
)}
aria-hidden="true"
/>
) : null} ) : null}
</button> </button>
<MenuUnstyled <MenuUnstyled

View File

@ -61,6 +61,7 @@ const Pill: FC<PillProps> = ({
px-3 px-3
py-1 py-1
rounded-lg rounded-lg
truncate
`, `,
noWrap && 'whitespace-nowrap', noWrap && 'whitespace-nowrap',
colorClassNames, colorClassNames,

View File

@ -59,7 +59,6 @@ const Select = forwardRef(
}, },
[onOpenChange], [onOpenChange],
); );
const handleButtonClick = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
const handleChange = useCallback( const handleChange = useCallback(
(_event: MouseEvent | KeyboardEvent | FocusEvent | null, selectedValue: number | string) => { (_event: MouseEvent | KeyboardEvent | FocusEvent | null, selectedValue: number | string) => {
@ -89,8 +88,10 @@ const Select = forwardRef(
<SelectUnstyled<any> <SelectUnstyled<any>
renderValue={() => { renderValue={() => {
return ( return (
<> <div className="w-full">
{label ?? placeholder} <div className="flex flex-start w-select-widget-label">
<span className="truncate">{label ?? placeholder}</span>
</div>
<span <span
className=" className="
pointer-events-none pointer-events-none
@ -118,13 +119,12 @@ const Select = forwardRef(
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
</> </div>
); );
}} }}
slotProps={{ slotProps={{
root: { root: {
ref, ref,
onClick: handleButtonClick,
className: classNames( className: classNames(
` `
flex flex
@ -160,11 +160,11 @@ const Select = forwardRef(
ring-opacity-5 ring-opacity-5
focus:outline-none focus:outline-none
sm:text-sm sm:text-sm
z-50 z-[100]
dark:bg-slate-700 dark:bg-slate-700
dark:shadow-lg dark:shadow-lg
`, `,
style: { width }, style: { width: ref ? width : 'auto' },
disablePortal: false, disablePortal: false,
}, },
}} }}

View File

@ -26,6 +26,7 @@ const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCell
py-3 py-3
whitespace-nowrap whitespace-nowrap
" "
tabIndex={-1}
> >
{children} {children}
</Link> </Link>
@ -50,6 +51,8 @@ const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCell
<div <div
className=" className="
h-[44px] h-[44px]
truncate
w-full
" "
> >
{content} {content}

View File

@ -35,6 +35,8 @@ const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
dark:border-gray-700 dark:border-gray-700
dark:bg-slate-800 dark:bg-slate-800
text-[14px] text-[14px]
truncate
w-full
" "
> >
{typeof children === 'string' && isEmpty(children) ? <>&nbsp;</> : children} {typeof children === 'string' && isEmpty(children) ? <>&nbsp;</> : children}

View File

@ -1,15 +1,31 @@
import React from 'react'; import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import type { ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
interface TableRowProps { interface TableRowProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
to?: string;
} }
const TableRow = ({ children, className }: TableRowProps) => { const TableRow = ({ children, className, to }: TableRowProps) => {
const navigate = useNavigate();
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!to) {
return;
}
if (event.key === 'Enter' || event.key === 'Space') {
navigate(to);
}
},
[navigate, to],
);
return ( return (
<tr <tr
className={classNames( className={classNames(
@ -22,9 +38,14 @@ const TableRow = ({ children, className }: TableRowProps) => {
hover:bg-slate-50 hover:bg-slate-50
dark:bg-slate-800 dark:bg-slate-800
dark:hover:bg-slate-700 dark:hover:bg-slate-700
focus:outline-none
focus:bg-gray-100
focus:dark:bg-slate-700
`, `,
className, className,
)} )}
tabIndex={to ? 0 : -1}
onKeyDown={handleKeyDown}
> >
{children} {children}
</tr> </tr>

View File

@ -19,6 +19,8 @@ export interface BaseTextFieldProps {
placeholder?: string; placeholder?: string;
endAdornment?: ReactNode; endAdornment?: ReactNode;
startAdornment?: ReactNode; startAdornment?: ReactNode;
rootClassName?: string;
inputClassName?: string;
} }
export interface NumberTextFieldProps extends BaseTextFieldProps { export interface NumberTextFieldProps extends BaseTextFieldProps {
@ -49,6 +51,8 @@ const TextField: FC<TextFieldProps> = ({
onClick, onClick,
startAdornment, startAdornment,
endAdornment, endAdornment,
rootClassName,
inputClassName,
...otherProps ...otherProps
}) => { }) => {
const finalCursor = useCursor(cursor, disabled); const finalCursor = useCursor(cursor, disabled);
@ -67,10 +71,13 @@ const TextField: FC<TextFieldProps> = ({
endAdornment={endAdornment} endAdornment={endAdornment}
slotProps={{ slotProps={{
root: { root: {
className: ` className: classNames(
flex `
w-full flex
`, w-full
`,
rootClassName,
),
}, },
input: { input: {
ref: inputRef, ref: inputRef,
@ -79,6 +86,7 @@ const TextField: FC<TextFieldProps> = ({
w-full w-full
text-sm text-sm
`, `,
inputClassName,
variant === 'borderless' && variant === 'borderless' &&
` `
h-6 h-6

View File

@ -15,7 +15,7 @@ interface ViewStyleControlPros {
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => { const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
return ( return (
<div className="flex items-center gap-1.5 mr-1"> <div className="flex items-center gap-1.5 lg:mr-1">
<IconButton <IconButton
variant="text" variant="text"
className={classNames(viewStyle === VIEW_STYLE_TABLE && 'text-blue-500 dark:text-blue-500')} className={classNames(viewStyle === VIEW_STYLE_TABLE && 'text-blue-500 dark:text-blue-500')}

View File

@ -22,6 +22,7 @@ export default function useWidgetsFor(
collection: Collection, collection: Collection,
fields: Field[], fields: Field[],
entry: Entry, entry: Entry,
data: EntryData = entry.data,
): { ): {
widgetFor: WidgetFor; widgetFor: WidgetFor;
widgetsFor: WidgetsFor; widgetsFor: WidgetsFor;
@ -35,9 +36,19 @@ export default function useWidgetsFor(
if (!config) { if (!config) {
return null; return null;
} }
return getWidgetFor(config, collection, name, fields, entry, theme, inferredFields); return getWidgetFor(
config,
collection,
name,
fields,
entry,
theme,
inferredFields,
fields,
data,
);
}, },
[collection, config, entry, fields, inferredFields, theme], [collection, config, data, entry, fields, inferredFields, theme],
); );
/** /**

View File

@ -3,7 +3,7 @@ import { ScrollSyncPane } from 'react-scroll-sync';
import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views'; import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views';
import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs'; import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs';
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n'; import { getI18nInfo, hasI18n } from '@staticcms/core/lib/i18n';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { import {
getFileFromSlug, getFileFromSlug,
@ -169,6 +169,11 @@ const EditorInterface = ({
const collectHasI18n = hasI18n(collection); const collectHasI18n = hasI18n(collection);
const [showMobilePreview, setShowMobilePreview] = useState(false);
const toggleMobilePreview = useCallback(() => {
setShowMobilePreview(old => !old);
}, []);
const editor = ( const editor = (
<div <div
key={defaultLocale} key={defaultLocale}
@ -181,7 +186,13 @@ const EditorInterface = ({
` `
overflow-y-auto overflow-y-auto
styled-scrollbars styled-scrollbars
h-main h-main-mobile
md:h-main
`,
showMobilePreview &&
`
hidden
lg:block
`, `,
)} )}
> >
@ -204,10 +215,12 @@ const EditorInterface = ({
<div <div
key={selectedLocale} key={selectedLocale}
className=" className="
flex
w-full w-full
overflow-y-auto overflow-y-auto
styled-scrollbars styled-scrollbars
h-main h-main-mobile
md:h-main
" "
> >
<EditorControlPane <EditorControlPane
@ -227,9 +240,36 @@ const EditorInterface = ({
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t], [collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
); );
const previewEntry = collectHasI18n const mobileLocaleEditor = useMemo(
? getPreviewEntry(entry, selectedLocale[0], defaultLocale) () => (
: entry; <div
key={selectedLocale}
className="
w-full
overflow-y-auto
styled-scrollbars
h-main-mobile
flex
md:hidden
"
>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={selectedLocale}
onLocaleChange={handleLocaleChange}
allowDefaultLocale
submitted={submitted}
canChangeLocale
hideBorder
t={t}
/>
</div>
),
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
);
const editorWithPreview = ( const editorWithPreview = (
<div <div
@ -238,27 +278,31 @@ const EditorInterface = ({
grid grid
h-full h-full
`, `,
editorSize === EDITOR_SIZE_COMPACT ? 'grid-cols-editor' : 'grid-cols-2', editorSize === EDITOR_SIZE_COMPACT ? 'lg:grid-cols-editor' : 'lg:grid-cols-2',
)} )}
> >
<ScrollSyncPane>{editor}</ScrollSyncPane> <ScrollSyncPane>{editor}</ScrollSyncPane>
<EditorPreviewPane <EditorPreviewPane
collection={collection} collection={collection}
previewInFrame={previewInFrame} previewInFrame={previewInFrame}
entry={previewEntry} entry={entry}
fields={fields} fields={fields}
editorSize={editorSize} editorSize={editorSize}
showMobilePreview={showMobilePreview}
/> />
</div> </div>
); );
const editorSideBySideLocale = ( const editorSideBySideLocale = (
<div className="grid grid-cols-2 h-full"> <>
<ScrollSyncPane>{editor}</ScrollSyncPane> <div className="grid-cols-2 h-full hidden lg:grid">
<ScrollSyncPane> <ScrollSyncPane>{editor}</ScrollSyncPane>
<>{editorLocale}</> <ScrollSyncPane>
</ScrollSyncPane> <>{editorLocale}</>
</div> </ScrollSyncPane>
</div>
{mobileLocaleEditor}
</>
); );
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
@ -296,6 +340,9 @@ const EditorInterface = ({
toggleScrollSync={handleToggleScrollSync} toggleScrollSync={handleToggleScrollSync}
toggleI18n={handleToggleI18n} toggleI18n={handleToggleI18n}
slug={slug} slug={slug}
showMobilePreview={showMobilePreview}
onMobilePreviewToggle={toggleMobilePreview}
className="flex"
/> />
} }
> >

View File

@ -11,6 +11,7 @@ import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries'; import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util'; import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util';
import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
@ -18,6 +19,7 @@ import confirm from '../common/confirm/Confirm';
import Menu from '../common/menu/Menu'; import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup'; import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; import MenuItemButton from '../common/menu/MenuItemButton';
import IconButton from '../common/button/IconButton';
import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface';
import type { FC, MouseEventHandler } from 'react'; import type { FC, MouseEventHandler } from 'react';
@ -44,6 +46,9 @@ export interface EditorToolbarProps {
toggleScrollSync: MouseEventHandler; toggleScrollSync: MouseEventHandler;
toggleI18n: MouseEventHandler; toggleI18n: MouseEventHandler;
slug?: string | undefined; slug?: string | undefined;
className?: string;
showMobilePreview: boolean;
onMobilePreviewToggle: () => void;
} }
const EditorToolbar = ({ const EditorToolbar = ({
@ -67,6 +72,9 @@ const EditorToolbar = ({
toggleScrollSync, toggleScrollSync,
toggleI18n, toggleI18n,
slug, slug,
className,
showMobilePreview,
onMobilePreviewToggle,
}: TranslatedProps<EditorToolbarProps>) => { }: TranslatedProps<EditorToolbarProps>) => {
const canCreate = useMemo( const canCreate = useMemo(
() => ('folder' in collection && collection.create) ?? false, () => ('folder' in collection && collection.create) ?? false,
@ -164,13 +172,22 @@ const EditorToolbar = ({
return useMemo( return useMemo(
() => ( () => (
<div className="flex gap-2"> <div
className={classNames(
`
flex
gap-2
`,
className,
)}
>
{showI18nToggle || showPreviewToggle || canDelete ? ( {showI18nToggle || showPreviewToggle || canDelete ? (
<Menu <Menu
key="extra-menu" key="extra-menu"
label={<MoreVertIcon className="w-5 h-5" />} label={<MoreVertIcon className="w-5 h-5" />}
variant="text" variant="text"
className="px-1.5" rootClassName="hidden lg:flex"
buttonClassName="px-1.5"
hideDropdownIcon hideDropdownIcon
> >
<MenuGroup> <MenuGroup>
@ -215,12 +232,39 @@ const EditorToolbar = ({
) : null} ) : null}
</Menu> </Menu>
) : null} ) : null}
{showPreviewToggle ? (
<IconButton
key="show-preview-button"
title={t('editor.editorInterface.preview')}
variant={showMobilePreview ? 'contained' : 'text'}
onClick={onMobilePreviewToggle}
className="flex lg:hidden"
>
<EyeIcon className="w-5 h-5" />
</IconButton>
) : null}
{canDelete ? (
<IconButton
key="delete-button"
title={t('editor.editorToolbar.deleteEntry')}
color="error"
variant="text"
onClick={onDelete}
className="flex lg:hidden"
>
<TrashIcon className="w-5 h-5" />
</IconButton>
) : null}
<Menu <Menu
label={ label={
isPublished ? t('editor.editorToolbar.published') : t('editor.editorToolbar.publish') isPublished ? t('editor.editorToolbar.published') : t('editor.editorToolbar.publish')
} }
color={isPublished ? 'success' : 'primary'} color={isPublished ? 'success' : 'primary'}
disabled={isLoading || (menuItems.length == 1 && menuItems[0].length === 0)} disabled={isLoading || (menuItems.length == 1 && menuItems[0].length === 0)}
startIcon={PublishIcon}
iconClassName="flex !md:hidden"
labelClassName="hidden md:block"
hideDropdownIconOnMobile
> >
{menuItems.map((group, index) => ( {menuItems.map((group, index) => (
<MenuGroup key={`menu-group-${index}`}>{group}</MenuGroup> <MenuGroup key={`menu-group-${index}`}>{group}</MenuGroup>
@ -229,6 +273,7 @@ const EditorToolbar = ({
</div> </div>
), ),
[ [
className,
showI18nToggle, showI18nToggle,
showPreviewToggle, showPreviewToggle,
canDelete, canDelete,
@ -241,6 +286,8 @@ const EditorToolbar = ({
toggleScrollSync, toggleScrollSync,
scrollSyncActive, scrollSyncActive,
onDelete, onDelete,
showMobilePreview,
onMobilePreviewToggle,
isPublished, isPublished,
menuItems, menuItems,
], ],

View File

@ -30,6 +30,7 @@ export interface EditorControlPaneProps {
hideBorder: boolean; hideBorder: boolean;
slug?: string; slug?: string;
onLocaleChange?: (locale: string) => void; onLocaleChange?: (locale: string) => void;
allowDefaultLocale?: boolean;
} }
const EditorControlPane = ({ const EditorControlPane = ({
@ -43,6 +44,7 @@ const EditorControlPane = ({
hideBorder, hideBorder,
slug, slug,
onLocaleChange, onLocaleChange,
allowDefaultLocale = false,
t, t,
}: TranslatedProps<EditorControlPaneProps>) => { }: TranslatedProps<EditorControlPaneProps>) => {
const pathField = useMemo( const pathField = useMemo(
@ -95,12 +97,13 @@ const EditorControlPane = ({
flex flex
flex-col flex-col
min-h-full min-h-full
w-full
`, `,
!hideBorder && !hideBorder &&
` `
border-r lg:border-r
border-slate-400 border-slate-400
`, `,
)} )}
> >
{i18n?.locales && locale ? ( {i18n?.locales && locale ? (
@ -117,6 +120,7 @@ const EditorControlPane = ({
})} })}
canChangeLocale={canChangeLocale} canChangeLocale={canChangeLocale}
onLocaleChange={onLocaleChange} onLocaleChange={onLocaleChange}
allowDefaultLocale={allowDefaultLocale}
/> />
</div> </div>
) : null} ) : null}

View File

@ -9,6 +9,7 @@ interface LocaleDropdownProps {
defaultLocale: string; defaultLocale: string;
dropdownText: string; dropdownText: string;
canChangeLocale: boolean; canChangeLocale: boolean;
allowDefaultLocale: boolean;
onLocaleChange?: (locale: string) => void; onLocaleChange?: (locale: string) => void;
} }
@ -17,6 +18,7 @@ const LocaleDropdown = ({
defaultLocale, defaultLocale,
dropdownText, dropdownText,
canChangeLocale, canChangeLocale,
allowDefaultLocale,
onLocaleChange, onLocaleChange,
}: LocaleDropdownProps) => { }: LocaleDropdownProps) => {
if (!canChangeLocale) { if (!canChangeLocale) {
@ -39,7 +41,7 @@ const LocaleDropdown = ({
<Menu label={dropdownText}> <Menu label={dropdownText}>
<MenuGroup> <MenuGroup>
{locales {locales
.filter(locale => locale !== defaultLocale) .filter(locale => allowDefaultLocale || locale !== defaultLocale)
.map(locale => ( .map(locale => (
<MenuItemButton key={locale} onClick={() => onLocaleChange?.(locale)}> <MenuItemButton key={locale} onClick={() => onLocaleChange?.(locale)}>
{locale} {locale}

View File

@ -90,6 +90,10 @@ const FrameGlobalStyles = `
height: 10px; /* Mostly for horizontal scrollbars */ height: 10px; /* Mostly for horizontal scrollbars */
} }
.styled-scrollbars::-webkit-scrollbar-corner {
background: rgba(0,0,0,0);
}
.styled-scrollbars::-webkit-scrollbar-thumb { .styled-scrollbars::-webkit-scrollbar-thumb {
/* Foreground */ /* Foreground */
background: var(--scrollbar-foreground); background: var(--scrollbar-foreground);
@ -107,10 +111,11 @@ export interface EditorPreviewPaneProps {
entry: Entry; entry: Entry;
previewInFrame: boolean; previewInFrame: boolean;
editorSize: EditorSize; editorSize: EditorSize;
showMobilePreview: boolean;
} }
const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => { const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
const { editorSize, entry, collection, fields, previewInFrame, t } = props; const { editorSize, entry, collection, fields, previewInFrame, showMobilePreview, t } = props;
const config = useAppSelector(selectConfig); const config = useAppSelector(selectConfig);
@ -175,62 +180,67 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
<div <div
className={classNames( className={classNames(
` `
h-main h-main-mobile
md:h-main
absolute absolute
top-16 top-16
right-0 right-0
w-full
`, `,
editorSize === EDITOR_SIZE_COMPACT ? 'w-preview' : 'w-6/12', editorSize === EDITOR_SIZE_COMPACT ? 'lg:w-preview' : 'lg:w-6/12',
!showMobilePreview &&
`
hidden
lg:block
`,
)} )}
> >
{!entry || !entry.data ? null : ( <ErrorBoundary config={config}>
<ErrorBoundary config={config}> {previewInFrame ? (
{previewInFrame ? ( <Frame
<Frame key="preview-frame"
key="preview-frame" id="preview-pane"
head={previewStyles}
initialContent={initialFrameContent}
className="w-full h-full"
>
{!collection ? (
t('collection.notFound')
) : (
<PreviewFrameContent
key="preview-frame-content"
previewComponent={previewComponent}
previewProps={{ ...previewProps }}
/>
)}
</Frame>
) : (
<ScrollSyncPane key="preview-wrapper-scroll-sync">
<div
key="preview-wrapper"
id="preview-pane" id="preview-pane"
head={previewStyles} className="
initialContent={initialFrameContent}
className="w-full h-full"
>
{!collection ? (
t('collection.notFound')
) : (
<PreviewFrameContent
key="preview-frame-content"
previewComponent={previewComponent}
previewProps={{ ...previewProps }}
/>
)}
</Frame>
) : (
<ScrollSyncPane key="preview-wrapper-scroll-sync">
<div
key="preview-wrapper"
id="preview-pane"
className="
overflow-y-auto overflow-y-auto
styled-scrollbars styled-scrollbars
h-full h-full
" "
> >
{!collection ? ( {!collection ? (
t('collection.notFound') t('collection.notFound')
) : ( ) : (
<> <>
{previewStyles} {previewStyles}
<EditorPreviewContent <EditorPreviewContent
key="preview-wrapper-content" key="preview-wrapper-content"
previewComponent={previewComponent} previewComponent={previewComponent}
previewProps={{ ...previewProps, document, window }} previewProps={{ ...previewProps, document, window }}
/> />
</> </>
)} )}
</div> </div>
</ScrollSyncPane> </ScrollSyncPane>
)} )}
</ErrorBoundary> </ErrorBoundary>
)}
</div>, </div>,
element, element,
'preview-content', 'preview-content',
@ -240,14 +250,14 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
config, config,
editorSize, editorSize,
element, element,
entry,
initialFrameContent, initialFrameContent,
previewComponent, previewComponent,
previewInFrame, previewInFrame,
previewProps, previewProps,
previewStyles, previewStyles,
showMobilePreview,
t, t,
]); ]);
}; };
export default translate()(PreviewPane) as FC<EditorPreviewPaneProps>; export default translate()(EditorPreviewPane) as FC<EditorPreviewPaneProps>;

View File

@ -0,0 +1,80 @@
import { Add as AddIcon } from '@styled-icons/material/Add';
import { Menu as MenuIcon } from '@styled-icons/material/Menu';
import { OpenInNew as OpenInNewIcon } from '@styled-icons/material/OpenInNew';
import React, { useCallback, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { selectDisplayUrl } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import IconButton from '../common/button/IconButton';
import NavigationDrawer from './NavigationDrawer';
import QuickCreate from './QuickCreate';
import type { Collection } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface BottomNavigationProps {
collection: Collection | undefined;
}
const BottomNavigation: FC<BottomNavigationProps> = ({ collection }) => {
const params = useParams();
const filterTerm = useMemo(() => params['*'], [params]);
const newEntryUrl = useNewEntryUrl(collection, filterTerm);
const displayUrl = useAppSelector(selectDisplayUrl);
const [mobileOpen, setMobileOpen] = useState(false);
const toggleMobileMenu = useCallback(() => {
setMobileOpen(old => !old);
}, []);
return (
<>
<div
className="
fixed
bottom-0
left-0
right-0
h-16
shadow-bottom-navigation
bg-gray-50
dark:bg-gray-800
flex
px-6
py-1
md:hidden
"
>
<IconButton variant="text" className="flex-grow" onClick={toggleMobileMenu}>
<MenuIcon className="w-6 h-6" />
</IconButton>
{isNotEmpty(newEntryUrl) ? (
<IconButton to={newEntryUrl} variant="text" className="flex-grow">
<AddIcon className="w-6 h-6" />
</IconButton>
) : (
<QuickCreate
key="quick-create"
variant="text"
rootClassName="flex-grow"
buttonClassName="w-full"
hideDropdownIcon
hideLabel
/>
)}
{displayUrl ? (
<IconButton variant="text" className="flex-grow" href={displayUrl}>
<OpenInNewIcon className="h-6 w-6" />
</IconButton>
) : null}
</div>
<NavigationDrawer mobileOpen={mobileOpen} onMobileOpenToggle={toggleMobileMenu} />
</>
);
};
export default BottomNavigation;

View File

@ -0,0 +1,142 @@
import { ArrowBack as ArrowBackIcon } from '@styled-icons/material/ArrowBack';
import React, { Fragment, useMemo } from 'react';
import { Link } from 'react-router-dom';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { Breadcrumb } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface BreadcrumbsProps {
breadcrumbs: Breadcrumb[];
inEditor?: boolean;
}
const Breadcrumbs: FC<BreadcrumbsProps> = ({ breadcrumbs, inEditor = false }) => {
const finalNonEditorBreadcrumb = useMemo(() => {
const nonEditorBreadcrumbs = breadcrumbs.filter(b => !b.editor);
if (nonEditorBreadcrumbs.length === 0) {
return undefined;
}
return nonEditorBreadcrumbs[nonEditorBreadcrumbs.length - 1];
}, [breadcrumbs]);
return (
<div
className="
flex
h-full
md:w-auto
items-center
text-xl
font-semibold
gap-1
text-gray-800
dark:text-white
flex-grow
truncate
"
>
<div
className="
hidden
md:flex
overflow-hidden
relative
flex-grow
h-full
"
>
<div
className="
w-full
absolute
inset-0
flex
items-center
gap-1
"
>
{breadcrumbs.map((breadcrumb, index) =>
breadcrumb.name ? (
<Fragment key={`breadcrumb-${index}`}>
{index > 0 ? <span key={`separator-${index}`}>&#62;</span> : null}
{breadcrumb.to ? (
<Link
key={`link-${index}`}
className={classNames(
`
hover:text-gray-400
dark:hover:text-gray-400
overflow-hidden
whitespace-nowrap
focus:outline-none
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
`,
index + 1 === breadcrumbs.length ? 'text-ellipsis' : 'flex-shrink-0',
)}
to={breadcrumb.to}
>
{breadcrumb.name}
</Link>
) : (
<span
key={`text-${index}`}
className={classNames(
`
truncate
`,
index + 1 === breadcrumbs.length ? 'text-ellipsis' : 'flex-shrink-0',
)}
>
{breadcrumb.name}
</span>
)}
</Fragment>
) : null,
)}
</div>
</div>
{finalNonEditorBreadcrumb ? (
finalNonEditorBreadcrumb.to ? (
<Link
key="final-non-editor-breadcrumb-link"
className="
flex
md:hidden
gap-2
items-center
truncate
w-full
focus:outline-none
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
"
to={finalNonEditorBreadcrumb.to}
>
{inEditor ? <ArrowBackIcon className="w-6 h-6" /> : null}
{finalNonEditorBreadcrumb.name}
</Link>
) : (
<div
key="final-non-editor-breadcrumb-text"
className="
block
md:hidden
truncate
w-full
"
>
{finalNonEditorBreadcrumb?.name ?? ''}
</div>
)
) : null}
</div>
);
};
export default Breadcrumbs;

View File

@ -1,18 +1,19 @@
import { OpenInNew as OpenInNewIcon } from '@styled-icons/material/OpenInNew'; import { OpenInNew as OpenInNewIcon } from '@styled-icons/material/OpenInNew';
import React, { Fragment, useEffect } from 'react'; import React, { useEffect, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { Link } from 'react-router-dom';
import { checkBackendStatus } from '@staticcms/core/actions/status'; import { checkBackendStatus } from '@staticcms/core/actions/status';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectConfig, selectDisplayUrl } from '@staticcms/core/reducers/selectors/config'; import { selectConfig, selectDisplayUrl } from '@staticcms/core/reducers/selectors/config';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import Button from '../common/button/Button'; import Button from '../common/button/Button';
import { StaticCmsIcon } from '../images/_index'; import { StaticCmsIcon } from '../images/_index';
import Breadcrumbs from './Breadcrumbs';
import QuickCreate from './QuickCreate'; import QuickCreate from './QuickCreate';
import SettingsDropdown from './SettingsDropdown'; import SettingsDropdown from './SettingsDropdown';
import type { Breadcrumb, TranslatedProps } from '@staticcms/core/interface'; import type { Breadcrumb, TranslatedProps } from '@staticcms/core/interface';
import type { ComponentType, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
export interface NavbarProps { export interface NavbarProps {
breadcrumbs?: Breadcrumb[]; breadcrumbs?: Breadcrumb[];
@ -40,6 +41,11 @@ const Navbar = ({
}; };
}, [dispatch]); }, [dispatch]);
const inEditor = useMemo(
() => Boolean(breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1].editor),
[breadcrumbs],
);
return ( return (
<nav <nav
className=" className="
@ -52,49 +58,56 @@ const Navbar = ({
" "
> >
<div key="nav" className="mx-auto pr-2 sm:pr-4 lg:pr-5"> <div key="nav" className="mx-auto pr-2 sm:pr-4 lg:pr-5">
<div className="relative flex h-16 items-center justify-between"> <div
<div className="flex flex-1 items-center justify-center h-full sm:items-stretch sm:justify-start gap-4"> className={classNames(
<div className="flex flex-shrink-0 items-center justify-center bg-slate-500 dark:bg-slate-700 w-16"> `
relative
flex
h-16
items-center
justify-between
gap-2
`,
inEditor && 'pl-3 md:pl-0',
)}
>
<div className="flex flex-1 h-full items-stretch justify-start gap-2 md:gap-4 truncate">
<div
className={classNames(
`
flex-shrink-0
items-center
justify-center
w-16
bg-slate-500
dark:bg-slate-700
`,
inEditor ? 'hidden md:flex' : 'flex',
)}
>
{config?.logo_url ? ( {config?.logo_url ? (
<div <div
className="inline-flex h-10 w-10 bg-cover bg-no-repeat bg-center object-cover" className="h-10 w-10 bg-cover bg-no-repeat bg-center object-cover"
style={{ backgroundImage: `url('${config.logo_url}')` }} style={{ backgroundImage: `url('${config.logo_url}')` }}
/> />
) : ( ) : (
<StaticCmsIcon className="inline-flex w-10 h-10" /> <StaticCmsIcon className="w-10 h-10" />
)}
</div>
<div className="flex h-full items-center text-xl font-semibold gap-1 text-gray-800 dark:text-white">
{breadcrumbs.map((breadcrumb, index) =>
breadcrumb.name ? (
<Fragment key={`breadcrumb-${index}`}>
{index > 0 ? <span key={`separator-${index}`}>&#62;</span> : null}
{breadcrumb.to ? (
<Link
key={`link-${index}`}
className="hover:text-gray-400 dark:hover:text-gray-400"
to={breadcrumb.to}
>
{breadcrumb.name}
</Link>
) : (
<span key={`text-${index}`}>{breadcrumb.name}</span>
)}
</Fragment>
) : null,
)} )}
</div> </div>
<Breadcrumbs breadcrumbs={breadcrumbs} inEditor={inEditor} />
</div> </div>
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
{displayUrl ? ( {displayUrl ? (
<Button variant="text" className="flex gap-2" href={displayUrl}> <Button variant="text" className="gap-2 hidden lg:flex" href={displayUrl}>
{displayUrl} <div className="hidden lg:flex">{displayUrl}</div>
<OpenInNewIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" /> <OpenInNewIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</Button> </Button>
) : null} ) : null}
{showQuickCreate ? <QuickCreate key="quick-create" /> : null} {showQuickCreate ? (
<QuickCreate key="quick-create" rootClassName="hidden md:block" />
) : null}
{navbarActions} {navbarActions}
<SettingsDropdown /> <SettingsDropdown inEditor={inEditor} />
</div> </div>
</div> </div>
</div> </div>
@ -102,4 +115,4 @@ const Navbar = ({
); );
}; };
export default translate()(Navbar) as ComponentType<NavbarProps>; export default translate()(Navbar) as FC<NavbarProps>;

View File

@ -0,0 +1,56 @@
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import React, { useMemo } from 'react';
import SidebarContent from './SidebarContent';
const DRAWER_WIDTH = 320;
interface NavigationDrawerProps {
mobileOpen: boolean;
onMobileOpenToggle: () => void;
}
const NavigationDrawer = ({ mobileOpen, onMobileOpenToggle }: NavigationDrawerProps) => {
const iOS = useMemo(
() => typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent),
[],
);
const container = useMemo(
() => (typeof window !== 'undefined' ? window.document.body : undefined),
[],
);
return (
<SwipeableDrawer
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
container={container}
variant="temporary"
open={mobileOpen}
onOpen={onMobileOpenToggle}
onClose={onMobileOpenToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
width: '80%',
maxWidth: DRAWER_WIDTH,
'& .MuiBackdrop-root': {
width: '100%',
},
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: '80%',
maxWidth: DRAWER_WIDTH,
},
}}
>
<div onClick={onMobileOpenToggle} className="w-full h-full">
<SidebarContent />
</div>
</SwipeableDrawer>
);
};
export default NavigationDrawer;

View File

@ -6,12 +6,19 @@ import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
import { selectCollections } from '@staticcms/core/reducers/selectors/collections'; import { selectCollections } from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import Menu from '../common/menu/Menu'; import Menu from '../common/menu/Menu';
import MenuItemLink from '../common/menu/MenuItemLink';
import MenuGroup from '../common/menu/MenuGroup'; import MenuGroup from '../common/menu/MenuGroup';
import MenuItemLink from '../common/menu/MenuItemLink';
import type { TranslateProps } from 'react-polyglot'; import type { TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { MenuProps } from '../common/menu/Menu';
const QuickCreate = ({ t }: TranslateProps) => { export type QuickCreateProps = Pick<
MenuProps,
'rootClassName' | 'buttonClassName' | 'hideDropdownIcon' | 'hideLabel' | 'variant'
>;
const QuickCreate: FC<TranslatedProps<QuickCreateProps>> = ({ t, ...menuProps }) => {
const collections = useAppSelector(selectCollections); const collections = useAppSelector(selectCollections);
const createableCollections = useMemo( const createableCollections = useMemo(
@ -23,7 +30,7 @@ const QuickCreate = ({ t }: TranslateProps) => {
); );
return ( return (
<Menu label={t('app.header.quickAdd')} startIcon={AddIcon}> <Menu label={t('app.header.quickAdd')} startIcon={AddIcon} {...menuProps}>
<MenuGroup> <MenuGroup>
{createableCollections.map(collection => ( {createableCollections.map(collection => (
<MenuItemLink key={collection.name} href={getNewEntryUrl(collection.name)}> <MenuItemLink key={collection.name} href={getNewEntryUrl(collection.name)}>
@ -35,4 +42,4 @@ const QuickCreate = ({ t }: TranslateProps) => {
); );
}; };
export default translate()(QuickCreate); export default translate()(QuickCreate) as FC<QuickCreateProps>;

View File

@ -13,8 +13,8 @@ import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; import MenuItemButton from '../common/menu/MenuItemButton';
import Switch from '../common/switch/Switch'; import Switch from '../common/switch/Switch';
import type { ChangeEvent, MouseEvent } from 'react'; import type { TranslatedProps } from '@staticcms/core/interface';
import type { TranslateProps } from 'react-polyglot'; import type { ChangeEvent, FC, MouseEvent } from 'react';
interface AvatarImageProps { interface AvatarImageProps {
imageUrl: string | undefined; imageUrl: string | undefined;
@ -28,7 +28,11 @@ const AvatarImage = ({ imageUrl }: AvatarImageProps) => {
); );
}; };
const SettingsDropdown = ({ t }: TranslateProps) => { export interface SettingsDropdownProps {
inEditor: boolean;
}
const SettingsDropdown: FC<TranslatedProps<SettingsDropdownProps>> = ({ inEditor, t }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const user = useAppSelector(selectUser); const user = useAppSelector(selectUser);
const [isDarkMode, setIsDarkMode] = useState(document.documentElement.classList.contains('dark')); const [isDarkMode, setIsDarkMode] = useState(document.documentElement.classList.contains('dark'));
@ -84,6 +88,7 @@ const SettingsDropdown = ({ t }: TranslateProps) => {
variant="outlined" variant="outlined"
rounded={!user?.avatar_url || 'no-padding'} rounded={!user?.avatar_url || 'no-padding'}
hideDropdownIcon hideDropdownIcon
rootClassName={inEditor ? 'hidden md:flex' : ''}
> >
<MenuGroup> <MenuGroup>
<MenuItemButton key="dark-mode" onClick={handleToggleDarkMode} startIcon={MoonIcon}> <MenuItemButton key="dark-mode" onClick={handleToggleDarkMode} startIcon={MoonIcon}>
@ -102,4 +107,4 @@ const SettingsDropdown = ({ t }: TranslateProps) => {
); );
}; };
export default translate()(SettingsDropdown); export default translate()(SettingsDropdown) as FC<SettingsDropdownProps>;

View File

@ -1,139 +1,19 @@
import { Photo as PhotoIcon } from '@styled-icons/material/Photo'; import React from 'react';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { useNavigate, useParams } from 'react-router-dom';
import { getIcon } from '@staticcms/core/lib/hooks/useIcon';
import { getAdditionalLinks } from '@staticcms/core/lib/registry';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectCollections } from '@staticcms/core/reducers/selectors/collections'; import SidebarContent from './SidebarContent';
import { selectIsSearchEnabled } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import CollectionSearch from '../collections/CollectionSearch';
import NestedCollection from '../collections/NestedCollection';
import NavLink from './NavLink';
import type { Collection } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
import type { TranslateProps } from 'react-polyglot';
const Sidebar: FC<TranslateProps> = ({ t }) => {
const { name, searchTerm, ...params } = useParams();
const filterTerm = useMemo(() => params['*'] ?? '', [params]);
const navigate = useNavigate();
const isSearchEnabled = useAppSelector(selectIsSearchEnabled);
const collections = useAppSelector(selectCollections);
const collection = useMemo(
() => (name ? collections[name] : collections[0]) as Collection | undefined,
[collections, name],
);
const collectionLinks = useMemo(
() =>
Object.values(collections)
.filter(collection => collection.hide !== true)
.map(collection => {
const collectionName = collection.name;
const icon = getIcon(collection.icon);
if ('nested' in collection) {
return (
<NestedCollection
key={`nested-${collectionName}`}
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
);
}
return (
<NavLink key={collectionName} to={`/collections/${collectionName}`} icon={icon}>
{collection.label}
</NavLink>
);
}),
[collections, filterTerm],
);
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
const links = useMemo(
() =>
Object.values(additionalLinks).map(
({ id, title, data, options: { icon: iconName } = {} }) => {
const icon = getIcon(iconName);
return typeof data === 'string' ? (
<NavLink key={title} href={data} icon={icon}>
{title}
</NavLink>
) : (
<NavLink key={title} to={`/page/${id}`} icon={icon}>
{title}
</NavLink>
);
},
),
[additionalLinks],
);
const searchCollections = useCallback(
(query?: string, collection?: string) => {
if (!query) {
return;
}
if (collection) {
navigate(`/collections/${collection}/search/${query}`);
} else {
navigate(`/search/${query}`);
}
},
[navigate],
);
const Sidebar: FC = () => {
return ( return (
<aside <aside
className={classNames( className={classNames('w-sidebar-expanded', 'h-main-mobile md:h-main hidden md:block')}
'w-sidebar-expanded',
'h-main sm:fixed sm:z-20 sm:shadow-sidebar lg:block lg:z-auto lg:shadow-none',
)}
aria-label="Sidebar" aria-label="Sidebar"
> >
<div <SidebarContent />
className="
px-3
py-4
h-full
w-full
overflow-y-auto
bg-white
dark:bg-slate-800
styled-scrollbars
"
>
<ul className="space-y-2">
{isSearchEnabled && (
<CollectionSearch
searchTerm={searchTerm}
collections={collections}
collection={collection}
onSubmit={(query: string, collection?: string) =>
searchCollections(query, collection)
}
/>
)}
{collectionLinks}
{links}
<NavLink key="Media" to="/media" icon={<PhotoIcon className="h-6 w-6" />}>
{t('app.header.media')}
</NavLink>
</ul>
</div>
</aside> </aside>
); );
}; };
export default translate()(Sidebar) as FC; export default Sidebar;

View File

@ -0,0 +1,128 @@
import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { useNavigate, useParams } from 'react-router-dom';
import { getIcon } from '@staticcms/core/lib/hooks/useIcon';
import { getAdditionalLinks } from '@staticcms/core/lib/registry';
import { selectCollections } from '@staticcms/core/reducers/selectors/collections';
import { selectIsSearchEnabled } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import CollectionSearch from '../collections/CollectionSearch';
import NestedCollection from '../collections/NestedCollection';
import NavLink from './NavLink';
import type { Collection } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { TranslateProps } from 'react-polyglot';
const SidebarContent: FC<TranslateProps> = ({ t }) => {
const { name, searchTerm, ...params } = useParams();
const filterTerm = useMemo(() => params['*'] ?? '', [params]);
const navigate = useNavigate();
const isSearchEnabled = useAppSelector(selectIsSearchEnabled);
const collections = useAppSelector(selectCollections);
const collection = useMemo(
() => (name ? collections[name] : collections[0]) as Collection | undefined,
[collections, name],
);
const collectionLinks = useMemo(
() =>
Object.values(collections)
.filter(collection => collection.hide !== true)
.map(collection => {
const collectionName = collection.name;
const icon = getIcon(collection.icon);
if ('nested' in collection) {
return (
<NestedCollection
key={`nested-${collectionName}`}
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
);
}
return (
<NavLink key={collectionName} to={`/collections/${collectionName}`} icon={icon}>
{collection.label}
</NavLink>
);
}),
[collections, filterTerm],
);
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
const links = useMemo(
() =>
Object.values(additionalLinks).map(
({ id, title, data, options: { icon: iconName } = {} }) => {
const icon = getIcon(iconName);
return typeof data === 'string' ? (
<NavLink key={title} href={data} icon={icon}>
{title}
</NavLink>
) : (
<NavLink key={title} to={`/page/${id}`} icon={icon}>
{title}
</NavLink>
);
},
),
[additionalLinks],
);
const searchCollections = useCallback(
(query?: string, collection?: string) => {
if (!query) {
return;
}
if (collection) {
navigate(`/collections/${collection}/search/${query}`);
} else {
navigate(`/search/${query}`);
}
},
[navigate],
);
return (
<div
className="
px-3
py-4
h-full
w-full
overflow-y-auto
bg-white
dark:bg-slate-800
styled-scrollbars
"
>
<ul className="space-y-2">
{isSearchEnabled && (
<CollectionSearch
searchTerm={searchTerm}
collections={collections}
collection={collection}
onSubmit={(query: string, collection?: string) => searchCollections(query, collection)}
/>
)}
{collectionLinks}
{links}
<NavLink key="Media" to="/media" icon={<PhotoIcon className="h-6 w-6" />}>
{t('app.header.media')}
</NavLink>
</ul>
</div>
);
};
export default translate()(SidebarContent) as FC;

View File

@ -72,6 +72,9 @@ export const INFERABLE_FIELDS: Record<string, InferredField> = {
'cover', 'cover',
'hero', 'hero',
'logo', 'logo',
'cover_image',
'cover-image',
'coverimage',
], ],
defaultPreview: value => value, defaultPreview: value => value,
fallbackToFirstField: false, fallbackToFirstField: false,

View File

@ -1103,6 +1103,7 @@ export interface SvgProps {
export interface Breadcrumb { export interface Breadcrumb {
name?: string; name?: string;
to?: string; to?: string;
editor?: boolean;
} }
export interface MediaLibraryDisplayURL { export interface MediaLibraryDisplayURL {

View File

@ -83,6 +83,7 @@ export default function useBreadcrumbs(
collectionLabel: collection.label_singular || collection.label, collectionLabel: collection.label_singular || collection.label,
}) })
: summary, : summary,
editor: true,
}); });
} }

View File

@ -0,0 +1,25 @@
import { useEffect } from 'react';
export interface MetaProps {
name: string;
content: string;
}
export default function useMeta({ name, content }: MetaProps) {
useEffect(() => {
const head = document.querySelector('head');
if (!head) {
return;
}
const meta = document.createElement('meta');
meta.setAttribute('name', name);
meta.setAttribute('content', content);
head.appendChild(meta);
return () => {
head.removeChild(meta);
};
}, [content, name]);
}

View File

@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { getNewEntryUrl } from '../urlHelper';
import { isNotEmpty } from '../util/string.util';
import type { Collection } from '@staticcms/core/interface';
export default function useNewEntryUrl(
collection: Collection | undefined,
filterTerm: string | undefined,
) {
return useMemo(() => {
if (!collection) {
return undefined;
}
return 'fields' in collection && collection.create
? `${getNewEntryUrl(collection.name)}${isNotEmpty(filterTerm) ? `/${filterTerm}` : ''}`
: '';
}, [collection, filterTerm]);
}

View File

@ -69,8 +69,7 @@ export function getLocaleDataPath(locale: string) {
} }
export function getDataPath(locale: string, defaultLocale: string) { export function getDataPath(locale: string, defaultLocale: string) {
const dataPath = locale !== defaultLocale ? getLocaleDataPath(locale) : ['data']; return locale !== defaultLocale ? getLocaleDataPath(locale) : ['data'];
return dataPath;
} }
export function getFilePath( export function getFilePath(

View File

@ -8,13 +8,15 @@
@layer components { @layer components {
html { html {
@apply overflow-hidden; @apply overflow-hidden
w-screen;
} }
body { body {
@apply text-gray-800 @apply text-gray-800
dark:text-gray-100 dark:text-gray-100
overflow-hidden; overflow-hidden
w-screen;
} }
/** /**
@ -29,7 +31,8 @@
focus:outline-none focus:outline-none
focus:ring-4 focus:ring-4
focus:ring-gray-200 focus:ring-gray-200
dark:focus:ring-slate-700; dark:focus:ring-slate-700
border;
} }
.btn { .btn {
@ -64,8 +67,7 @@
} }
.btn-contained-primary { .btn-contained-primary {
@apply border @apply border-transparent
border-transparent
bg-blue-700 bg-blue-700
hover:bg-blue-800 hover:bg-blue-800
text-white text-white
@ -80,7 +82,6 @@
.btn-outlined-primary { .btn-outlined-primary {
@apply text-gray-800 @apply text-gray-800
bg-transparent bg-transparent
border
border-gray-200 border-gray-200
hover:bg-slate-100 hover:bg-slate-100
hover:border-blue-400 hover:border-blue-400
@ -101,6 +102,7 @@
.btn-text-primary { .btn-text-primary {
@apply bg-transparent @apply bg-transparent
border-transparent
text-gray-800 text-gray-800
hover:text-blue-700 hover:text-blue-700
hover:bg-blue-100 hover:bg-blue-100
@ -110,13 +112,11 @@
dark:hover:text-blue-400 dark:hover:text-blue-400
dark:hover:bg-slate-700/75 dark:hover:bg-slate-700/75
dark:disabled:text-slate-600/75 dark:disabled:text-slate-600/75
dark:disabled:border-slate-600/75
dark:disabled:hover:bg-transparent; dark:disabled:hover:bg-transparent;
} }
.btn-contained-secondary { .btn-contained-secondary {
@apply border @apply font-medium
font-medium
text-gray-600 text-gray-600
bg-white bg-white
border-gray-200/75 border-gray-200/75
@ -140,7 +140,6 @@
.btn-outlined-secondary { .btn-outlined-secondary {
@apply text-gray-800 @apply text-gray-800
bg-transparent bg-transparent
border
border-gray-200 border-gray-200
hover:bg-gray-100 hover:bg-gray-100
hover:text-gray-800 hover:text-gray-800
@ -161,6 +160,7 @@
.btn-text-secondary { .btn-text-secondary {
@apply bg-transparent @apply bg-transparent
border-transparent
text-gray-800 text-gray-800
hover:text-gray-800 hover:text-gray-800
hover:bg-gray-100 hover:bg-gray-100
@ -170,13 +170,11 @@
dark:hover:text-white dark:hover:text-white
dark:hover:bg-gray-700 dark:hover:bg-gray-700
dark:disabled:text-gray-400/20 dark:disabled:text-gray-400/20
dark:disabled:border-gray-600/20
dark:disabled:hover:bg-transparent; dark:disabled:hover:bg-transparent;
} }
.btn-contained-success { .btn-contained-success {
@apply border @apply border-transparent
border-transparent
bg-green-600 bg-green-600
hover:bg-green-700 hover:bg-green-700
text-white text-white
@ -190,7 +188,6 @@
.btn-outlined-success { .btn-outlined-success {
@apply bg-transparent @apply bg-transparent
border
text-green-500 text-green-500
border-green-200 border-green-200
hover:bg-green-100 hover:bg-green-100
@ -210,6 +207,7 @@
.btn-text-success { .btn-text-success {
@apply bg-transparent @apply bg-transparent
border-transparent
text-green-500 text-green-500
hover:text-green-700 hover:text-green-700
hover:bg-green-100 hover:bg-green-100
@ -219,13 +217,11 @@
dark:hover:text-green-500 dark:hover:text-green-500
dark:hover:bg-green-700/10 dark:hover:bg-green-700/10
dark:disabled:text-slate-600/75 dark:disabled:text-slate-600/75
dark:disabled:border-slate-600/75
dark:disabled:hover:bg-transparent; dark:disabled:hover:bg-transparent;
} }
.btn-contained-error { .btn-contained-error {
@apply border @apply border-transparent
border-transparent
bg-red-600 bg-red-600
hover:bg-red-700 hover:bg-red-700
text-white text-white
@ -239,7 +235,6 @@
.btn-outlined-error { .btn-outlined-error {
@apply bg-transparent @apply bg-transparent
border
text-red-500 text-red-500
border-red-200 border-red-200
hover:bg-red-100 hover:bg-red-100
@ -259,6 +254,7 @@
.btn-text-error { .btn-text-error {
@apply bg-transparent @apply bg-transparent
border-transparent
text-red-500 text-red-500
hover:text-red-700 hover:text-red-700
hover:bg-red-100 hover:bg-red-100
@ -268,13 +264,11 @@
dark:hover:text-red-500 dark:hover:text-red-500
dark:hover:bg-red-700/10 dark:hover:bg-red-700/10
dark:disabled:text-slate-600/75 dark:disabled:text-slate-600/75
dark:disabled:border-slate-600/75
dark:disabled:hover:bg-transparent; dark:disabled:hover:bg-transparent;
} }
.btn-contained-warning { .btn-contained-warning {
@apply border @apply border-transparent
border-transparent
bg-yellow-500 bg-yellow-500
hover:bg-yellow-600 hover:bg-yellow-600
text-white text-white
@ -308,6 +302,7 @@
.btn-text-warning { .btn-text-warning {
@apply bg-transparent @apply bg-transparent
border-transparent
text-yellow-500 text-yellow-500
hover:text-yellow-600 hover:text-yellow-600
hover:bg-yellow-50 hover:bg-yellow-50
@ -317,7 +312,6 @@
dark:hover:text-yellow-500 dark:hover:text-yellow-500
dark:hover:bg-yellow-700/10 dark:hover:bg-yellow-700/10
dark:disabled:text-slate-600/75 dark:disabled:text-slate-600/75
dark:disabled:border-slate-600/75
dark:disabled:hover:bg-transparent; dark:disabled:hover:bg-transparent;
} }
@ -379,6 +373,10 @@
height: 10px; /* Mostly for horizontal scrollbars */ height: 10px; /* Mostly for horizontal scrollbars */
} }
.styled-scrollbars::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
.styled-scrollbars::-webkit-scrollbar-thumb { .styled-scrollbars::-webkit-scrollbar-thumb {
/* Foreground */ /* Foreground */
background: var(--scrollbar-foreground); background: var(--scrollbar-foreground);
@ -389,6 +387,20 @@
background: var(--scrollbar-background); background: var(--scrollbar-background);
} }
.hide-scrollbar {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
.hide-tap {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
}
table { table {
tbody { tbody {
tr { tr {

View File

@ -249,6 +249,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
{...convertMuiTextFieldProps(props)} {...convertMuiTextFieldProps(props)}
inputRef={ref} inputRef={ref}
cursor="pointer" cursor="pointer"
inputClassName="truncate"
/> />
<NowButton <NowButton
key="mobile-date-now" key="mobile-date-now"
@ -282,6 +283,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
forSingleList={forSingleList} forSingleList={forSingleList}
cursor="pointer" cursor="pointer"
disabled={disabled} disabled={disabled}
wrapperClassName="!w-date-widget"
> >
<LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}> <LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}>
{dateTimePicker} {dateTimePicker}

View File

@ -350,7 +350,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) { if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
return ( return (
<div key="selection" className="flex flex-col gap-2 px-3 pt-2 pb-4"> <div key="selection" className="flex flex-col gap-2 px-3 pt-2 pb-4">
<div key="controls" className="flex gap-2"> <div key="controls" className="flex gap-2 flex-col xs:flex-row">
<Button <Button
buttonRef={uploadButtonRef} buttonRef={uploadButtonRef}
color="primary" color="primary"
@ -388,7 +388,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
)} )}
> >
{renderedImagesLinks} {renderedImagesLinks}
<div key="controls" className="flex gap-2"> <div key="controls" className="flex gap-2 flex-col xs:flex-row">
<Button <Button
buttonRef={uploadButtonRef} buttonRef={uploadButtonRef}
color="primary" color="primary"

View File

@ -364,7 +364,7 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = pro
<Menu <Menu
label={t('editor.editorWidgets.list.addType', { item: label })} label={t('editor.editorWidgets.list.addType', { item: label })}
variant="outlined" variant="outlined"
className="w-full z-20" buttonClassName="w-full z-20"
data-testid="list-type-add" data-testid="list-type-add"
disabled={disabled} disabled={disabled}
> >

View File

@ -1,5 +1,5 @@
import { MDXProvider } from '@mdx-js/react'; import { MDXProvider } from '@mdx-js/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { VFileMessage } from 'vfile-message'; import { VFileMessage } from 'vfile-message';
import { withMdxImage } from '@staticcms/core/components/common/image/Image'; import { withMdxImage } from '@staticcms/core/components/common/image/Image';
@ -11,20 +11,33 @@ import { processShortcodeConfigToMdx } from './plate/serialization/slate/process
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface'; import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
import type { UseMdxState } from './plate/hooks/useMdx';
interface FallbackComponentProps { interface MdxComponentProps {
error: string; state: UseMdxState;
} }
function FallbackComponent({ error }: FallbackComponentProps) { // Create a preview component that can handle errors with try-catch block; for catching invalid JS expressions errors that ErrorBoundary cannot catch.
const message = new VFileMessage(error); const MdxComponent: FC<MdxComponentProps> = ({ state }) => {
message.fatal = true; const Result = useMemo(() => state.file?.result as FC | undefined, [state]);
return (
<pre> if (!Result) {
<code>{String(message)}</code> return null;
</pre> }
);
} try {
return <Result key="result" />;
} catch (error) {
const message = new VFileMessage(String(error));
message.fatal = true;
return (
<pre key="error">
<code>{String(message)}</code>
</pre>
);
}
};
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => { const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => {
const { value, collection, field } = previewProps; const { value, collection, field } = previewProps;
@ -40,7 +53,7 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
); );
const [state, setValue] = useMdx(`editor-${id}.mdx`, value ?? ''); const [state, setValue] = useMdx(`editor-${id}.mdx`, value ?? '');
const [prevValue, setPrevValue] = useState(''); const [prevValue, setPrevValue] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (prevValue !== value) { if (prevValue !== value) {
const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? ''); const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? '');
@ -49,35 +62,13 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
} }
}, [prevValue, setValue, value]); }, [prevValue, setValue, value]);
// Create a preview component that can handle errors with try-catch block; for catching invalid JS expressions errors that ErrorBoundary cannot catch. return (
const MdxComponent = useCallback(() => { <div key="markdown-preview">
if (!state.file) { <MDXProvider components={components}>
return null; <MdxComponent state={state} />{' '}
} </MDXProvider>
</div>
try { );
return (state.file.result as FC)({});
} catch (error) {
return <FallbackComponent error={String(error)} />;
}
}, [state.file]);
return useMemo(() => {
if (!value) {
return null;
}
return (
<div>
{state.file && state.file.result ? (
<MDXProvider components={components}>
<MdxComponent />
</MDXProvider>
) : null}
</div>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [MdxComponent]);
}; };
export default MarkdownPreview; export default MarkdownPreview;

View File

@ -280,6 +280,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
...editableProps, ...editableProps,
onFocus, onFocus,
onBlur, onBlur,
className: '!outline-none',
}} }}
> >
<div key="editor-inner-wrapper" ref={innerEditorContainerRef}> <div key="editor-inner-wrapper" ref={innerEditorContainerRef}>

View File

@ -40,7 +40,7 @@ const ShortcodeToolbarButton: FC<ShortcodeToolbarButtonProps> = ({ disabled }) =
keepMounted keepMounted
hideDropdownIcon hideDropdownIcon
variant="text" variant="text"
className=" buttonClassName="
py-0.5 py-0.5
px-0.5 px-0.5
h-6 h-6

View File

@ -8,6 +8,7 @@ import { VFileMessage } from 'vfile-message';
import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback'; import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback';
import flattenListItemParagraphs from '../serialization/slate/flattenListItemParagraphs'; import flattenListItemParagraphs from '../serialization/slate/flattenListItemParagraphs';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
export interface UseMdxState { export interface UseMdxState {
file: VFile | null; file: VFile | null;
@ -49,10 +50,11 @@ export default function useMdx(
); );
const setValue = useDebouncedCallback(setValueCallback, 100); const setValue = useDebouncedCallback(setValueCallback, 100);
const debouncedState = useDebounce(state, 150);
useEffect(() => { useEffect(() => {
setValue(input); setValue(input);
}, [input, setValue]); }, [input, setValue]);
return [state, setValue]; return [debouncedState, setValue];
} }

View File

@ -64,7 +64,7 @@ export function getToolbarButtons(
keepMounted keepMounted
hideDropdownIcon hideDropdownIcon
variant="text" variant="text"
className=" buttonClassName="
py-0.5 py-0.5
px-0.5 px-0.5
h-6 h-6

View File

@ -322,7 +322,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
label={ label={
<> <>
{Array.isArray(selectedValue) && selectedValue.length > 0 ? ( {Array.isArray(selectedValue) && selectedValue.length > 0 ? (
<div className="flex flex-wrap gap-2 w-full p-2 pr-0 max-w-fit"> <div className="flex flex-wrap gap-2 p-2 pr-0 w-relation-widget-label">
{selectedValue.map(selectValue => { {selectedValue.map(selectValue => {
const option = uniqueOptionsByValue[selectValue]; const option = uniqueOptionsByValue[selectValue];
return ( return (

View File

@ -87,7 +87,14 @@ const UUIDControl: FC<WidgetControlProps<string, UUIDField>> = ({
) : null ) : null
} }
> >
<TextField type="text" inputRef={ref} value={internalValue} disabled={disabled} readonly /> <TextField
type="text"
inputRef={ref}
value={internalValue}
disabled={disabled}
readonly
inputClassName="truncate"
/>
</Field> </Field>
); );
}; };

View File

@ -4,13 +4,14 @@ module.exports = {
extend: { extend: {
height: { height: {
main: "calc(100vh - 64px)", main: "calc(100vh - 64px)",
"main-mobile": "calc(100vh - 128px)",
"media-library-dialog": "80vh", "media-library-dialog": "80vh",
"media-card": "240px", "media-card": "240px",
"media-preview-image": "104px", "media-preview-image": "104px",
"media-card-image": "196px", "media-card-image": "196px",
"image-card": "120px", "image-card": "120px",
input: "24px", input: "24px",
"table-full": "calc(100% - 40px)" "table-full": "calc(100% - 40px)",
}, },
minHeight: { minHeight: {
8: "2rem", 8: "2rem",
@ -26,12 +27,20 @@ module.exports = {
"media-card": "240px", "media-card": "240px",
"media-preview-image": "126px", "media-preview-image": "126px",
"image-card": "120px", "image-card": "120px",
"card-grid": "calc(100% + 8px)",
"breadcrumb-title-small": "calc(100vw - 126px)",
"breadcrumb-title": "calc(100vw * .4)",
"collection-header": "calc(100% - 32px)",
"date-widget": "calc(100% - 58px)",
"relation-widget-label": "calc(100% - 32px)",
"select-widget-label": "calc(100% - 12px)",
}, },
maxWidth: { maxWidth: {
"media-search": "400px", "media-search": "400px",
}, },
boxShadow: { boxShadow: {
sidebar: "0 10px 15px 18px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", sidebar: "0 10px 15px 18px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
"bottom-navigation": "0 -10px 10px -5px rgb(0 0 0 / 0.15)",
}, },
gridTemplateColumns: { gridTemplateColumns: {
editor: "450px auto", editor: "450px auto",
@ -42,5 +51,24 @@ module.exports = {
sans: ["Inter var", "Inter", "ui-sans-serif", "system-ui", "sans-serif"], sans: ["Inter var", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
}, },
}, },
screens: {
xs: "480px",
// => @media (min-width: 480px) { ... }
sm: "640px",
// => @media (min-width: 640px) { ... }
md: "768px",
// => @media (min-width: 768px) { ... }
lg: "1024px",
// => @media (min-width: 1024px) { ... }
xl: "1280px",
// => @media (min-width: 1280px) { ... }
"2xl": "1536px",
// => @media (min-width: 1536px) { ... }
},
}, },
}; };

1096
yarn.lock

File diff suppressed because it is too large Load Diff