feat: mobile support (#831)
This commit is contained in:
parent
7744df1103
commit
83c9d91b88
@ -870,7 +870,7 @@ collections:
|
||||
- value: 2
|
||||
label: Another fancy label
|
||||
- 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
|
||||
name: value_and_label_with_default
|
||||
widget: select
|
||||
|
@ -84,9 +84,9 @@
|
||||
"@styled-icons/material-rounded": "10.47.0",
|
||||
"@styled-icons/remix-editor": "10.46.0",
|
||||
"@styled-icons/simple-icons": "10.46.0",
|
||||
"@udecode/plate": "21.1.4",
|
||||
"@udecode/plate-juice": "21.0.0",
|
||||
"@udecode/plate-serializer-md": "21.0.0",
|
||||
"@udecode/plate": "21.3.2",
|
||||
"@udecode/plate-juice": "21.3.2",
|
||||
"@udecode/plate-serializer-md": "21.3.2",
|
||||
"@uiw/codemirror-extensions-langs": "4.19.16",
|
||||
"@uiw/react-codemirror": "4.19.16",
|
||||
"ajv": "8.12.0",
|
||||
@ -156,10 +156,10 @@
|
||||
"sanitize-filename": "1.6.3",
|
||||
"scheduler": "0.23.0",
|
||||
"semaphore": "1.1.0",
|
||||
"slate": "0.91.4",
|
||||
"slate-history": "0.86.0",
|
||||
"slate": "0.94.1",
|
||||
"slate-history": "0.93.0",
|
||||
"slate-hyperscript": "0.77.0",
|
||||
"slate-react": "0.91.10",
|
||||
"slate-react": "0.95.0",
|
||||
"stream-browserify": "3.0.0",
|
||||
"styled-components": "5.3.10",
|
||||
"symbol-observable": "4.0.0",
|
||||
@ -271,6 +271,7 @@
|
||||
"tailwindcss": "3.3.1",
|
||||
"to-string-loader": "1.2.0",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths-webpack-plugin": "4.0.1",
|
||||
"typescript": "5.0.4",
|
||||
"webpack": "5.80.0",
|
||||
|
@ -16,6 +16,7 @@ import addExtensions from './extensions';
|
||||
import { getPhrases } from './lib/phrases';
|
||||
import { selectLocale } from './reducers/selectors/config';
|
||||
import { store } from './store';
|
||||
import useMeta from './lib/hooks/useMeta';
|
||||
|
||||
import type { AnyAction } from '@reduxjs/toolkit';
|
||||
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;
|
||||
|
||||
const TranslatedApp = ({ locale, config }: AppRootProps) => {
|
||||
useMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' });
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
@ -2,11 +2,12 @@ import React from 'react';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import classNames from '../lib/util/classNames.util';
|
||||
import BottomNavigation from './navbar/BottomNavigation';
|
||||
import Navbar from './navbar/Navbar';
|
||||
import Sidebar from './navbar/Sidebar';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Breadcrumb } from '../interface';
|
||||
import type { Breadcrumb, Collection } from '../interface';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
@ -25,6 +26,7 @@ interface MainViewProps {
|
||||
noMargin?: boolean;
|
||||
noScroll?: boolean;
|
||||
children: ReactNode;
|
||||
collection?: Collection;
|
||||
}
|
||||
|
||||
const MainView = ({
|
||||
@ -35,6 +37,7 @@ const MainView = ({
|
||||
noMargin = false,
|
||||
noScroll = false,
|
||||
navbarActions,
|
||||
collection,
|
||||
}: MainViewProps) => {
|
||||
return (
|
||||
<>
|
||||
@ -48,11 +51,12 @@ const MainView = ({
|
||||
<div
|
||||
id="main-view"
|
||||
className={classNames(
|
||||
showLeftNav ? 'w-main left-64' : 'w-full',
|
||||
showLeftNav ? ' w-full left-0 md:w-main' : 'w-full',
|
||||
!noMargin && 'px-5 py-4',
|
||||
noScroll ? 'overflow-hidden' : 'overflow-y-auto',
|
||||
`
|
||||
h-main
|
||||
h-main-mobile
|
||||
md:h-main
|
||||
relative
|
||||
styled-scrollbars
|
||||
`,
|
||||
@ -61,6 +65,7 @@ const MainView = ({
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<BottomNavigation collection={collection} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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 GroupControl from './GroupControl';
|
||||
import MobileCollectionControls from './mobile/MobileCollectionControls';
|
||||
import SortControl from './SortControl';
|
||||
import ViewStyleControl from '../common/view-style/ViewStyleControl';
|
||||
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
import type {
|
||||
@ -12,7 +13,6 @@ import type {
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '@staticcms/core/interface';
|
||||
@ -41,34 +41,69 @@ const CollectionControls = ({
|
||||
viewGroups,
|
||||
onFilterClick,
|
||||
onGroupClick,
|
||||
t,
|
||||
filter,
|
||||
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 (
|
||||
<div className="flex gap-2 items-center relative z-20">
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{viewGroups && onGroupClick && group
|
||||
? viewGroups.length > 0 && (
|
||||
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
|
||||
)
|
||||
: null}
|
||||
{viewFilters && onFilterClick && filter
|
||||
? viewFilters.length > 0 && (
|
||||
<FilterControl
|
||||
viewFilters={viewFilters}
|
||||
onFilterClick={onFilterClick}
|
||||
t={t}
|
||||
filter={filter}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{sortableFields && onSortClick && sort
|
||||
? sortableFields.length > 0 && (
|
||||
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
items-center
|
||||
relative
|
||||
z-20
|
||||
w-full
|
||||
justify-end
|
||||
gap-1.5
|
||||
sm:w-auto
|
||||
sm:justify-normal
|
||||
lg:gap-2
|
||||
flex-[1_0_0%]
|
||||
"
|
||||
>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{showGroupControl || showFilterControl || showFilterControl ? (
|
||||
<MobileCollectionControls
|
||||
showFilterControl={showFilterControl}
|
||||
viewFilters={viewFilters}
|
||||
onFilterClick={onFilterClick}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -11,26 +11,25 @@ import {
|
||||
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
|
||||
import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate';
|
||||
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 { FC } from 'react';
|
||||
|
||||
interface CollectionHeaderProps {
|
||||
collection: Collection;
|
||||
newEntryUrl?: string;
|
||||
}
|
||||
|
||||
const CollectionHeader = ({
|
||||
collection,
|
||||
newEntryUrl,
|
||||
t,
|
||||
}: TranslatedProps<CollectionHeaderProps>) => {
|
||||
const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collection, t }) => {
|
||||
const collectionLabel = collection.label;
|
||||
const collectionLabelSingular = collection.label_singular;
|
||||
|
||||
const icon = useIcon(collection.icon);
|
||||
|
||||
const params = useParams();
|
||||
const filterTerm = useMemo(() => params['*'], [params]);
|
||||
const newEntryUrl = useNewEntryUrl(collection, filterTerm);
|
||||
|
||||
const icon = useIcon(collection.icon);
|
||||
|
||||
const entries = useEntries(collection);
|
||||
|
||||
const pluralLabel = useMemo(() => {
|
||||
@ -65,7 +64,18 @@ const CollectionHeader = ({
|
||||
|
||||
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
|
||||
className="
|
||||
text-xl
|
||||
@ -75,13 +85,25 @@ const CollectionHeader = ({
|
||||
text-gray-800
|
||||
dark:text-gray-300
|
||||
gap-2
|
||||
flex-grow
|
||||
w-full
|
||||
md:grow-0
|
||||
md:w-auto
|
||||
"
|
||||
>
|
||||
<div className="flex items-center">{icon}</div>
|
||||
{pluralLabel}
|
||||
<div
|
||||
className="
|
||||
w-collection-header
|
||||
flex-grow
|
||||
truncate
|
||||
"
|
||||
>
|
||||
{pluralLabel}
|
||||
</div>
|
||||
</h2>
|
||||
{newEntryUrl ? (
|
||||
<Button to={newEntryUrl}>
|
||||
<Button to={newEntryUrl} className="hidden md:flex">
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || pluralLabel,
|
||||
})}
|
||||
@ -92,4 +114,4 @@ const CollectionHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionHeader);
|
||||
export default translate()(CollectionHeader) as FC<CollectionHeaderProps>;
|
||||
|
@ -42,7 +42,14 @@ const SingleCollectionPage: FC<SingleCollectionPageProps> = ({
|
||||
const breadcrumbs = useBreadcrumbs(collection, filterTerm);
|
||||
|
||||
return (
|
||||
<MainView breadcrumbs={breadcrumbs} showQuickCreate showLeftNav noScroll noMargin>
|
||||
<MainView
|
||||
breadcrumbs={breadcrumbs}
|
||||
collection={collection}
|
||||
showQuickCreate
|
||||
showLeftNav
|
||||
noScroll
|
||||
noMargin
|
||||
>
|
||||
<CollectionView
|
||||
name={name}
|
||||
searchTerm={searchTerm}
|
||||
|
@ -136,6 +136,10 @@ const CollectionSearch = ({
|
||||
[submitSearch],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
@ -171,6 +175,7 @@ const CollectionSearch = ({
|
||||
onFocus={handleFocus}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
<PopperUnstyled
|
||||
@ -191,7 +196,7 @@ const CollectionSearch = ({
|
||||
ring-opacity-5
|
||||
focus:outline-none
|
||||
sm:text-sm
|
||||
z-40
|
||||
z-[1300]
|
||||
dark:bg-slate-700
|
||||
dark:shadow-lg
|
||||
"
|
||||
|
@ -9,13 +9,11 @@ import {
|
||||
sortByField as sortByFieldAction,
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
|
||||
import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
|
||||
import {
|
||||
selectSortableFields,
|
||||
selectViewFilters,
|
||||
selectViewGroups,
|
||||
} from '@staticcms/core/lib/util/collection.util';
|
||||
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
|
||||
import {
|
||||
selectEntriesFilter,
|
||||
selectEntriesGroup,
|
||||
@ -42,7 +40,6 @@ import type { ConnectedProps } from 'react-redux';
|
||||
const CollectionView = ({
|
||||
collection,
|
||||
collections,
|
||||
collectionName,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
@ -67,16 +64,6 @@ const CollectionView = ({
|
||||
setPrevCollection(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(
|
||||
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
|
||||
[isSingleSearchResult],
|
||||
@ -196,18 +183,18 @@ const CollectionView = ({
|
||||
const collectionDescription = collection?.description;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full px-5 pt-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex flex-col h-full px-5 pt-4 overflow-hidden">
|
||||
<div className="flex items-center mb-4 flex-row gap-4 sm:gap-0">
|
||||
{isSearchResults ? (
|
||||
<>
|
||||
<div className="flex-grow">
|
||||
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</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
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={changeViewStyle}
|
||||
@ -216,7 +203,6 @@ const CollectionView = ({
|
||||
sort={sort}
|
||||
viewFilters={viewFilters ?? []}
|
||||
viewGroups={viewGroups ?? []}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
@ -270,7 +256,6 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionV
|
||||
filterTerm,
|
||||
collection,
|
||||
collections,
|
||||
collectionName: name,
|
||||
sort,
|
||||
sortableFields,
|
||||
viewFilters,
|
||||
|
@ -6,19 +6,21 @@ import MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
|
||||
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { FC, MouseEvent } from 'react';
|
||||
|
||||
interface FilterControlProps {
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (viewFilter: ViewFilter) => void;
|
||||
export interface FilterControlProps {
|
||||
filter: Record<string, FilterMap> | undefined;
|
||||
viewFilters: ViewFilter[] | undefined;
|
||||
variant?: 'menu' | 'list';
|
||||
onFilterClick: ((viewFilter: ViewFilter) => void) | undefined;
|
||||
}
|
||||
|
||||
const FilterControl = ({
|
||||
viewFilters,
|
||||
t,
|
||||
filter = {},
|
||||
viewFilters = [],
|
||||
variant = 'menu',
|
||||
onFilterClick,
|
||||
filter,
|
||||
t,
|
||||
}: TranslatedProps<FilterControlProps>) => {
|
||||
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
|
||||
|
||||
@ -26,15 +28,65 @@ const FilterControl = ({
|
||||
(viewFilter: ViewFilter) => (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onFilterClick(viewFilter);
|
||||
onFilterClick?.(viewFilter);
|
||||
},
|
||||
[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 (
|
||||
<Menu
|
||||
key="filter-by-menu"
|
||||
label={t('collection.collectionTop.filterBy')}
|
||||
variant={anyActive ? 'contained' : 'outlined'}
|
||||
rootClassName="hidden lg:block"
|
||||
>
|
||||
<MenuGroup>
|
||||
{viewFilters.map(viewFilter => {
|
||||
@ -62,4 +114,4 @@ const FilterControl = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(FilterControl);
|
||||
export default translate()(FilterControl) as FC<FilterControlProps>;
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 Menu from '../common/menu/Menu';
|
||||
@ -7,31 +7,87 @@ import MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
|
||||
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
|
||||
import type { FC, MouseEvent } from 'react';
|
||||
|
||||
interface GroupControlProps {
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (viewGroup: ViewGroup) => void;
|
||||
export interface GroupControlProps {
|
||||
group: Record<string, GroupMap> | undefined;
|
||||
viewGroups: ViewGroup[] | undefined;
|
||||
variant?: 'menu' | 'list';
|
||||
onGroupClick: ((viewGroup: ViewGroup) => void) | undefined;
|
||||
}
|
||||
|
||||
const GroupControl = ({
|
||||
viewGroups,
|
||||
group,
|
||||
t,
|
||||
viewGroups = [],
|
||||
group = {},
|
||||
variant = 'menu',
|
||||
onGroupClick,
|
||||
t,
|
||||
}: TranslatedProps<GroupControlProps>) => {
|
||||
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 (
|
||||
<Menu
|
||||
label={t('collection.collectionTop.groupBy')}
|
||||
variant={activeGroup ? 'contained' : 'outlined'}
|
||||
rootClassName="hidden lg:block"
|
||||
>
|
||||
<MenuGroup>
|
||||
{viewGroups.map(viewGroup => (
|
||||
<MenuItemButton
|
||||
key={viewGroup.id}
|
||||
onClick={() => onGroupClick(viewGroup)}
|
||||
onClick={() => onGroupClick?.(viewGroup)}
|
||||
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
|
||||
>
|
||||
{viewGroup.label}
|
||||
@ -42,4 +98,4 @@ const GroupControl = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(GroupControl);
|
||||
export default translate()(GroupControl) as FC<GroupControlProps>;
|
||||
|
@ -11,6 +11,7 @@ import NavLink from '../navbar/NavLink';
|
||||
|
||||
import type { Collection, Entry } from '@staticcms/core/interface';
|
||||
import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
function getNodeTitle(node: TreeNodeData) {
|
||||
const title = node.isRoot
|
||||
@ -29,6 +30,20 @@ interface TreeNodeProps {
|
||||
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
|
||||
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);
|
||||
return (
|
||||
<>
|
||||
@ -50,7 +65,7 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
|
||||
<div className={classNames(depth !== 0 && 'ml-8')}>
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
onClick={() => handleClick(undefined, node, !node.expanded)}
|
||||
data-testid={node.path}
|
||||
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>
|
||||
{hasChildren && (
|
||||
<ChevronRightIcon
|
||||
onClick={event => handleClick(event, node, !node.expanded)}
|
||||
className={classNames(
|
||||
node.expanded && 'rotate-90 transform',
|
||||
`
|
||||
@ -135,7 +151,6 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
|
||||
const entries = useEntries(collection);
|
||||
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
|
||||
const [selected, setSelected] = useState<TreeNodeData | null>(null);
|
||||
const [useFilter, setUseFilter] = useState(true);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
|
||||
@ -186,22 +201,15 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
|
||||
|
||||
const onToggle = useCallback(
|
||||
({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => {
|
||||
if (!selected || selected.path === node.path || expanded) {
|
||||
setTreeData(
|
||||
updateNode(treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
})),
|
||||
);
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
} else {
|
||||
// don't collapse non selected nodes when clicked
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
}
|
||||
setTreeData(
|
||||
updateNode(treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
})),
|
||||
);
|
||||
setUseFilter(false);
|
||||
},
|
||||
[selected, treeData],
|
||||
[treeData],
|
||||
);
|
||||
|
||||
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
|
||||
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 {
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { FC, MouseEvent } from 'react';
|
||||
|
||||
function nextSortDirection(direction: SortDirection) {
|
||||
switch (direction) {
|
||||
@ -30,13 +31,20 @@ function nextSortDirection(direction: SortDirection) {
|
||||
}
|
||||
}
|
||||
|
||||
interface SortControlProps {
|
||||
fields: SortableField[];
|
||||
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
export interface SortControlProps {
|
||||
fields: SortableField[] | 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(() => {
|
||||
if (!sort) {
|
||||
return { key: undefined, direction: undefined };
|
||||
@ -50,10 +58,68 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
|
||||
return sortValues[0];
|
||||
}, [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 (
|
||||
<Menu
|
||||
label={t('collection.collectionTop.sortBy')}
|
||||
variant={selectedSort.key ? 'contained' : 'outlined'}
|
||||
rootClassName="hidden lg:block"
|
||||
>
|
||||
<MenuGroup>
|
||||
{fields.map(field => {
|
||||
@ -62,7 +128,7 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
|
||||
return (
|
||||
<MenuItemButton
|
||||
key={field.name}
|
||||
onClick={() => onSortClick(field.name, nextSortDir)}
|
||||
onClick={handleSortClick(field.name, nextSortDir)}
|
||||
active={field.name === selectedSort.key}
|
||||
endIcon={
|
||||
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>;
|
||||
|
@ -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);
|
||||
|
@ -6,10 +6,10 @@ import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/e
|
||||
import useEntries from '@staticcms/core/lib/hooks/useEntries';
|
||||
import useGroups from '@staticcms/core/lib/hooks/useGroups';
|
||||
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 { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
|
||||
import { useAppDispatch } from '@staticcms/core/store/hooks';
|
||||
import Button from '../../common/button/Button';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ViewStyle } from '@staticcms/core/constants/views';
|
||||
@ -104,39 +104,64 @@ const EntriesCollection = ({
|
||||
[collection, dispatch],
|
||||
);
|
||||
|
||||
const [selectedGroup, setSelectedGroup] = useState(0);
|
||||
const handleGroupClick = useCallback(
|
||||
(index: number) => () => {
|
||||
setSelectedGroup(index);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (groups && groups.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{groups.map((group, index) => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<div key={group.id} id={group.id}>
|
||||
<h2
|
||||
className={classNames(
|
||||
`
|
||||
px-2
|
||||
pt-4
|
||||
pb-2
|
||||
`,
|
||||
index === 0 && 'pt-0',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<Entries
|
||||
collection={collection}
|
||||
entries={getGroupEntries(filteredEntries, group.paths)}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.label}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
<div
|
||||
className="
|
||||
pb-3
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
-m-1
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
gap-2
|
||||
p-1
|
||||
overflow-x-auto
|
||||
hide-scrollbar
|
||||
"
|
||||
>
|
||||
{groups.map((group, index) => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={index === selectedGroup ? 'contained' : 'text'}
|
||||
onClick={handleGroupClick(index)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -113,52 +113,64 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
|
||||
|
||||
if (PreviewCardComponent) {
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea to={path}>
|
||||
<PreviewCardComponent
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
entry={entry}
|
||||
widgetFor={widgetFor}
|
||||
widgetsFor={widgetsFor}
|
||||
theme={theme}
|
||||
hasLocalBackup={hasLocalBackup}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
<div className="h-full w-full relative overflow-visible">
|
||||
<div className="absolute -inset-1 pr-2">
|
||||
<div className="p-1 h-full w-full">
|
||||
<Card>
|
||||
<CardActionArea to={path}>
|
||||
<PreviewCardComponent
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
entry={entry}
|
||||
widgetFor={widgetFor}
|
||||
widgetsFor={widgetsFor}
|
||||
theme={theme}
|
||||
hasLocalBackup={hasLocalBackup}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full" title={summary}>
|
||||
<CardActionArea to={path}>
|
||||
{image && imageField ? (
|
||||
<CardMedia
|
||||
height="140"
|
||||
image={image}
|
||||
collection={collection}
|
||||
field={imageField}
|
||||
entry={entry}
|
||||
/>
|
||||
) : null}
|
||||
<CardContent>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="whitespace-nowrap overflow-hidden text-ellipsis">{summary}</div>
|
||||
{hasLocalBackup ? (
|
||||
<InfoIcon
|
||||
className="
|
||||
w-5
|
||||
h-5
|
||||
text-blue-600
|
||||
dark:text-blue-300
|
||||
"
|
||||
title={t('ui.localBackup.hasLocalBackup')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
<div className="h-full w-full relative overflow-visible">
|
||||
<div className="absolute -inset-1 pr-2">
|
||||
<div className="p-1 h-full w-full">
|
||||
<Card className="h-full" title={summary}>
|
||||
<CardActionArea to={path}>
|
||||
{image && imageField ? (
|
||||
<CardMedia
|
||||
height="140"
|
||||
image={image}
|
||||
collection={collection}
|
||||
field={imageField}
|
||||
entry={entry}
|
||||
/>
|
||||
) : null}
|
||||
<CardContent>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="truncate">{summary}</div>
|
||||
{hasLocalBackup ? (
|
||||
<InfoIcon
|
||||
className="
|
||||
w-5
|
||||
h-5
|
||||
text-blue-600
|
||||
dark:text-blue-300
|
||||
"
|
||||
title={t('ui.localBackup.hasLocalBackup')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -47,7 +47,7 @@ const CardWrapper = ({
|
||||
? style.left ?? COLLECTION_CARD_MARGIN * columnIndex
|
||||
: style.left
|
||||
}`,
|
||||
),
|
||||
) + 4,
|
||||
[columnIndex, style.left],
|
||||
);
|
||||
|
||||
@ -138,12 +138,22 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
|
||||
}, [cardHeights, prevCardHeights.length]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div
|
||||
className="
|
||||
relative
|
||||
w-card-grid
|
||||
h-full
|
||||
overflow-hidden
|
||||
-ml-1
|
||||
"
|
||||
>
|
||||
<AutoSizer onResize={handleResize}>
|
||||
{({ height = 0, width = 0 }) => {
|
||||
const calculatedWidth = width - 4;
|
||||
const columnWidthWithGutter = COLLECTION_CARD_WIDTH + COLLECTION_CARD_MARGIN;
|
||||
const columnCount = Math.floor(width / columnWidthWithGutter);
|
||||
const nonGutterSpace = (width - COLLECTION_CARD_MARGIN * columnCount) / width;
|
||||
const columnCount = Math.max(Math.floor(calculatedWidth / columnWidthWithGutter), 1);
|
||||
const nonGutterSpace =
|
||||
(calculatedWidth - COLLECTION_CARD_MARGIN * columnCount) / calculatedWidth;
|
||||
const columnWidth = (1 / columnCount) * nonGutterSpace;
|
||||
|
||||
const rowCount = Math.ceil(entryData.length / columnCount);
|
||||
@ -157,7 +167,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
|
||||
`,
|
||||
)}
|
||||
style={{
|
||||
width,
|
||||
width: calculatedWidth,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
@ -165,8 +175,8 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
|
||||
columnCount={columnCount}
|
||||
columnWidth={index =>
|
||||
index + 1 === columnCount
|
||||
? width * columnWidth
|
||||
: width * columnWidth + COLLECTION_CARD_MARGIN
|
||||
? calculatedWidth * columnWidth
|
||||
: calculatedWidth * columnWidth + COLLECTION_CARD_MARGIN
|
||||
}
|
||||
rowCount={rowCount}
|
||||
rowHeight={index => {
|
||||
@ -189,7 +199,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
|
||||
|
||||
return rowHeight;
|
||||
}}
|
||||
width={width}
|
||||
width={calculatedWidth}
|
||||
height={height}
|
||||
itemData={
|
||||
{
|
||||
@ -203,7 +213,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
|
||||
onScroll={onScroll}
|
||||
className={classNames(
|
||||
`
|
||||
overflow-hidden
|
||||
!overflow-x-hidden
|
||||
overflow-y-auto
|
||||
styled-scrollbars
|
||||
`,
|
||||
|
@ -57,8 +57,8 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
|
||||
}, [handleScroll]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<div ref={gridContainerRef} className="relative h-full overflow-hidden">
|
||||
<div className="relative h-full flex-grow">
|
||||
<div ref={gridContainerRef} className="relative h-full">
|
||||
<EntryListingCardGrid
|
||||
key="grid"
|
||||
entryData={entryData}
|
||||
@ -71,14 +71,14 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
|
||||
<div
|
||||
key="loading"
|
||||
className="
|
||||
absolute
|
||||
inset-0
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-slate-50/50
|
||||
dark:bg-slate-900/50
|
||||
"
|
||||
absolute
|
||||
inset-0
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-slate-50/50
|
||||
dark:bg-slate-900/50
|
||||
"
|
||||
>
|
||||
{t('collection.entries.loadingEntries')}
|
||||
</div>
|
||||
|
@ -80,6 +80,7 @@ const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
|
||||
hover:bg-gray-200
|
||||
dark:hover:bg-slate-700/70
|
||||
"
|
||||
to={path}
|
||||
>
|
||||
{collectionLabel ? (
|
||||
<TableCell key="collectionLabel" to={path}>
|
||||
|
@ -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;
|
@ -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;
|
@ -63,7 +63,8 @@ const Autocomplete = function <T>(
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
items-center
|
||||
flex-col
|
||||
flex-start
|
||||
text-sm
|
||||
font-medium
|
||||
relative
|
||||
@ -90,8 +91,8 @@ const Autocomplete = function <T>(
|
||||
leading-5
|
||||
focus:ring-0
|
||||
outline-none
|
||||
basis-60
|
||||
flex-grow
|
||||
truncate
|
||||
`,
|
||||
disabled
|
||||
? `
|
||||
@ -194,7 +195,9 @@ const Autocomplete = function <T>(
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'block truncate',
|
||||
`
|
||||
block
|
||||
`,
|
||||
selected ? 'font-medium' : 'font-normal',
|
||||
)}
|
||||
>
|
||||
|
@ -4,13 +4,13 @@ import Button from './Button';
|
||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||
|
||||
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 }>;
|
||||
};
|
||||
|
||||
const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonProps) => {
|
||||
const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonLinkProps) => {
|
||||
return (
|
||||
<Button
|
||||
className={classNames(size === 'small' && 'px-0.5', size === 'medium' && 'px-1.5', className)}
|
||||
|
@ -22,6 +22,10 @@ const CardActionArea = ({ to, children }: CardActionAreaProps) => {
|
||||
justify-start
|
||||
hover:bg-gray-200
|
||||
dark:hover:bg-slate-700/70
|
||||
focus:outline-none
|
||||
focus:ring-4
|
||||
focus:ring-gray-200
|
||||
dark:focus:ring-slate-700
|
||||
"
|
||||
>
|
||||
{children}
|
||||
|
@ -52,7 +52,7 @@ const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) =>
|
||||
ref={inputRef}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
className="sr-only peer"
|
||||
className="sr-only peer hide-tap"
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onClick={handleNoop}
|
||||
|
@ -23,6 +23,8 @@ export interface FieldProps {
|
||||
disabled: boolean;
|
||||
disableClick?: boolean;
|
||||
endAdornment?: ReactNode;
|
||||
rootClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
const Field: FC<FieldProps> = ({
|
||||
@ -39,6 +41,8 @@ const Field: FC<FieldProps> = ({
|
||||
disabled,
|
||||
disableClick = false,
|
||||
endAdornment,
|
||||
rootClassName,
|
||||
wrapperClassName,
|
||||
}) => {
|
||||
const finalCursor = useCursor(cursor, disabled);
|
||||
|
||||
@ -104,6 +108,7 @@ const Field: FC<FieldProps> = ({
|
||||
focus-within:border-blue-800
|
||||
dark:focus-within:border-blue-100
|
||||
`,
|
||||
rootClassName,
|
||||
!noHightlight &&
|
||||
!disabled &&
|
||||
`
|
||||
@ -118,7 +123,7 @@ const Field: FC<FieldProps> = ({
|
||||
finalCursor === 'default' && 'cursor-default',
|
||||
!hasErrors && 'group/active',
|
||||
),
|
||||
[finalCursor, disabled, hasErrors, noHightlight, noPadding],
|
||||
[rootClassName, noHightlight, disabled, noPadding, finalCursor, hasErrors],
|
||||
);
|
||||
|
||||
const wrapperClassNames = useMemo(
|
||||
@ -129,9 +134,10 @@ const Field: FC<FieldProps> = ({
|
||||
flex-col
|
||||
w-full
|
||||
`,
|
||||
wrapperClassName,
|
||||
forSingleList && 'mr-14',
|
||||
),
|
||||
[forSingleList],
|
||||
[forSingleList, wrapperClassName],
|
||||
);
|
||||
|
||||
if (variant === 'inline') {
|
||||
|
@ -16,9 +16,14 @@ export interface MenuProps {
|
||||
color?: BaseBaseProps['color'];
|
||||
size?: BaseBaseProps['size'];
|
||||
rounded?: boolean | 'no-padding';
|
||||
className?: string;
|
||||
rootClassName?: string;
|
||||
iconClassName?: string;
|
||||
buttonClassName?: string;
|
||||
labelClassName?: string;
|
||||
children: ReactNode | ReactNode[];
|
||||
hideDropdownIcon?: boolean;
|
||||
hideDropdownIconOnMobile?: boolean;
|
||||
hideLabel?: boolean;
|
||||
keepMounted?: boolean;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
@ -31,9 +36,14 @@ const Menu = ({
|
||||
color = 'primary',
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
className,
|
||||
rootClassName,
|
||||
iconClassName,
|
||||
buttonClassName,
|
||||
labelClassName,
|
||||
children,
|
||||
hideDropdownIcon = false,
|
||||
hideDropdownIconOnMobile = false,
|
||||
hideLabel = false,
|
||||
keepMounted = false,
|
||||
disabled = false,
|
||||
'data-testid': dataTestId,
|
||||
@ -56,16 +66,16 @@ const Menu = ({
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const buttonClassName = useButtonClassNames(variant, color, size, rounded);
|
||||
const calculatedButtonClassName = useButtonClassNames(variant, color, size, rounded);
|
||||
|
||||
const menuButtonClassNames = useMemo(
|
||||
() => classNames(className, buttonClassName),
|
||||
[buttonClassName, className],
|
||||
() => classNames(calculatedButtonClassName, buttonClassName, 'whitespace-nowrap'),
|
||||
[calculatedButtonClassName, buttonClassName],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart" onClickAway={handleClose}>
|
||||
<div className="flex">
|
||||
<div className={classNames('flex', rootClassName)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleButtonClick}
|
||||
@ -76,10 +86,26 @@ const Menu = ({
|
||||
className={menuButtonClassNames}
|
||||
disabled={disabled}
|
||||
>
|
||||
{StartIcon ? <StartIcon className="-ml-0.5 mr-1.5 h-5 w-5" /> : null}
|
||||
{label}
|
||||
{StartIcon ? (
|
||||
<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 ? (
|
||||
<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}
|
||||
</button>
|
||||
<MenuUnstyled
|
||||
|
@ -61,6 +61,7 @@ const Pill: FC<PillProps> = ({
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
truncate
|
||||
`,
|
||||
noWrap && 'whitespace-nowrap',
|
||||
colorClassNames,
|
||||
|
@ -59,7 +59,6 @@ const Select = forwardRef(
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
const handleButtonClick = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(_event: MouseEvent | KeyboardEvent | FocusEvent | null, selectedValue: number | string) => {
|
||||
@ -89,8 +88,10 @@ const Select = forwardRef(
|
||||
<SelectUnstyled<any>
|
||||
renderValue={() => {
|
||||
return (
|
||||
<>
|
||||
{label ?? placeholder}
|
||||
<div className="w-full">
|
||||
<div className="flex flex-start w-select-widget-label">
|
||||
<span className="truncate">{label ?? placeholder}</span>
|
||||
</div>
|
||||
<span
|
||||
className="
|
||||
pointer-events-none
|
||||
@ -118,13 +119,12 @@ const Select = forwardRef(
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
root: {
|
||||
ref,
|
||||
onClick: handleButtonClick,
|
||||
className: classNames(
|
||||
`
|
||||
flex
|
||||
@ -160,11 +160,11 @@ const Select = forwardRef(
|
||||
ring-opacity-5
|
||||
focus:outline-none
|
||||
sm:text-sm
|
||||
z-50
|
||||
z-[100]
|
||||
dark:bg-slate-700
|
||||
dark:shadow-lg
|
||||
`,
|
||||
style: { width },
|
||||
style: { width: ref ? width : 'auto' },
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
|
@ -26,6 +26,7 @@ const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCell
|
||||
py-3
|
||||
whitespace-nowrap
|
||||
"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
@ -50,6 +51,8 @@ const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCell
|
||||
<div
|
||||
className="
|
||||
h-[44px]
|
||||
truncate
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{content}
|
||||
|
@ -35,6 +35,8 @@ const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
|
||||
dark:border-gray-700
|
||||
dark:bg-slate-800
|
||||
text-[14px]
|
||||
truncate
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{typeof children === 'string' && isEmpty(children) ? <> </> : children}
|
||||
|
@ -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 type { ReactNode } from 'react';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
|
||||
interface TableRowProps {
|
||||
children: ReactNode;
|
||||
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 (
|
||||
<tr
|
||||
className={classNames(
|
||||
@ -22,9 +38,14 @@ const TableRow = ({ children, className }: TableRowProps) => {
|
||||
hover:bg-slate-50
|
||||
dark:bg-slate-800
|
||||
dark:hover:bg-slate-700
|
||||
focus:outline-none
|
||||
focus:bg-gray-100
|
||||
focus:dark:bg-slate-700
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
tabIndex={to ? 0 : -1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
|
@ -19,6 +19,8 @@ export interface BaseTextFieldProps {
|
||||
placeholder?: string;
|
||||
endAdornment?: ReactNode;
|
||||
startAdornment?: ReactNode;
|
||||
rootClassName?: string;
|
||||
inputClassName?: string;
|
||||
}
|
||||
|
||||
export interface NumberTextFieldProps extends BaseTextFieldProps {
|
||||
@ -49,6 +51,8 @@ const TextField: FC<TextFieldProps> = ({
|
||||
onClick,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
rootClassName,
|
||||
inputClassName,
|
||||
...otherProps
|
||||
}) => {
|
||||
const finalCursor = useCursor(cursor, disabled);
|
||||
@ -67,10 +71,13 @@ const TextField: FC<TextFieldProps> = ({
|
||||
endAdornment={endAdornment}
|
||||
slotProps={{
|
||||
root: {
|
||||
className: `
|
||||
flex
|
||||
w-full
|
||||
`,
|
||||
className: classNames(
|
||||
`
|
||||
flex
|
||||
w-full
|
||||
`,
|
||||
rootClassName,
|
||||
),
|
||||
},
|
||||
input: {
|
||||
ref: inputRef,
|
||||
@ -79,6 +86,7 @@ const TextField: FC<TextFieldProps> = ({
|
||||
w-full
|
||||
text-sm
|
||||
`,
|
||||
inputClassName,
|
||||
variant === 'borderless' &&
|
||||
`
|
||||
h-6
|
||||
|
@ -15,7 +15,7 @@ interface ViewStyleControlPros {
|
||||
|
||||
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mr-1">
|
||||
<div className="flex items-center gap-1.5 lg:mr-1">
|
||||
<IconButton
|
||||
variant="text"
|
||||
className={classNames(viewStyle === VIEW_STYLE_TABLE && 'text-blue-500 dark:text-blue-500')}
|
||||
|
@ -22,6 +22,7 @@ export default function useWidgetsFor(
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
data: EntryData = entry.data,
|
||||
): {
|
||||
widgetFor: WidgetFor;
|
||||
widgetsFor: WidgetsFor;
|
||||
@ -35,9 +36,19 @@ export default function useWidgetsFor(
|
||||
if (!config) {
|
||||
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],
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -3,7 +3,7 @@ import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views';
|
||||
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 {
|
||||
getFileFromSlug,
|
||||
@ -169,6 +169,11 @@ const EditorInterface = ({
|
||||
|
||||
const collectHasI18n = hasI18n(collection);
|
||||
|
||||
const [showMobilePreview, setShowMobilePreview] = useState(false);
|
||||
const toggleMobilePreview = useCallback(() => {
|
||||
setShowMobilePreview(old => !old);
|
||||
}, []);
|
||||
|
||||
const editor = (
|
||||
<div
|
||||
key={defaultLocale}
|
||||
@ -181,7 +186,13 @@ const EditorInterface = ({
|
||||
`
|
||||
overflow-y-auto
|
||||
styled-scrollbars
|
||||
h-main
|
||||
h-main-mobile
|
||||
md:h-main
|
||||
`,
|
||||
showMobilePreview &&
|
||||
`
|
||||
hidden
|
||||
lg:block
|
||||
`,
|
||||
)}
|
||||
>
|
||||
@ -204,10 +215,12 @@ const EditorInterface = ({
|
||||
<div
|
||||
key={selectedLocale}
|
||||
className="
|
||||
flex
|
||||
w-full
|
||||
overflow-y-auto
|
||||
styled-scrollbars
|
||||
h-main
|
||||
h-main-mobile
|
||||
md:h-main
|
||||
"
|
||||
>
|
||||
<EditorControlPane
|
||||
@ -227,9 +240,36 @@ const EditorInterface = ({
|
||||
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
|
||||
);
|
||||
|
||||
const previewEntry = collectHasI18n
|
||||
? getPreviewEntry(entry, selectedLocale[0], defaultLocale)
|
||||
: entry;
|
||||
const mobileLocaleEditor = useMemo(
|
||||
() => (
|
||||
<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 = (
|
||||
<div
|
||||
@ -238,27 +278,31 @@ const EditorInterface = ({
|
||||
grid
|
||||
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>
|
||||
<EditorPreviewPane
|
||||
collection={collection}
|
||||
previewInFrame={previewInFrame}
|
||||
entry={previewEntry}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
editorSize={editorSize}
|
||||
showMobilePreview={showMobilePreview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorSideBySideLocale = (
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>
|
||||
<>{editorLocale}</>
|
||||
</ScrollSyncPane>
|
||||
</div>
|
||||
<>
|
||||
<div className="grid-cols-2 h-full hidden lg:grid">
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>
|
||||
<>{editorLocale}</>
|
||||
</ScrollSyncPane>
|
||||
</div>
|
||||
{mobileLocaleEditor}
|
||||
</>
|
||||
);
|
||||
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
@ -296,6 +340,9 @@ const EditorInterface = ({
|
||||
toggleScrollSync={handleToggleScrollSync}
|
||||
toggleI18n={handleToggleI18n}
|
||||
slug={slug}
|
||||
showMobilePreview={showMobilePreview}
|
||||
onMobilePreviewToggle={toggleMobilePreview}
|
||||
className="flex"
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -11,6 +11,7 @@ import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
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 { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
|
||||
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 MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
import IconButton from '../common/button/IconButton';
|
||||
|
||||
import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
@ -44,6 +46,9 @@ export interface EditorToolbarProps {
|
||||
toggleScrollSync: MouseEventHandler;
|
||||
toggleI18n: MouseEventHandler;
|
||||
slug?: string | undefined;
|
||||
className?: string;
|
||||
showMobilePreview: boolean;
|
||||
onMobilePreviewToggle: () => void;
|
||||
}
|
||||
|
||||
const EditorToolbar = ({
|
||||
@ -67,6 +72,9 @@ const EditorToolbar = ({
|
||||
toggleScrollSync,
|
||||
toggleI18n,
|
||||
slug,
|
||||
className,
|
||||
showMobilePreview,
|
||||
onMobilePreviewToggle,
|
||||
}: TranslatedProps<EditorToolbarProps>) => {
|
||||
const canCreate = useMemo(
|
||||
() => ('folder' in collection && collection.create) ?? false,
|
||||
@ -164,13 +172,22 @@ const EditorToolbar = ({
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
flex
|
||||
gap-2
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showI18nToggle || showPreviewToggle || canDelete ? (
|
||||
<Menu
|
||||
key="extra-menu"
|
||||
label={<MoreVertIcon className="w-5 h-5" />}
|
||||
variant="text"
|
||||
className="px-1.5"
|
||||
rootClassName="hidden lg:flex"
|
||||
buttonClassName="px-1.5"
|
||||
hideDropdownIcon
|
||||
>
|
||||
<MenuGroup>
|
||||
@ -215,12 +232,39 @@ const EditorToolbar = ({
|
||||
) : null}
|
||||
</Menu>
|
||||
) : 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
|
||||
label={
|
||||
isPublished ? t('editor.editorToolbar.published') : t('editor.editorToolbar.publish')
|
||||
}
|
||||
color={isPublished ? 'success' : 'primary'}
|
||||
disabled={isLoading || (menuItems.length == 1 && menuItems[0].length === 0)}
|
||||
startIcon={PublishIcon}
|
||||
iconClassName="flex !md:hidden"
|
||||
labelClassName="hidden md:block"
|
||||
hideDropdownIconOnMobile
|
||||
>
|
||||
{menuItems.map((group, index) => (
|
||||
<MenuGroup key={`menu-group-${index}`}>{group}</MenuGroup>
|
||||
@ -229,6 +273,7 @@ const EditorToolbar = ({
|
||||
</div>
|
||||
),
|
||||
[
|
||||
className,
|
||||
showI18nToggle,
|
||||
showPreviewToggle,
|
||||
canDelete,
|
||||
@ -241,6 +286,8 @@ const EditorToolbar = ({
|
||||
toggleScrollSync,
|
||||
scrollSyncActive,
|
||||
onDelete,
|
||||
showMobilePreview,
|
||||
onMobilePreviewToggle,
|
||||
isPublished,
|
||||
menuItems,
|
||||
],
|
||||
|
@ -30,6 +30,7 @@ export interface EditorControlPaneProps {
|
||||
hideBorder: boolean;
|
||||
slug?: string;
|
||||
onLocaleChange?: (locale: string) => void;
|
||||
allowDefaultLocale?: boolean;
|
||||
}
|
||||
|
||||
const EditorControlPane = ({
|
||||
@ -43,6 +44,7 @@ const EditorControlPane = ({
|
||||
hideBorder,
|
||||
slug,
|
||||
onLocaleChange,
|
||||
allowDefaultLocale = false,
|
||||
t,
|
||||
}: TranslatedProps<EditorControlPaneProps>) => {
|
||||
const pathField = useMemo(
|
||||
@ -95,12 +97,13 @@ const EditorControlPane = ({
|
||||
flex
|
||||
flex-col
|
||||
min-h-full
|
||||
w-full
|
||||
`,
|
||||
!hideBorder &&
|
||||
`
|
||||
border-r
|
||||
border-slate-400
|
||||
`,
|
||||
lg:border-r
|
||||
border-slate-400
|
||||
`,
|
||||
)}
|
||||
>
|
||||
{i18n?.locales && locale ? (
|
||||
@ -117,6 +120,7 @@ const EditorControlPane = ({
|
||||
})}
|
||||
canChangeLocale={canChangeLocale}
|
||||
onLocaleChange={onLocaleChange}
|
||||
allowDefaultLocale={allowDefaultLocale}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -9,6 +9,7 @@ interface LocaleDropdownProps {
|
||||
defaultLocale: string;
|
||||
dropdownText: string;
|
||||
canChangeLocale: boolean;
|
||||
allowDefaultLocale: boolean;
|
||||
onLocaleChange?: (locale: string) => void;
|
||||
}
|
||||
|
||||
@ -17,6 +18,7 @@ const LocaleDropdown = ({
|
||||
defaultLocale,
|
||||
dropdownText,
|
||||
canChangeLocale,
|
||||
allowDefaultLocale,
|
||||
onLocaleChange,
|
||||
}: LocaleDropdownProps) => {
|
||||
if (!canChangeLocale) {
|
||||
@ -39,7 +41,7 @@ const LocaleDropdown = ({
|
||||
<Menu label={dropdownText}>
|
||||
<MenuGroup>
|
||||
{locales
|
||||
.filter(locale => locale !== defaultLocale)
|
||||
.filter(locale => allowDefaultLocale || locale !== defaultLocale)
|
||||
.map(locale => (
|
||||
<MenuItemButton key={locale} onClick={() => onLocaleChange?.(locale)}>
|
||||
{locale}
|
||||
|
@ -90,6 +90,10 @@ const FrameGlobalStyles = `
|
||||
height: 10px; /* Mostly for horizontal scrollbars */
|
||||
}
|
||||
|
||||
.styled-scrollbars::-webkit-scrollbar-corner {
|
||||
background: rgba(0,0,0,0);
|
||||
}
|
||||
|
||||
.styled-scrollbars::-webkit-scrollbar-thumb {
|
||||
/* Foreground */
|
||||
background: var(--scrollbar-foreground);
|
||||
@ -107,10 +111,11 @@ export interface EditorPreviewPaneProps {
|
||||
entry: Entry;
|
||||
previewInFrame: boolean;
|
||||
editorSize: EditorSize;
|
||||
showMobilePreview: boolean;
|
||||
}
|
||||
|
||||
const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
||||
const { editorSize, entry, collection, fields, previewInFrame, t } = props;
|
||||
const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
||||
const { editorSize, entry, collection, fields, previewInFrame, showMobilePreview, t } = props;
|
||||
|
||||
const config = useAppSelector(selectConfig);
|
||||
|
||||
@ -175,62 +180,67 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
h-main
|
||||
h-main-mobile
|
||||
md:h-main
|
||||
absolute
|
||||
top-16
|
||||
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}>
|
||||
{previewInFrame ? (
|
||||
<Frame
|
||||
key="preview-frame"
|
||||
<ErrorBoundary config={config}>
|
||||
{previewInFrame ? (
|
||||
<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"
|
||||
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"
|
||||
className="
|
||||
className="
|
||||
overflow-y-auto
|
||||
styled-scrollbars
|
||||
h-full
|
||||
"
|
||||
>
|
||||
{!collection ? (
|
||||
t('collection.notFound')
|
||||
) : (
|
||||
<>
|
||||
{previewStyles}
|
||||
<EditorPreviewContent
|
||||
key="preview-wrapper-content"
|
||||
previewComponent={previewComponent}
|
||||
previewProps={{ ...previewProps, document, window }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollSyncPane>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
>
|
||||
{!collection ? (
|
||||
t('collection.notFound')
|
||||
) : (
|
||||
<>
|
||||
{previewStyles}
|
||||
<EditorPreviewContent
|
||||
key="preview-wrapper-content"
|
||||
previewComponent={previewComponent}
|
||||
previewProps={{ ...previewProps, document, window }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollSyncPane>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>,
|
||||
element,
|
||||
'preview-content',
|
||||
@ -240,14 +250,14 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
||||
config,
|
||||
editorSize,
|
||||
element,
|
||||
entry,
|
||||
initialFrameContent,
|
||||
previewComponent,
|
||||
previewInFrame,
|
||||
previewProps,
|
||||
previewStyles,
|
||||
showMobilePreview,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
||||
export default translate()(PreviewPane) as FC<EditorPreviewPaneProps>;
|
||||
export default translate()(EditorPreviewPane) as FC<EditorPreviewPaneProps>;
|
||||
|
80
packages/core/src/components/navbar/BottomNavigation.tsx
Normal file
80
packages/core/src/components/navbar/BottomNavigation.tsx
Normal 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;
|
142
packages/core/src/components/navbar/Breadcrumbs.tsx
Normal file
142
packages/core/src/components/navbar/Breadcrumbs.tsx
Normal 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}`}>></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;
|
@ -1,18 +1,19 @@
|
||||
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 { Link } from 'react-router-dom';
|
||||
|
||||
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 { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import Button from '../common/button/Button';
|
||||
import { StaticCmsIcon } from '../images/_index';
|
||||
import Breadcrumbs from './Breadcrumbs';
|
||||
import QuickCreate from './QuickCreate';
|
||||
import SettingsDropdown from './SettingsDropdown';
|
||||
|
||||
import type { Breadcrumb, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
export interface NavbarProps {
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
@ -40,6 +41,11 @@ const Navbar = ({
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const inEditor = useMemo(
|
||||
() => Boolean(breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1].editor),
|
||||
[breadcrumbs],
|
||||
);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="
|
||||
@ -52,49 +58,56 @@ const Navbar = ({
|
||||
"
|
||||
>
|
||||
<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 className="flex flex-1 items-center justify-center h-full sm:items-stretch sm:justify-start gap-4">
|
||||
<div className="flex flex-shrink-0 items-center justify-center bg-slate-500 dark:bg-slate-700 w-16">
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
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 ? (
|
||||
<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}')` }}
|
||||
/>
|
||||
) : (
|
||||
<StaticCmsIcon className="inline-flex 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}`}>></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,
|
||||
<StaticCmsIcon className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} inEditor={inEditor} />
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
{displayUrl ? (
|
||||
<Button variant="text" className="flex gap-2" href={displayUrl}>
|
||||
{displayUrl}
|
||||
<Button variant="text" className="gap-2 hidden lg:flex" href={displayUrl}>
|
||||
<div className="hidden lg:flex">{displayUrl}</div>
|
||||
<OpenInNewIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</Button>
|
||||
) : null}
|
||||
{showQuickCreate ? <QuickCreate key="quick-create" /> : null}
|
||||
{showQuickCreate ? (
|
||||
<QuickCreate key="quick-create" rootClassName="hidden md:block" />
|
||||
) : null}
|
||||
{navbarActions}
|
||||
<SettingsDropdown />
|
||||
<SettingsDropdown inEditor={inEditor} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -102,4 +115,4 @@ const Navbar = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Navbar) as ComponentType<NavbarProps>;
|
||||
export default translate()(Navbar) as FC<NavbarProps>;
|
||||
|
56
packages/core/src/components/navbar/NavigationDrawer.tsx
Normal file
56
packages/core/src/components/navbar/NavigationDrawer.tsx
Normal 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;
|
@ -6,12 +6,19 @@ import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
|
||||
import { selectCollections } from '@staticcms/core/reducers/selectors/collections';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import Menu from '../common/menu/Menu';
|
||||
import MenuItemLink from '../common/menu/MenuItemLink';
|
||||
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 createableCollections = useMemo(
|
||||
@ -23,7 +30,7 @@ const QuickCreate = ({ t }: TranslateProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu label={t('app.header.quickAdd')} startIcon={AddIcon}>
|
||||
<Menu label={t('app.header.quickAdd')} startIcon={AddIcon} {...menuProps}>
|
||||
<MenuGroup>
|
||||
{createableCollections.map(collection => (
|
||||
<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>;
|
||||
|
@ -13,8 +13,8 @@ import MenuGroup from '../common/menu/MenuGroup';
|
||||
import MenuItemButton from '../common/menu/MenuItemButton';
|
||||
import Switch from '../common/switch/Switch';
|
||||
|
||||
import type { ChangeEvent, MouseEvent } from 'react';
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { ChangeEvent, FC, MouseEvent } from 'react';
|
||||
|
||||
interface AvatarImageProps {
|
||||
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 user = useAppSelector(selectUser);
|
||||
const [isDarkMode, setIsDarkMode] = useState(document.documentElement.classList.contains('dark'));
|
||||
@ -84,6 +88,7 @@ const SettingsDropdown = ({ t }: TranslateProps) => {
|
||||
variant="outlined"
|
||||
rounded={!user?.avatar_url || 'no-padding'}
|
||||
hideDropdownIcon
|
||||
rootClassName={inEditor ? 'hidden md:flex' : ''}
|
||||
>
|
||||
<MenuGroup>
|
||||
<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>;
|
||||
|
@ -1,139 +1,19 @@
|
||||
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 React from 'react';
|
||||
|
||||
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 { 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 SidebarContent from './SidebarContent';
|
||||
|
||||
import type { Collection } from '@staticcms/core/interface';
|
||||
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 (
|
||||
<aside
|
||||
className={classNames(
|
||||
'w-sidebar-expanded',
|
||||
'h-main sm:fixed sm:z-20 sm:shadow-sidebar lg:block lg:z-auto lg:shadow-none',
|
||||
)}
|
||||
className={classNames('w-sidebar-expanded', 'h-main-mobile md:h-main hidden md:block')}
|
||||
aria-label="Sidebar"
|
||||
>
|
||||
<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>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Sidebar) as FC;
|
||||
export default Sidebar;
|
||||
|
128
packages/core/src/components/navbar/SidebarContent.tsx
Normal file
128
packages/core/src/components/navbar/SidebarContent.tsx
Normal 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;
|
@ -72,6 +72,9 @@ export const INFERABLE_FIELDS: Record<string, InferredField> = {
|
||||
'cover',
|
||||
'hero',
|
||||
'logo',
|
||||
'cover_image',
|
||||
'cover-image',
|
||||
'coverimage',
|
||||
],
|
||||
defaultPreview: value => value,
|
||||
fallbackToFirstField: false,
|
||||
|
@ -1103,6 +1103,7 @@ export interface SvgProps {
|
||||
export interface Breadcrumb {
|
||||
name?: string;
|
||||
to?: string;
|
||||
editor?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaLibraryDisplayURL {
|
||||
|
@ -83,6 +83,7 @@ export default function useBreadcrumbs(
|
||||
collectionLabel: collection.label_singular || collection.label,
|
||||
})
|
||||
: summary,
|
||||
editor: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
25
packages/core/src/lib/hooks/useMeta.ts
Normal file
25
packages/core/src/lib/hooks/useMeta.ts
Normal 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]);
|
||||
}
|
21
packages/core/src/lib/hooks/useNewEntryUrl.ts
Normal file
21
packages/core/src/lib/hooks/useNewEntryUrl.ts
Normal 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]);
|
||||
}
|
@ -69,8 +69,7 @@ export function getLocaleDataPath(locale: string) {
|
||||
}
|
||||
|
||||
export function getDataPath(locale: string, defaultLocale: string) {
|
||||
const dataPath = locale !== defaultLocale ? getLocaleDataPath(locale) : ['data'];
|
||||
return dataPath;
|
||||
return locale !== defaultLocale ? getLocaleDataPath(locale) : ['data'];
|
||||
}
|
||||
|
||||
export function getFilePath(
|
||||
|
@ -8,13 +8,15 @@
|
||||
|
||||
@layer components {
|
||||
html {
|
||||
@apply overflow-hidden;
|
||||
@apply overflow-hidden
|
||||
w-screen;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-gray-800
|
||||
dark:text-gray-100
|
||||
overflow-hidden;
|
||||
overflow-hidden
|
||||
w-screen;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -29,7 +31,8 @@
|
||||
focus:outline-none
|
||||
focus:ring-4
|
||||
focus:ring-gray-200
|
||||
dark:focus:ring-slate-700;
|
||||
dark:focus:ring-slate-700
|
||||
border;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@ -64,8 +67,7 @@
|
||||
}
|
||||
|
||||
.btn-contained-primary {
|
||||
@apply border
|
||||
border-transparent
|
||||
@apply border-transparent
|
||||
bg-blue-700
|
||||
hover:bg-blue-800
|
||||
text-white
|
||||
@ -80,7 +82,6 @@
|
||||
.btn-outlined-primary {
|
||||
@apply text-gray-800
|
||||
bg-transparent
|
||||
border
|
||||
border-gray-200
|
||||
hover:bg-slate-100
|
||||
hover:border-blue-400
|
||||
@ -101,6 +102,7 @@
|
||||
|
||||
.btn-text-primary {
|
||||
@apply bg-transparent
|
||||
border-transparent
|
||||
text-gray-800
|
||||
hover:text-blue-700
|
||||
hover:bg-blue-100
|
||||
@ -110,13 +112,11 @@
|
||||
dark:hover:text-blue-400
|
||||
dark:hover:bg-slate-700/75
|
||||
dark:disabled:text-slate-600/75
|
||||
dark:disabled:border-slate-600/75
|
||||
dark:disabled:hover:bg-transparent;
|
||||
}
|
||||
|
||||
.btn-contained-secondary {
|
||||
@apply border
|
||||
font-medium
|
||||
@apply font-medium
|
||||
text-gray-600
|
||||
bg-white
|
||||
border-gray-200/75
|
||||
@ -140,7 +140,6 @@
|
||||
.btn-outlined-secondary {
|
||||
@apply text-gray-800
|
||||
bg-transparent
|
||||
border
|
||||
border-gray-200
|
||||
hover:bg-gray-100
|
||||
hover:text-gray-800
|
||||
@ -161,6 +160,7 @@
|
||||
|
||||
.btn-text-secondary {
|
||||
@apply bg-transparent
|
||||
border-transparent
|
||||
text-gray-800
|
||||
hover:text-gray-800
|
||||
hover:bg-gray-100
|
||||
@ -170,13 +170,11 @@
|
||||
dark:hover:text-white
|
||||
dark:hover:bg-gray-700
|
||||
dark:disabled:text-gray-400/20
|
||||
dark:disabled:border-gray-600/20
|
||||
dark:disabled:hover:bg-transparent;
|
||||
}
|
||||
|
||||
.btn-contained-success {
|
||||
@apply border
|
||||
border-transparent
|
||||
@apply border-transparent
|
||||
bg-green-600
|
||||
hover:bg-green-700
|
||||
text-white
|
||||
@ -190,7 +188,6 @@
|
||||
|
||||
.btn-outlined-success {
|
||||
@apply bg-transparent
|
||||
border
|
||||
text-green-500
|
||||
border-green-200
|
||||
hover:bg-green-100
|
||||
@ -210,6 +207,7 @@
|
||||
|
||||
.btn-text-success {
|
||||
@apply bg-transparent
|
||||
border-transparent
|
||||
text-green-500
|
||||
hover:text-green-700
|
||||
hover:bg-green-100
|
||||
@ -219,13 +217,11 @@
|
||||
dark:hover:text-green-500
|
||||
dark:hover:bg-green-700/10
|
||||
dark:disabled:text-slate-600/75
|
||||
dark:disabled:border-slate-600/75
|
||||
dark:disabled:hover:bg-transparent;
|
||||
}
|
||||
|
||||
.btn-contained-error {
|
||||
@apply border
|
||||
border-transparent
|
||||
@apply border-transparent
|
||||
bg-red-600
|
||||
hover:bg-red-700
|
||||
text-white
|
||||
@ -239,7 +235,6 @@
|
||||
|
||||
.btn-outlined-error {
|
||||
@apply bg-transparent
|
||||
border
|
||||
text-red-500
|
||||
border-red-200
|
||||
hover:bg-red-100
|
||||
@ -259,6 +254,7 @@
|
||||
|
||||
.btn-text-error {
|
||||
@apply bg-transparent
|
||||
border-transparent
|
||||
text-red-500
|
||||
hover:text-red-700
|
||||
hover:bg-red-100
|
||||
@ -268,13 +264,11 @@
|
||||
dark:hover:text-red-500
|
||||
dark:hover:bg-red-700/10
|
||||
dark:disabled:text-slate-600/75
|
||||
dark:disabled:border-slate-600/75
|
||||
dark:disabled:hover:bg-transparent;
|
||||
}
|
||||
|
||||
.btn-contained-warning {
|
||||
@apply border
|
||||
border-transparent
|
||||
@apply border-transparent
|
||||
bg-yellow-500
|
||||
hover:bg-yellow-600
|
||||
text-white
|
||||
@ -308,6 +302,7 @@
|
||||
|
||||
.btn-text-warning {
|
||||
@apply bg-transparent
|
||||
border-transparent
|
||||
text-yellow-500
|
||||
hover:text-yellow-600
|
||||
hover:bg-yellow-50
|
||||
@ -317,7 +312,6 @@
|
||||
dark:hover:text-yellow-500
|
||||
dark:hover:bg-yellow-700/10
|
||||
dark:disabled:text-slate-600/75
|
||||
dark:disabled:border-slate-600/75
|
||||
dark:disabled:hover:bg-transparent;
|
||||
}
|
||||
|
||||
@ -379,6 +373,10 @@
|
||||
height: 10px; /* Mostly for horizontal scrollbars */
|
||||
}
|
||||
|
||||
.styled-scrollbars::-webkit-scrollbar-corner {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.styled-scrollbars::-webkit-scrollbar-thumb {
|
||||
/* Foreground */
|
||||
background: var(--scrollbar-foreground);
|
||||
@ -389,6 +387,20 @@
|
||||
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 {
|
||||
tbody {
|
||||
tr {
|
||||
|
@ -249,6 +249,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
|
||||
{...convertMuiTextFieldProps(props)}
|
||||
inputRef={ref}
|
||||
cursor="pointer"
|
||||
inputClassName="truncate"
|
||||
/>
|
||||
<NowButton
|
||||
key="mobile-date-now"
|
||||
@ -282,6 +283,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
|
||||
forSingleList={forSingleList}
|
||||
cursor="pointer"
|
||||
disabled={disabled}
|
||||
wrapperClassName="!w-date-widget"
|
||||
>
|
||||
<LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}>
|
||||
{dateTimePicker}
|
||||
|
@ -350,7 +350,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
|
||||
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
|
||||
return (
|
||||
<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
|
||||
buttonRef={uploadButtonRef}
|
||||
color="primary"
|
||||
@ -388,7 +388,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
|
||||
)}
|
||||
>
|
||||
{renderedImagesLinks}
|
||||
<div key="controls" className="flex gap-2">
|
||||
<div key="controls" className="flex gap-2 flex-col xs:flex-row">
|
||||
<Button
|
||||
buttonRef={uploadButtonRef}
|
||||
color="primary"
|
||||
|
@ -364,7 +364,7 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = pro
|
||||
<Menu
|
||||
label={t('editor.editorWidgets.list.addType', { item: label })}
|
||||
variant="outlined"
|
||||
className="w-full z-20"
|
||||
buttonClassName="w-full z-20"
|
||||
data-testid="list-type-add"
|
||||
disabled={disabled}
|
||||
>
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { 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 { FC } from 'react';
|
||||
import type { UseMdxState } from './plate/hooks/useMdx';
|
||||
|
||||
interface FallbackComponentProps {
|
||||
error: string;
|
||||
interface MdxComponentProps {
|
||||
state: UseMdxState;
|
||||
}
|
||||
|
||||
function FallbackComponent({ error }: FallbackComponentProps) {
|
||||
const message = new VFileMessage(error);
|
||||
message.fatal = true;
|
||||
return (
|
||||
<pre>
|
||||
<code>{String(message)}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
// Create a preview component that can handle errors with try-catch block; for catching invalid JS expressions errors that ErrorBoundary cannot catch.
|
||||
const MdxComponent: FC<MdxComponentProps> = ({ state }) => {
|
||||
const Result = useMemo(() => state.file?.result as FC | undefined, [state]);
|
||||
|
||||
if (!Result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 { value, collection, field } = previewProps;
|
||||
@ -40,7 +53,7 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
|
||||
);
|
||||
|
||||
const [state, setValue] = useMdx(`editor-${id}.mdx`, value ?? '');
|
||||
const [prevValue, setPrevValue] = useState('');
|
||||
const [prevValue, setPrevValue] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (prevValue !== value) {
|
||||
const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? '');
|
||||
@ -49,35 +62,13 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
|
||||
}
|
||||
}, [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.
|
||||
const MdxComponent = useCallback(() => {
|
||||
if (!state.file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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]);
|
||||
return (
|
||||
<div key="markdown-preview">
|
||||
<MDXProvider components={components}>
|
||||
<MdxComponent state={state} />{' '}
|
||||
</MDXProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
||||
|
@ -280,6 +280,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
|
||||
...editableProps,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className: '!outline-none',
|
||||
}}
|
||||
>
|
||||
<div key="editor-inner-wrapper" ref={innerEditorContainerRef}>
|
||||
|
@ -40,7 +40,7 @@ const ShortcodeToolbarButton: FC<ShortcodeToolbarButtonProps> = ({ disabled }) =
|
||||
keepMounted
|
||||
hideDropdownIcon
|
||||
variant="text"
|
||||
className="
|
||||
buttonClassName="
|
||||
py-0.5
|
||||
px-0.5
|
||||
h-6
|
||||
|
@ -8,6 +8,7 @@ import { VFileMessage } from 'vfile-message';
|
||||
|
||||
import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback';
|
||||
import flattenListItemParagraphs from '../serialization/slate/flattenListItemParagraphs';
|
||||
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
|
||||
|
||||
export interface UseMdxState {
|
||||
file: VFile | null;
|
||||
@ -49,10 +50,11 @@ export default function useMdx(
|
||||
);
|
||||
|
||||
const setValue = useDebouncedCallback(setValueCallback, 100);
|
||||
const debouncedState = useDebounce(state, 150);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(input);
|
||||
}, [input, setValue]);
|
||||
|
||||
return [state, setValue];
|
||||
return [debouncedState, setValue];
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ export function getToolbarButtons(
|
||||
keepMounted
|
||||
hideDropdownIcon
|
||||
variant="text"
|
||||
className="
|
||||
buttonClassName="
|
||||
py-0.5
|
||||
px-0.5
|
||||
h-6
|
||||
|
@ -322,7 +322,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
|
||||
label={
|
||||
<>
|
||||
{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 => {
|
||||
const option = uniqueOptionsByValue[selectValue];
|
||||
return (
|
||||
|
@ -87,7 +87,14 @@ const UUIDControl: FC<WidgetControlProps<string, UUIDField>> = ({
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<TextField type="text" inputRef={ref} value={internalValue} disabled={disabled} readonly />
|
||||
<TextField
|
||||
type="text"
|
||||
inputRef={ref}
|
||||
value={internalValue}
|
||||
disabled={disabled}
|
||||
readonly
|
||||
inputClassName="truncate"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
@ -4,13 +4,14 @@ module.exports = {
|
||||
extend: {
|
||||
height: {
|
||||
main: "calc(100vh - 64px)",
|
||||
"main-mobile": "calc(100vh - 128px)",
|
||||
"media-library-dialog": "80vh",
|
||||
"media-card": "240px",
|
||||
"media-preview-image": "104px",
|
||||
"media-card-image": "196px",
|
||||
"image-card": "120px",
|
||||
input: "24px",
|
||||
"table-full": "calc(100% - 40px)"
|
||||
"table-full": "calc(100% - 40px)",
|
||||
},
|
||||
minHeight: {
|
||||
8: "2rem",
|
||||
@ -26,12 +27,20 @@ module.exports = {
|
||||
"media-card": "240px",
|
||||
"media-preview-image": "126px",
|
||||
"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: {
|
||||
"media-search": "400px",
|
||||
},
|
||||
boxShadow: {
|
||||
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: {
|
||||
editor: "450px auto",
|
||||
@ -42,5 +51,24 @@ module.exports = {
|
||||
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) { ... }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user