fix: improve media fetching on collection page, add pagination (#788)

This commit is contained in:
Daniel Lautzenheiser 2023-05-09 16:07:16 -04:00 committed by GitHub
parent e78ebbe65e
commit 92cc4575f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 918 additions and 215 deletions

View File

@ -5,12 +5,12 @@ backend:
media_folder: assets/upload
public_folder: /assets/upload
media_library:
max_file_size: 100000000
collections:
- name: posts
label: Posts
label_singular: Post
media_folder: /assets/posts
public_folder: /assets/posts
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.

View File

@ -3,10 +3,12 @@ backend:
branch: main
repo: static-cms/static-cms-gitlab
auth_type: pkce
app_id: 91cc479ec663625098d456850c4dc4943fd8462064ebd9693b330e66f9d8f11a
app_id: 589ccb30ce31da8ba08e0b1e10b681a10be108e144508c2712573c6e4d60707e
media_folder: assets/upload
public_folder: /assets/upload
media_library:
max_file_size: 100000000
collections:
- name: posts
label: Posts
@ -17,12 +19,14 @@ collections:
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
summary_fields:
- title
- date
- draft
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index

View File

@ -7,6 +7,6 @@
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
<script type="module" src="/backends/gitlab/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,197 @@
// Register all the things
CMS.init();
const PostPreview = ({ entry, widgetFor }) => {
return h(
'div',
{},
h('div', { className: 'cover' }, h('h1', {}, entry.data.title), widgetFor('image')),
h('p', {}, h('small', {}, 'Written ' + entry.data.date)),
h('div', { className: 'text' }, widgetFor('body')),
);
};
const PostDateFieldPreview = ({ value }) => {
const date = new Date(value);
const month = date.getMonth() + 1;
const day = date.getDate();
return h(
'div',
{},
`${date.getFullYear()}-${month < 10 ? `0${month}` : month}-${day < 10 ? `0${day}` : day}`,
);
};
const PostDraftFieldPreview = ({ value }) => {
return h(
'div',
{
style: {
backgroundColor: value === true ? 'rgb(37 99 235)' : 'rgb(22 163 74)',
color: 'white',
border: 'none',
padding: '2px 6px',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
cursor: 'pointer',
borderRadius: '4px',
fontSize: '14px',
},
},
value === true ? 'Draft' : 'Published',
);
};
const GeneralPreview = ({ widgetsFor, entry, collection }) => {
const title = entry.data.site_title;
const posts = entry.data.posts;
const thumb = posts && posts.thumb;
const thumbUrl = useMediaAsset(thumb, collection, undefined, entry);
return h(
'div',
{},
h('h1', {}, title),
h(
'dl',
{},
h('dt', {}, 'Posts on Frontpage'),
h('dd', {}, widgetsFor('posts').widgets.front_limit ?? 0),
h('dt', {}, 'Default Author'),
h('dd', {}, widgetsFor('posts').data?.author ?? 'None'),
h('dt', {}, 'Default Thumbnail'),
h('dd', {}, thumb && h('img', { src: thumbUrl })),
),
);
};
const AuthorsPreview = ({ widgetsFor }) => {
return h(
'div',
{},
h('h1', {}, 'Authors'),
widgetsFor('authors').map(function (author, index) {
return h(
'div',
{ key: index },
h('hr', {}),
h('strong', {}, author.data.name),
author.widgets.description,
);
}),
);
};
const RelationKitchenSinkPostPreview = ({ fieldsMetaData }) => {
// When a post is selected from the relation field, all of it's data
// will be available in the field's metadata nested under the collection
// name, and then further nested under the value specified in `value_field`.
// In this case, the post would be nested under "posts" and then under
// the title of the selected post, since our `value_field` in the config
// is "title".
const post = fieldsMetaData && fieldsMetaData.posts.value;
const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' };
return post
? h(
'div',
{ style: style },
h('h2', {}, 'Related Post'),
h('h3', {}, post.title),
h('img', { src: post.image }),
h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
)
: null;
};
const CustomPage = () => {
return h('div', {}, 'I am a custom page!');
};
CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview);
CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview);
CMS.registerPreviewTemplate('general', GeneralPreview);
CMS.registerPreviewTemplate('authors', AuthorsPreview);
// Pass the name of a registered control to reuse with a new widget preview.
CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);
CMS.registerAdditionalLink({
id: 'example',
title: 'Example.com',
data: 'https://example.com',
options: {
icon: 'page',
},
});
CMS.registerAdditionalLink({
id: 'custom-page',
title: 'Custom Page',
data: CustomPage,
options: {
icon: 'page',
},
});
CMS.registerShortcode('youtube', {
label: 'YouTube',
openTag: '[',
closeTag: ']',
separator: '|',
toProps: args => {
if (args.length > 0) {
return { src: args[0] };
}
return { src: '' };
},
toArgs: ({ src }) => {
return [src];
},
control: ({ src, onChange, theme }) => {
return h('span', {}, [
h('input', {
key: 'control-input',
value: src,
onChange: event => {
onChange({ src: event.target.value });
},
style: {
width: '100%',
backgroundColor: theme === 'dark' ? 'rgb(30, 41, 59)' : 'white',
color: theme === 'dark' ? 'white' : 'black',
padding: '4px 8px',
},
}),
h(
'iframe',
{
key: 'control-preview',
width: '100%',
height: '315',
src: `https://www.youtube.com/embed/${src}`,
},
'',
),
]);
},
preview: ({ src }) => {
return h(
'span',
{},
h(
'iframe',
{
width: '420',
height: '315',
src: `https://www.youtube.com/embed/${src}`,
},
'',
),
);
},
});

View File

@ -146,6 +146,7 @@
"react-router-dom": "6.10.0",
"react-scroll-sync": "0.11.0",
"react-topbar-progress-indicator": "4.1.1",
"react-virtual": "2.10.4",
"react-virtualized-auto-sizer": "1.0.15",
"react-waypoint": "10.3.0",
"react-window": "1.8.9",

View File

@ -633,8 +633,7 @@ export async function tryLoadEntry(state: RootState, collection: Collection, slu
}
const backend = currentBackend(configState.config);
const loadedEntry = await backend.getEntry(state, collection, slug);
return loadedEntry;
return backend.getEntry(state, collection, slug);
}
interface AppendAction {

View File

@ -46,6 +46,7 @@ const MainView = ({
<div className="flex bg-slate-50 dark:bg-slate-900">
{showLeftNav ? <Sidebar /> : null}
<div
id="main-view"
className={classNames(
showLeftNav ? 'w-main left-64' : 'w-full',
!noMargin && 'px-5 py-4',

View File

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

View File

@ -196,7 +196,7 @@ const CollectionView = ({
const collectionDescription = collection?.description;
return (
<div className="flex flex-col">
<div className="flex flex-col h-full">
<div className="flex items-center mb-4">
{isSearchResults ? (
<>
@ -226,7 +226,7 @@ const CollectionView = ({
)}
</div>
{collectionDescription ? (
<div className="flex flex-grow mb-4">
<div className="flex mb-4">
<Card className="flex-grow px-3.5 py-2.5 text-sm">{collectionDescription}</Card>
</div>
) : null}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import Loader from '@staticcms/core/components/common/progress/Loader';
@ -37,11 +37,14 @@ const Entries = ({
page,
...otherProps
}: TranslatedProps<EntriesProps>) => {
const loadingMessages = [
const loadingMessages = useMemo(
() => [
t('collection.entries.loadingEntries'),
t('collection.entries.cachingEntries'),
t('collection.entries.longerLoading'),
];
],
[t],
);
if (isFetching && page === undefined) {
return <Loader>{loadingMessages}</Loader>;
@ -49,9 +52,7 @@ const Entries = ({
const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next');
if (hasEntries) {
return (
<>
{'collection' in otherProps ? (
return 'collection' in otherProps ? (
<EntryListing
key="collection-listing"
collection={otherProps.collection}
@ -60,6 +61,7 @@ const Entries = ({
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
isLoadingEntries={isFetching && page !== undefined && entries.length > 0}
/>
) : (
<EntryListing
@ -70,12 +72,8 @@ const Entries = ({
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
isLoadingEntries={isFetching && page !== undefined && entries.length > 0}
/>
)}
{isFetching && page !== undefined && entries.length > 0 ? (
<div>{t('collection.entries.loadingEntries')}</div>
) : null}
</>
);
}

View File

@ -1,10 +1,7 @@
import { Info as InfoIcon } from '@styled-icons/material-outlined/Info';
import get from 'lodash/get';
import React, { useEffect, useMemo, useState } from 'react';
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/views';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getFieldPreview, getPreviewCard } from '@staticcms/core/lib/registry';
import { getPreviewCard } from '@staticcms/core/lib/registry';
import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util';
import {
selectEntryCollectionTitle,
@ -12,7 +9,6 @@ import {
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
@ -20,17 +16,13 @@ import Card from '../../common/card/Card';
import CardActionArea from '../../common/card/CardActionArea';
import CardContent from '../../common/card/CardContent';
import CardMedia from '../../common/card/CardMedia';
import TableCell from '../../common/table/TableCell';
import TableRow from '../../common/table/TableRow';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type {
BackupEntry,
Collection,
Entry,
FileOrImageField,
MediaField,
TranslatedProps,
} from '@staticcms/core/interface';
import type { FC } from 'react';
@ -39,18 +31,12 @@ export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
collection: Collection;
collectionLabel?: string;
viewStyle: ViewStyle;
summaryFields: string[];
}
const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
collection,
entry,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
imageFieldName,
summaryFields,
t,
}) => {
const entryData = entry.data;
@ -83,7 +69,6 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
const config = useAppSelector(selectConfig);
@ -123,66 +108,6 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
};
}, [collection.name, entry.slug]);
if (viewStyle === VIEW_STYLE_LIST) {
return (
<TableRow
className="
hover:bg-gray-200
dark:hover:bg-slate-700/70
"
>
{collectionLabel ? (
<TableCell key="collectionLabel" to={path}>
{collectionLabel}
</TableCell>
) : null}
{summaryFields.map(fieldName => {
if (fieldName === 'summary') {
return (
<TableCell key={fieldName} to={path}>
{summary}
</TableCell>
);
}
const field = fields.find(f => f.name === fieldName);
const value = get(entry.data, fieldName);
const FieldPreviewComponent = getFieldPreview(templateName, fieldName);
return (
<TableCell key={fieldName} to={path}>
{field && FieldPreviewComponent ? (
<FieldPreviewComponent
collection={collection}
field={field}
value={value}
theme={theme}
/>
) : isNullish(value) ? (
''
) : (
String(value)
)}
</TableCell>
);
})}
<TableCell key="unsavedChanges" to={path} shrink>
{hasLocalBackup ? (
<InfoIcon
className="
w-5
h-5
text-blue-600
dark:text-blue-300
"
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}
</TableCell>
</TableRow>
);
}
if (PreviewCardComponent) {
return (
<Card>
@ -202,9 +127,9 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
}
return (
<Card>
<Card className="h-full">
<CardActionArea to={path}>
{image && imageField ? <CardMedia height="140" image={imageUrl} /> : null}
{image && imageField ? <CardMedia height="140" image={image} /> : null}
<CardContent>
<div className="flex w-full items-center justify-between">
<div>{summary}</div>

View File

@ -1,15 +1,16 @@
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { Waypoint } from 'react-waypoint';
import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views';
import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util';
import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util';
import Table from '../../common/table/Table';
import EntryCard from './EntryCard';
import EntryListingGrid from './EntryListingGrid';
import EntryListingTable from './EntryListingTable';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type {
Collection,
CollectionEntryData,
Collections,
Entry,
Field,
@ -22,6 +23,7 @@ export interface BaseEntryListingProps {
entries: Entry[];
viewStyle: ViewStyle;
cursor?: Cursor;
isLoadingEntries: boolean;
handleCursorActions?: (action: string) => void;
page?: number;
}
@ -40,9 +42,9 @@ export type EntryListingProps =
const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
entries,
page,
cursor,
viewStyle,
isLoadingEntries,
handleCursorActions,
t,
...otherProps
@ -93,23 +95,22 @@ const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
[otherProps],
);
const renderedCards = useMemo(() => {
const entryData: CollectionEntryData[] = useMemo(() => {
if ('collection' in otherProps) {
const inferredFields = inferFields(otherProps.collection);
return entries.map(entry => (
<EntryCard
collection={otherProps.collection}
imageFieldName={inferredFields.imageField}
viewStyle={viewStyle}
entry={entry}
key={entry.slug}
summaryFields={summaryFields}
t={t}
/>
));
return entries.map(entry => ({
collection: otherProps.collection,
imageFieldName: inferredFields.imageField,
viewStyle,
entry,
key: entry.slug,
summaryFields,
}));
}
return entries.map(entry => {
return entries
.map(entry => {
const collectionName = entry.collection;
const collection = Object.values(otherProps.collections).find(
coll => coll.name === collectionName,
@ -117,20 +118,20 @@ const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
const inferredFields = inferFields(collection);
return collection ? (
<EntryCard
collection={collection}
entry={entry}
imageFieldName={inferredFields.imageField}
viewStyle={viewStyle}
collectionLabel={collectionLabel}
key={entry.slug}
summaryFields={summaryFields}
t={t}
/>
) : null;
});
}, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, t, viewStyle]);
return collection
? {
collection,
entry,
imageFieldName: inferredFields.imageField,
viewStyle,
collectionLabel,
key: entry.slug,
summaryFields,
}
: null;
})
.filter(e => e) as CollectionEntryData[];
}, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, viewStyle]);
const summaryFieldHeaders = useMemo(() => {
if ('collection' in otherProps) {
@ -149,38 +150,30 @@ const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
return [];
}, [otherProps, summaryFields]);
if (viewStyle === 'VIEW_STYLE_LIST') {
if (viewStyle === VIEW_STYLE_TABLE) {
return (
<>
<Table
columns={
!isSingleCollectionInList
? ['Collection', ...summaryFieldHeaders, '']
: [...summaryFieldHeaders, '']
}
>
{renderedCards}
</Table>
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
</>
<EntryListingTable
key="table"
entryData={entryData}
isSingleCollectionInList={isSingleCollectionInList}
summaryFieldHeaders={summaryFieldHeaders}
loadNext={handleLoadMore}
canLoadMore={Boolean(hasMore && handleLoadMore)}
isLoadingEntries={isLoadingEntries}
t={t}
/>
);
}
return (
<div
className="
grid
gap-4
sm:grid-cols-1
md:grid-cols-1
lg:grid-cols-2
xl:grid-cols-3
2xl:grid-cols-4
"
>
{renderedCards}
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
</div>
<EntryListingGrid
key="grid"
entryData={entryData}
onLoadMore={handleLoadMore}
canLoadMore={Boolean(hasMore && handleLoadMore)}
isLoadingEntries={isLoadingEntries}
t={t}
/>
);
};

View File

@ -0,0 +1,160 @@
import React, { useCallback, useMemo, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeGrid as Grid } from 'react-window';
import {
MEDIA_CARD_HEIGHT,
MEDIA_CARD_MARGIN,
MEDIA_CARD_WIDTH,
MEDIA_LIBRARY_PADDING,
} from '@staticcms/core/constants/mediaLibrary';
import classNames from '@staticcms/core/lib/util/classNames.util';
import EntryCard from './EntryCard';
import type { CollectionEntryData } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { t } from 'react-polyglot';
import type { GridChildComponentProps } from 'react-window';
export interface EntryListingCardGridProps {
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
entryData: CollectionEntryData[];
onScroll: () => void;
t: t;
}
export interface CardGridItemData {
columnCount: number;
entryData: CollectionEntryData[];
t: t;
}
const CardWrapper = ({
rowIndex,
columnIndex,
style,
data: { columnCount, entryData, t },
}: GridChildComponentProps<CardGridItemData>) => {
const left = useMemo(
() =>
parseFloat(
`${
typeof style.left === 'number'
? style.left ?? MEDIA_CARD_MARGIN * columnIndex
: style.left
}`,
),
[columnIndex, style.left],
);
const top = useMemo(
() => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`) + 4,
[style.top],
);
const index = rowIndex * columnCount + columnIndex;
if (index >= entryData.length) {
return null;
}
const data = entryData[index];
return (
<div
style={{
...style,
left,
top,
width: style.width,
height: style.height,
paddingRight: `${columnIndex + 1 === columnCount ? 0 : MEDIA_CARD_MARGIN}px`,
paddingBottom: `${MEDIA_CARD_MARGIN}px`,
}}
>
<EntryCard
key={data.key}
collection={data.collection}
entry={data.entry}
imageFieldName={data.imageFieldName}
t={t}
/>
</div>
);
};
const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
entryData,
scrollContainerRef,
onScroll,
t,
}) => {
const [version, setVersion] = useState(0);
const handleResize = useCallback(() => {
setVersion(oldVersion => oldVersion + 1);
}, []);
return (
<div className="relative w-full h-full">
<AutoSizer onResize={handleResize}>
{({ height = 0, width = 0 }) => {
const columnWidthWithGutter = MEDIA_CARD_WIDTH + MEDIA_CARD_MARGIN;
const rowHeightWithGutter = MEDIA_CARD_HEIGHT + MEDIA_CARD_MARGIN;
const columnCount = Math.floor(width / columnWidthWithGutter);
const nonGutterSpace = (width - MEDIA_CARD_MARGIN * columnCount) / width;
const columnWidth = (1 / columnCount) * nonGutterSpace;
const rowCount = Math.ceil(entryData.length / columnCount);
return (
<div
key={version}
className={classNames(
`
overflow-hidden
`,
)}
style={{
width,
height,
}}
>
<Grid
columnCount={columnCount}
columnWidth={index =>
index + 1 === columnCount
? width * columnWidth
: width * columnWidth + MEDIA_CARD_MARGIN
}
rowCount={rowCount}
rowHeight={() => rowHeightWithGutter}
width={width}
height={height - MEDIA_LIBRARY_PADDING}
itemData={
{
entryData,
columnCount,
t,
} as CardGridItemData
}
outerRef={scrollContainerRef}
onScroll={onScroll}
className={classNames(
`
overflow-hidden
overflow-y-auto
styled-scrollbars
`,
)}
style={{ position: 'unset' }}
>
{CardWrapper}
</Grid>
</div>
);
}}
</AutoSizer>
</div>
);
};
export default EntryListingCardGrid;

View File

@ -0,0 +1,90 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import EntryListingCardGrid from './EntryListingCardGrid';
import type { CollectionEntryData } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { t } from 'react-polyglot';
export interface EntryListingGridProps {
entryData: CollectionEntryData[];
canLoadMore?: boolean;
isLoadingEntries: boolean;
onLoadMore: () => void;
t: t;
}
const EntryListingGrid: FC<EntryListingGridProps> = ({
entryData,
canLoadMore,
isLoadingEntries,
onLoadMore,
t,
}) => {
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const isFetching = useAppSelector(selectIsFetching);
const fetchMoreOnBottomReached = useCallback(
(scrollHeight?: number, scrollTop?: number, clientHeight?: number) => {
if (isNotNullish(scrollHeight) && isNotNullish(scrollTop) && isNotNullish(clientHeight)) {
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && canLoadMore) {
onLoadMore();
}
}
},
[isFetching, canLoadMore, onLoadMore],
);
const handleScroll = useCallback(() => {
const { scrollHeight, scrollTop, clientHeight } = gridContainerRef.current ?? {};
fetchMoreOnBottomReached(scrollHeight, scrollTop, clientHeight);
}, [fetchMoreOnBottomReached]);
useEffect(() => {
const interval = setInterval(() => {
handleScroll();
}, 100);
return () => {
clearInterval(interval);
};
}, [handleScroll]);
return (
<div className="relative h-full overflow-hidden">
<div ref={gridContainerRef} className="relative h-full overflow-auto styled-scrollbars">
<EntryListingCardGrid
key="grid"
entryData={entryData}
scrollContainerRef={gridContainerRef}
onScroll={handleScroll}
t={t}
/>
</div>
{isLoadingEntries ? (
<div
key="loading"
className="
absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50
"
>
{t('collection.entries.loadingEntries')}
</div>
) : null}
</div>
);
};
export default EntryListingGrid;

View File

@ -0,0 +1,125 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useVirtual } from 'react-virtual';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import Table from '../../common/table/Table';
import EntryRow from './EntryRow';
import type { CollectionEntryData } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { t } from 'react-polyglot';
export interface EntryListingTableProps {
isSingleCollectionInList: boolean;
entryData: CollectionEntryData[];
summaryFieldHeaders: string[];
canLoadMore: boolean;
isLoadingEntries: boolean;
loadNext: () => void;
t: t;
}
const EntryListingTable: FC<EntryListingTableProps> = ({
isSingleCollectionInList,
entryData,
summaryFieldHeaders,
canLoadMore,
isLoadingEntries,
loadNext,
t,
}) => {
const isFetching = useAppSelector(selectIsFetching);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const { virtualItems: virtualRows, totalSize } = useVirtual({
parentRef: tableContainerRef,
size: entryData.length,
overscan: 10,
});
const paddingTop = useMemo(
() => (virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0),
[virtualRows],
);
const paddingBottom = useMemo(
() =>
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0,
[totalSize, virtualRows],
);
const fetchMoreOnBottomReached = useCallback(
(scrollHeight?: number, scrollTop?: number, clientHeight?: number) => {
if (isNotNullish(scrollHeight) && isNotNullish(scrollTop) && isNotNullish(clientHeight)) {
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && canLoadMore) {
loadNext();
}
}
},
[isFetching, canLoadMore, loadNext],
);
const { scrollHeight, scrollTop, clientHeight } = tableContainerRef.current ?? {};
useEffect(() => {
fetchMoreOnBottomReached(scrollHeight, scrollTop, clientHeight);
}, [clientHeight, fetchMoreOnBottomReached, scrollHeight, scrollTop]);
return (
<div className="relative h-full overflow-hidden">
<div ref={tableContainerRef} className="relative h-full overflow-auto styled-scrollbars">
<Table
columns={
!isSingleCollectionInList
? ['Collection', ...summaryFieldHeaders, '']
: [...summaryFieldHeaders, '']
}
>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows.map(virtualRow => {
const data = entryData[virtualRow.index];
return (
<EntryRow
key={virtualRow.index}
collection={data.collection}
entry={data.entry}
summaryFields={data.summaryFields}
t={t}
/>
);
})}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
</Table>
</div>
{isLoadingEntries ? (
<div
key="loading"
className="
absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50
"
>
{t('collection.entries.loadingEntries')}
</div>
) : null}
</div>
);
};
export default EntryListingTable;

View File

@ -0,0 +1,136 @@
import { Info as InfoIcon } from '@styled-icons/material-outlined/Info';
import get from 'lodash/get';
import React, { useEffect, useMemo, useState } from 'react';
import { getFieldPreview } from '@staticcms/core/lib/registry';
import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util';
import {
selectEntryCollectionTitle,
selectFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import TableCell from '../../common/table/TableCell';
import TableRow from '../../common/table/TableRow';
import type { BackupEntry, Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface EntryRowProps {
entry: Entry;
collection: Collection;
collectionLabel?: string;
summaryFields: string[];
}
const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
collection,
entry,
collectionLabel,
summaryFields,
t,
}) => {
const path = useMemo(
() => `/collections/${collection.name}/entries/${entry.slug}`,
[collection.name, entry.slug],
);
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const templateName = useMemo(
() => selectTemplateName(collection, entry.slug),
[collection, entry.slug],
);
const theme = useAppSelector(selectTheme);
const [hasLocalBackup, setHasLocalBackup] = useState(false);
useEffect(() => {
let alive = true;
const checkLocalBackup = async () => {
const key = getEntryBackupKey(collection.name, entry.slug);
const backup = await localForage.getItem<BackupEntry>(key);
if (alive) {
setHasLocalBackup(Boolean(backup));
}
};
checkLocalBackup();
// Check again after small delay to ensure we capture the draft just made from the editor
setTimeout(() => {
checkLocalBackup();
}, 250);
return () => {
alive = false;
};
}, [collection.name, entry.slug]);
return (
<TableRow
className="
hover:bg-gray-200
dark:hover:bg-slate-700/70
"
>
{collectionLabel ? (
<TableCell key="collectionLabel" to={path}>
{collectionLabel}
</TableCell>
) : null}
{summaryFields.map(fieldName => {
if (fieldName === 'summary') {
return (
<TableCell key={fieldName} to={path}>
{summary}
</TableCell>
);
}
const field = fields.find(f => f.name === fieldName);
const value = get(entry.data, fieldName);
const FieldPreviewComponent = getFieldPreview(templateName, fieldName);
return (
<TableCell key={fieldName} to={path}>
{field && FieldPreviewComponent ? (
<FieldPreviewComponent
collection={collection}
field={field}
value={value}
theme={theme}
/>
) : isNullish(value) ? (
''
) : (
String(value)
)}
</TableCell>
);
})}
<TableCell key="unsavedChanges" to={path} shrink>
{hasLocalBackup ? (
<InfoIcon
className="
w-5
h-5
text-blue-600
dark:text-blue-300
"
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}
</TableCell>
</TableRow>
);
};
export default EntryRow;

View File

@ -20,6 +20,8 @@ const Card = ({ children, className }: CardProps) => {
shadow-md
dark:bg-slate-800
dark:border-gray-700/40
flex
flex-col
`,
className,
)}

View File

@ -17,6 +17,8 @@ const CardActionArea = ({ to, children }: CardActionAreaProps) => {
w-full
relative
flex
flex-col
rounded-lg
justify-start
hover:bg-gray-200
dark:hover:bg-slate-700/70

View File

@ -1,5 +1,7 @@
import React from 'react';
import Image from '../image/Image';
interface CardMediaProps {
image: string;
width?: string | number;
@ -9,7 +11,7 @@ interface CardMediaProps {
const CardMedia = ({ image, width, height, alt = '' }: CardMediaProps) => {
return (
<img
<Image
className="rounded-t-lg bg-cover bg-no-repeat bg-center w-full object-cover"
style={{
width: width ? `${width}px` : undefined,

View File

@ -8,11 +8,13 @@ import { useAppSelector } from '@staticcms/core/store/hooks';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type { BaseField, Collection, MediaField, UnknownField } from '@staticcms/core/interface';
import type { CSSProperties } from 'react';
export interface ImageProps<EF extends BaseField> {
src?: string;
alt?: string;
className?: string;
style?: CSSProperties;
collection?: Collection<EF>;
field?: MediaField;
'data-testid'?: string;
@ -22,6 +24,7 @@ const Image = <EF extends BaseField = UnknownField>({
src,
alt,
className,
style,
collection,
field,
'data-testid': dataTestId,
@ -52,6 +55,7 @@ const Image = <EF extends BaseField = UnknownField>({
alt={alt}
data-testid={dataTestId ?? 'image'}
className={classNames('object-cover max-w-full overflow-hidden', className)}
style={style}
/>
);
};

View File

@ -11,12 +11,18 @@ interface TableCellProps {
const TableCell = ({ columns, children }: TableCellProps) => {
return (
<div className="relative overflow-x-auto shadow-md sm:rounded-lg border border-slate-200 dark:border-gray-700">
<div className="shadow-md">
<table className="w-full text-sm text-left text-gray-500 dark:text-gray-300">
<thead className="text-xs text-gray-700 bg-gray-100 dark:bg-slate-700 dark:text-gray-300">
<thead className="text-xs">
<tr>
{columns.map((column, index) => (
<TableHeaderCell key={index}>{column}</TableHeaderCell>
<TableHeaderCell
key={index}
isFirst={index === 0}
isLast={index + 1 === columns.length}
>
{column}
</TableHeaderCell>
))}
</tr>
</thead>

View File

@ -1,15 +1,46 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type { ReactNode } from 'react';
interface TableHeaderCellProps {
children: ReactNode;
isFirst: boolean;
isLast: boolean;
}
const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
const TableHeaderCell = ({ children, isFirst, isLast }: TableHeaderCellProps) => {
return (
<th scope="col" className="px-4 py-3 text-gray-500 dark:text-gray-400">
{children}
<th
scope="col"
className="
text-gray-500
bg-slate-50
dark:text-gray-400
dark:bg-slate-900
sticky
top-0
p-0
"
>
<div
className={classNames(
`
bg-gray-100
border-slate-200
dark:bg-slate-700
dark:border-gray-700
px-4
py-3
`,
isFirst && 'rounded-tl-lg',
isLast && 'rounded-tr-lg',
)}
>
{typeof children === 'string' && isEmpty(children) ? <>&nbsp;</> : children}
</div>
</th>
);
};

View File

@ -2,7 +2,7 @@ import { Grid as GridIcon } from '@styled-icons/bootstrap/Grid';
import { TableRows as TableRowsIcon } from '@styled-icons/material-rounded/TableRows';
import React from 'react';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/views';
import { VIEW_STYLE_GRID, VIEW_STYLE_TABLE } from '@staticcms/core/constants/views';
import classNames from '@staticcms/core/lib/util/classNames.util';
import IconButton from '../button/IconButton';
@ -18,9 +18,9 @@ const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros
<div className="flex items-center gap-1.5 mr-1">
<IconButton
variant="text"
className={classNames(viewStyle === VIEW_STYLE_LIST && 'text-blue-500 dark:text-blue-500')}
className={classNames(viewStyle === VIEW_STYLE_TABLE && 'text-blue-500 dark:text-blue-500')}
aria-label="table view"
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
onClick={() => onChangeViewStyle(VIEW_STYLE_TABLE)}
>
<TableRowsIcon className="h-5 w-5" />
</IconButton>

View File

@ -10,13 +10,14 @@ import { Publish as PublishIcon } from '@styled-icons/material/Publish';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries';
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';
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 confirm from '../common/confirm/Confirm';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries';
import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface';
import type { FC, MouseEventHandler } from 'react';
@ -73,6 +74,7 @@ const EditorToolbar = ({
);
const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]);
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);
const isLoading = useAppSelector(selectIsFetching);
const dispatch = useAppDispatch();
@ -185,7 +187,7 @@ const EditorToolbar = ({
<>
<MenuItemButton
onClick={togglePreview}
disabled={i18nActive}
disabled={isLoading || i18nActive}
startIcon={EyeIcon}
endIcon={previewActive && !i18nActive ? CheckIcon : undefined}
>
@ -193,7 +195,7 @@ const EditorToolbar = ({
</MenuItemButton>
<MenuItemButton
onClick={toggleScrollSync}
disabled={i18nActive || !previewActive}
disabled={isLoading || i18nActive || !previewActive}
startIcon={HeightIcon}
endIcon={
scrollSyncActive && !(i18nActive || !previewActive) ? CheckIcon : undefined
@ -218,7 +220,7 @@ const EditorToolbar = ({
isPublished ? t('editor.editorToolbar.published') : t('editor.editorToolbar.publish')
}
color={isPublished ? 'success' : 'primary'}
disabled={menuItems.length == 1 && menuItems[0].length === 0}
disabled={isLoading || (menuItems.length == 1 && menuItems[0].length === 0)}
>
{menuItems.map((group, index) => (
<MenuGroup key={`menu-group-${index}`}>{group}</MenuGroup>
@ -229,14 +231,15 @@ const EditorToolbar = ({
[
showI18nToggle,
showPreviewToggle,
canDelete,
toggleI18n,
i18nActive,
t,
togglePreview,
isLoading,
previewActive,
toggleScrollSync,
scrollSyncActive,
canDelete,
onDelete,
isPublished,
menuItems,

View File

@ -1,13 +1,12 @@
import AJV from 'ajv';
import select from 'ajv-keywords/dist/keywords/select';
import uniqueItemProperties from 'ajv-keywords/dist/keywords/uniqueItemProperties';
import ajvErrors from 'ajv-errors';
import instanceOf from 'ajv-keywords/dist/keywords/instanceof';
import prohibited from 'ajv-keywords/dist/keywords/prohibited';
import ajvErrors from 'ajv-errors';
import select from 'ajv-keywords/dist/keywords/select';
import uniqueItemProperties from 'ajv-keywords/dist/keywords/uniqueItemProperties';
import { v4 as uuid } from 'uuid';
import { formatExtensions, frontmatterFormats, extensionFormatters } from '../formats/formats';
import { getWidgets } from '../lib/registry';
import { extensionFormatters, formatExtensions, frontmatterFormats } from '../formats/formats';
import {
I18N_FIELD_DUPLICATE,
I18N_FIELD_NONE,
@ -16,6 +15,7 @@ import {
I18N_STRUCTURE_MULTIPLE_FOLDERS,
I18N_STRUCTURE_SINGLE_FILE,
} from '../lib/i18n';
import { getWidgets } from '../lib/registry';
import type { ErrorObject } from 'ajv';
import type { Config } from '../interface';

View File

@ -1,4 +1,4 @@
export const VIEW_STYLE_LIST = 'VIEW_STYLE_LIST';
export const VIEW_STYLE_GRID = 'VIEW_STYLE_GRID';
export const VIEW_STYLE_TABLE = 'table';
export const VIEW_STYLE_GRID = 'grid';
export type ViewStyle = typeof VIEW_STYLE_LIST | typeof VIEW_STYLE_GRID;
export type ViewStyle = typeof VIEW_STYLE_TABLE | typeof VIEW_STYLE_GRID;

View File

@ -37,6 +37,7 @@ import type {
STRIKETHROUGH_TOOLBAR_BUTTON,
UNORDERED_LIST_TOOLBAR_BUTTON,
} from './constants/toolbar_buttons';
import type { ViewStyle } from './constants/views';
import type { formatExtensions } from './formats/formats';
import type {
I18N_FIELD_DUPLICATE,
@ -1087,3 +1088,13 @@ export interface BackupEntry {
mediaFiles: MediaFile[];
i18n?: Record<string, { raw: string }>;
}
export interface CollectionEntryData {
collection: Collection;
imageFieldName: string | null | undefined;
viewStyle: ViewStyle;
entry: Entry;
key: string;
summaryFields: string[];
collectionLabel?: string;
}

View File

@ -22,7 +22,7 @@ import {
SORT_ENTRIES_REQUEST,
SORT_ENTRIES_SUCCESS,
} from '../constants';
import { VIEW_STYLE_LIST } from '../constants/views';
import { VIEW_STYLE_TABLE } from '../constants/views';
import { set } from '../lib/util/object.util';
import type { EntriesAction } from '../actions/entries';
@ -97,8 +97,8 @@ const loadViewStyle = once(() => {
return viewStyle;
}
localStorage.setItem(viewStyleKey, VIEW_STYLE_LIST);
return VIEW_STYLE_LIST;
localStorage.setItem(viewStyleKey, VIEW_STYLE_TABLE);
return VIEW_STYLE_TABLE;
});
function clearViewStyle() {

View File

@ -10,6 +10,7 @@ module.exports = {
"media-card-image": "196px",
"image-card": "120px",
input: "24px",
"table-full": "calc(100% - 40px)"
},
minHeight: {
8: "2rem",

View File

@ -3594,6 +3594,11 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
@ -16401,6 +16406,13 @@ react-use@^17.3.2:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtual@2.10.4:
version "2.10.4"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704"
integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==
dependencies:
"@reach/observe-rect" "^1.1.0"
react-virtualized-auto-sizer@1.0.15:
version "1.0.15"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.15.tgz#84558bcab61a625d13ec37876639bb09c5a3ec0b"