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 media_folder: assets/upload
public_folder: /assets/upload public_folder: /assets/upload
media_library:
max_file_size: 100000000
collections: collections:
- name: posts - name: posts
label: Posts label: Posts
label_singular: Post label_singular: Post
media_folder: /assets/posts
public_folder: /assets/posts
description: > description: >
The description is a great place for tone setting, high level information, The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection. and editing guidelines that are specific to a collection.

View File

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

View File

@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<script src="/static-cms-core.js"></script> <script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script> <script type="module" src="/backends/gitlab/index.js"></script>
</body> </body>
</html> </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-router-dom": "6.10.0",
"react-scroll-sync": "0.11.0", "react-scroll-sync": "0.11.0",
"react-topbar-progress-indicator": "4.1.1", "react-topbar-progress-indicator": "4.1.1",
"react-virtual": "2.10.4",
"react-virtualized-auto-sizer": "1.0.15", "react-virtualized-auto-sizer": "1.0.15",
"react-waypoint": "10.3.0", "react-waypoint": "10.3.0",
"react-window": "1.8.9", "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 backend = currentBackend(configState.config);
const loadedEntry = await backend.getEntry(state, collection, slug); return backend.getEntry(state, collection, slug);
return loadedEntry;
} }
interface AppendAction { interface AppendAction {

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import Loader from '@staticcms/core/components/common/progress/Loader'; import Loader from '@staticcms/core/components/common/progress/Loader';
@ -37,11 +37,14 @@ const Entries = ({
page, page,
...otherProps ...otherProps
}: TranslatedProps<EntriesProps>) => { }: TranslatedProps<EntriesProps>) => {
const loadingMessages = [ const loadingMessages = useMemo(
() => [
t('collection.entries.loadingEntries'), t('collection.entries.loadingEntries'),
t('collection.entries.cachingEntries'), t('collection.entries.cachingEntries'),
t('collection.entries.longerLoading'), t('collection.entries.longerLoading'),
]; ],
[t],
);
if (isFetching && page === undefined) { if (isFetching && page === undefined) {
return <Loader>{loadingMessages}</Loader>; return <Loader>{loadingMessages}</Loader>;
@ -49,9 +52,7 @@ const Entries = ({
const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next'); const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next');
if (hasEntries) { if (hasEntries) {
return ( return 'collection' in otherProps ? (
<>
{'collection' in otherProps ? (
<EntryListing <EntryListing
key="collection-listing" key="collection-listing"
collection={otherProps.collection} collection={otherProps.collection}
@ -60,6 +61,7 @@ const Entries = ({
cursor={cursor} cursor={cursor}
handleCursorActions={handleCursorActions} handleCursorActions={handleCursorActions}
page={page} page={page}
isLoadingEntries={isFetching && page !== undefined && entries.length > 0}
/> />
) : ( ) : (
<EntryListing <EntryListing
@ -70,12 +72,8 @@ const Entries = ({
cursor={cursor} cursor={cursor}
handleCursorActions={handleCursorActions} handleCursorActions={handleCursorActions}
page={page} 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 { Info as InfoIcon } from '@styled-icons/material-outlined/Info';
import get from 'lodash/get';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/views'; import { getPreviewCard } from '@staticcms/core/lib/registry';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getFieldPreview, getPreviewCard } from '@staticcms/core/lib/registry';
import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util'; import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util';
import { import {
selectEntryCollectionTitle, selectEntryCollectionTitle,
@ -12,7 +9,6 @@ import {
selectTemplateName, selectTemplateName,
} from '@staticcms/core/lib/util/collection.util'; } from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage'; 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 { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
@ -20,17 +16,13 @@ import Card from '../../common/card/Card';
import CardActionArea from '../../common/card/CardActionArea'; import CardActionArea from '../../common/card/CardActionArea';
import CardContent from '../../common/card/CardContent'; import CardContent from '../../common/card/CardContent';
import CardMedia from '../../common/card/CardMedia'; 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 useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { import type {
BackupEntry, BackupEntry,
Collection, Collection,
Entry, Entry,
FileOrImageField, FileOrImageField,
MediaField,
TranslatedProps, TranslatedProps,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
@ -39,18 +31,12 @@ export interface EntryCardProps {
entry: Entry; entry: Entry;
imageFieldName?: string | null | undefined; imageFieldName?: string | null | undefined;
collection: Collection; collection: Collection;
collectionLabel?: string;
viewStyle: ViewStyle;
summaryFields: string[];
} }
const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
collection, collection,
entry, entry,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
imageFieldName, imageFieldName,
summaryFields,
t, t,
}) => { }) => {
const entryData = entry.data; const entryData = entry.data;
@ -83,7 +69,6 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug); const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
const config = useAppSelector(selectConfig); const config = useAppSelector(selectConfig);
@ -123,66 +108,6 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
}; };
}, [collection.name, entry.slug]); }, [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) { if (PreviewCardComponent) {
return ( return (
<Card> <Card>
@ -202,9 +127,9 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
} }
return ( return (
<Card> <Card className="h-full">
<CardActionArea to={path}> <CardActionArea to={path}>
{image && imageField ? <CardMedia height="140" image={imageUrl} /> : null} {image && imageField ? <CardMedia height="140" image={image} /> : null}
<CardContent> <CardContent>
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div>{summary}</div> <div>{summary}</div>

View File

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

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 shadow-md
dark:bg-slate-800 dark:bg-slate-800
dark:border-gray-700/40 dark:border-gray-700/40
flex
flex-col
`, `,
className, className,
)} )}

View File

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

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import Image from '../image/Image';
interface CardMediaProps { interface CardMediaProps {
image: string; image: string;
width?: string | number; width?: string | number;
@ -9,7 +11,7 @@ interface CardMediaProps {
const CardMedia = ({ image, width, height, alt = '' }: CardMediaProps) => { const CardMedia = ({ image, width, height, alt = '' }: CardMediaProps) => {
return ( return (
<img <Image
className="rounded-t-lg bg-cover bg-no-repeat bg-center w-full object-cover" className="rounded-t-lg bg-cover bg-no-repeat bg-center w-full object-cover"
style={{ style={{
width: width ? `${width}px` : undefined, 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 { isEmpty } from '@staticcms/core/lib/util/string.util';
import type { BaseField, Collection, MediaField, UnknownField } from '@staticcms/core/interface'; import type { BaseField, Collection, MediaField, UnknownField } from '@staticcms/core/interface';
import type { CSSProperties } from 'react';
export interface ImageProps<EF extends BaseField> { export interface ImageProps<EF extends BaseField> {
src?: string; src?: string;
alt?: string; alt?: string;
className?: string; className?: string;
style?: CSSProperties;
collection?: Collection<EF>; collection?: Collection<EF>;
field?: MediaField; field?: MediaField;
'data-testid'?: string; 'data-testid'?: string;
@ -22,6 +24,7 @@ const Image = <EF extends BaseField = UnknownField>({
src, src,
alt, alt,
className, className,
style,
collection, collection,
field, field,
'data-testid': dataTestId, 'data-testid': dataTestId,
@ -52,6 +55,7 @@ const Image = <EF extends BaseField = UnknownField>({
alt={alt} alt={alt}
data-testid={dataTestId ?? 'image'} data-testid={dataTestId ?? 'image'}
className={classNames('object-cover max-w-full overflow-hidden', className)} 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) => { const TableCell = ({ columns, children }: TableCellProps) => {
return ( 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"> <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> <tr>
{columns.map((column, index) => ( {columns.map((column, index) => (
<TableHeaderCell key={index}>{column}</TableHeaderCell> <TableHeaderCell
key={index}
isFirst={index === 0}
isLast={index + 1 === columns.length}
>
{column}
</TableHeaderCell>
))} ))}
</tr> </tr>
</thead> </thead>

View File

@ -1,15 +1,46 @@
import React from 'react'; 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'; import type { ReactNode } from 'react';
interface TableHeaderCellProps { interface TableHeaderCellProps {
children: ReactNode; children: ReactNode;
isFirst: boolean;
isLast: boolean;
} }
const TableHeaderCell = ({ children }: TableHeaderCellProps) => { const TableHeaderCell = ({ children, isFirst, isLast }: TableHeaderCellProps) => {
return ( return (
<th scope="col" className="px-4 py-3 text-gray-500 dark:text-gray-400"> <th
{children} 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> </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 { TableRows as TableRowsIcon } from '@styled-icons/material-rounded/TableRows';
import React from 'react'; 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 classNames from '@staticcms/core/lib/util/classNames.util';
import IconButton from '../button/IconButton'; import IconButton from '../button/IconButton';
@ -18,9 +18,9 @@ const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros
<div className="flex items-center gap-1.5 mr-1"> <div className="flex items-center gap-1.5 mr-1">
<IconButton <IconButton
variant="text" 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" aria-label="table view"
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)} onClick={() => onChangeViewStyle(VIEW_STYLE_TABLE)}
> >
<TableRowsIcon className="h-5 w-5" /> <TableRowsIcon className="h-5 w-5" />
</IconButton> </IconButton>

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export const VIEW_STYLE_LIST = 'VIEW_STYLE_LIST'; export const VIEW_STYLE_TABLE = 'table';
export const VIEW_STYLE_GRID = 'VIEW_STYLE_GRID'; 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, STRIKETHROUGH_TOOLBAR_BUTTON,
UNORDERED_LIST_TOOLBAR_BUTTON, UNORDERED_LIST_TOOLBAR_BUTTON,
} from './constants/toolbar_buttons'; } from './constants/toolbar_buttons';
import type { ViewStyle } from './constants/views';
import type { formatExtensions } from './formats/formats'; import type { formatExtensions } from './formats/formats';
import type { import type {
I18N_FIELD_DUPLICATE, I18N_FIELD_DUPLICATE,
@ -1087,3 +1088,13 @@ export interface BackupEntry {
mediaFiles: MediaFile[]; mediaFiles: MediaFile[];
i18n?: Record<string, { raw: string }>; 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_REQUEST,
SORT_ENTRIES_SUCCESS, SORT_ENTRIES_SUCCESS,
} from '../constants'; } from '../constants';
import { VIEW_STYLE_LIST } from '../constants/views'; import { VIEW_STYLE_TABLE } from '../constants/views';
import { set } from '../lib/util/object.util'; import { set } from '../lib/util/object.util';
import type { EntriesAction } from '../actions/entries'; import type { EntriesAction } from '../actions/entries';
@ -97,8 +97,8 @@ const loadViewStyle = once(() => {
return viewStyle; return viewStyle;
} }
localStorage.setItem(viewStyleKey, VIEW_STYLE_LIST); localStorage.setItem(viewStyleKey, VIEW_STYLE_TABLE);
return VIEW_STYLE_LIST; return VIEW_STYLE_TABLE;
}); });
function clearViewStyle() { function clearViewStyle() {

View File

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

View File

@ -3594,6 +3594,11 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@react-dnd/asap@^5.0.1":
version "5.0.2" version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488" 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" ts-easing "^0.2.0"
tslib "^2.1.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: react-virtualized-auto-sizer@1.0.15:
version "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" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.15.tgz#84558bcab61a625d13ec37876639bb09c5a3ec0b"