feat: ui overhaul (#676)

This commit is contained in:
Daniel Lautzenheiser 2023-03-30 13:29:09 -04:00 committed by GitHub
parent 5c86462859
commit 66b81e9228
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
385 changed files with 20607 additions and 16493 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
18

View File

@ -1 +1,18 @@
BREAKING_CHANGES
- Card preview only is used for card view (viewStyle prop removed).
- Deprecated stuff removed (getAsset, createReactClass, isFieldDuplicate, isFieldHidden)
- widget prop `isDisabled` renamed to `disabled`
- widget prop `isDuplicate` renamed to `duplicate`
- widget prop `isHidden` renamed to `hidden`
- useMediaInsert now requires collection to be passed - useMediaInsert now requires collection to be passed
- media path changed from `string | string[]` to `{ path: string | string[], alt?: string }`
ADDED
- `forSingleList` - Allows for changing styles for single list items
TODO
- Docs on table columns
- Docs on field previews
- Re-add collection description OR document as breaking change

View File

@ -55,23 +55,6 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@mui/*/*/*', '!@mui/material/test-utils/*'],
message: 'Do not import material imports as 3rd level imports',
allowTypeImports: true,
},
{
group: ['@mui/material', '!@mui/material/'],
message: 'Please import material imports as defaults or 2nd level imports',
allowTypeImports: true,
},
],
},
],
'import/prefer-default-export': 'error', 'import/prefer-default-export': 'error',
}, },
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'], plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],

View File

@ -57,11 +57,12 @@
"@babel/preset-typescript": "7.21.0", "@babel/preset-typescript": "7.21.0",
"@emotion/eslint-plugin": "11.10.0", "@emotion/eslint-plugin": "11.10.0",
"@emotion/jest": "11.10.5", "@emotion/jest": "11.10.5",
"@types/node": "16.18.16", "@types/node": "18.11.18",
"@types/react": "18.0.28", "@types/react": "18.0.28",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"@typescript-eslint/eslint-plugin": "5.55.0", "@typescript-eslint/eslint-plugin": "5.55.0",
"@typescript-eslint/parser": "5.55.0", "@typescript-eslint/parser": "5.55.0",
"autoprefixer": "10.4.13",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"babel-loader": "9.1.2", "babel-loader": "9.1.2",
"babel-plugin-emotion": "11.0.0", "babel-plugin-emotion": "11.0.0",
@ -73,6 +74,7 @@
"babel-plugin-transform-export-extensions": "6.22.0", "babel-plugin-transform-export-extensions": "6.22.0",
"babel-plugin-transform-inline-environment-variables": "0.4.4", "babel-plugin-transform-inline-environment-variables": "0.4.4",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"css-loader": "6.7.3",
"dotenv": "16.0.3", "dotenv": "16.0.3",
"eslint": "8.36.0", "eslint": "8.36.0",
"eslint-import-resolver-typescript": "3.5.3", "eslint-import-resolver-typescript": "3.5.3",
@ -82,6 +84,7 @@
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-unicorn": "46.0.0", "eslint-plugin-unicorn": "46.0.0",
"mini-css-extract-plugin": "2.7.2",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"postcss": "8.4.21", "postcss": "8.4.21",
"postcss-scss": "4.0.6", "postcss-scss": "4.0.6",

View File

@ -0,0 +1,7 @@
module.exports = {
plugins: {
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,7 @@
const baseConfig = require('../../tailwind.base.config');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['../core/src/**/*.tsx'],
...baseConfig,
};

View File

@ -49,7 +49,7 @@
"@staticcms/string/*": ["../core/src/widgets/string/*"], "@staticcms/string/*": ["../core/src/widgets/string/*"],
"@staticcms/text": ["../core/src/widgets/text"], "@staticcms/text": ["../core/src/widgets/text"],
"@staticcms/text/*": ["../core/src/widgets/text/*"], "@staticcms/text/*": ["../core/src/widgets/text/*"],
"@staticcms/core": ["../core/core/src/*"], "@staticcms/core": ["../core/src"],
"@staticcms/core/*": ["../core/src/*"] "@staticcms/core/*": ["../core/src/*"]
}, },
"types": ["@emotion/react/types/css-prop", "@types/jest", "@testing-library/jest-dom"] "types": ["@emotion/react/types/css-prop", "@types/jest", "@testing-library/jest-dom"]

View File

@ -2,6 +2,7 @@ const path = require('path');
const webpack = require('webpack'); const webpack = require('webpack');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const devServerPort = parseInt(process.env.STATIC_CMS_DEV_SERVER_PORT || `${8080}`); const devServerPort = parseInt(process.env.STATIC_CMS_DEV_SERVER_PORT || `${8080}`);
@ -44,14 +45,11 @@ module.exports = {
}, },
{ {
test: /\.css$/, test: /\.css$/,
include: ['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), include: [...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), path.resolve(__dirname, '..', 'core', 'src')],
use: [ use: [
{ !isProduction ? 'style-loader' : MiniCssExtractPlugin.loader,
loader: 'style-loader', 'css-loader',
}, 'postcss-loader',
{
loader: 'css-loader',
},
], ],
}, },
{ {
@ -85,6 +83,7 @@ module.exports = {
}, },
plugins: [ plugins: [
!isProduction && new ReactRefreshWebpackPlugin(), !isProduction && new ReactRefreshWebpackPlugin(),
isProduction && new MiniCssExtractPlugin(),
new webpack.IgnorePlugin({ resourceRegExp: /^esprima$/ }), new webpack.IgnorePlugin({ resourceRegExp: /^esprima$/ }),
new webpack.IgnorePlugin({ resourceRegExp: /moment\/locale\// }), new webpack.IgnorePlugin({ resourceRegExp: /moment\/locale\// }),
new webpack.ProvidePlugin({ new webpack.ProvidePlugin({

View File

@ -55,23 +55,6 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@mui/*/*/*', '!@mui/material/test-utils/*'],
message: 'Do not import material imports as 3rd level imports',
allowTypeImports: true,
},
{
group: ['@mui/material', '!@mui/material/'],
message: 'Please import material imports as defaults or 2nd level imports',
allowTypeImports: true,
},
],
},
],
'import/prefer-default-export': 'error', 'import/prefer-default-export': 'error',
}, },
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'], plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
@ -81,7 +64,7 @@ module.exports = {
}, },
'import/resolver': { 'import/resolver': {
typescript: { typescript: {
project: 'packages/core/tsconfig.json', project: 'packages/core/tsconfig-dev.json',
}, // this loads <rootdir>/tsconfig.json to eslint }, // this loads <rootdir>/tsconfig.json to eslint
}, },
'import/core-modules': ['src'], 'import/core-modules': ['src'],

View File

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,8 +1,8 @@
--- ---
title: Something something something... title: Something something something2...
draft: false draft: false
date: 2022-11-01 06:30 date: 2022-11-01 06:30
image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg image: static-cms-icon.svg
--- ---
# Welcome # Welcome

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -0,0 +1 @@
Some text here!

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -25,7 +25,10 @@ collections:
and editing guidelines that are specific to a collection. and editing guidelines that are specific to a collection.
folder: _posts folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}' slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}' summary_fields:
- title
- date
- draft
sortable_fields: sortable_fields:
fields: fields:
- title - title
@ -219,7 +222,7 @@ collections:
time_format: 'h:mm aaa' time_format: 'h:mm aaa'
required: false required: false
- name: date_and_time_with_default - name: date_and_time_with_default
label: Date and Time With Deafult label: Date and Time With Default
widget: datetime widget: datetime
format: 'MMM d, yyyy h:mm aaa' format: 'MMM d, yyyy h:mm aaa'
date_format: 'MMM d, yyyy' date_format: 'MMM d, yyyy'
@ -233,22 +236,25 @@ collections:
time_format: false time_format: false
required: false required: false
- name: date_with_default - name: date_with_default
label: Date With Deafult label: Date With Default
widget: datetime widget: datetime
format: 'MMM d, yyyy' format: 'MMM d, yyyy'
date_format: 'MMM d, yyyy' date_format: 'MMM d, yyyy'
time_format: false
required: false required: false
default: 'Jan 12, 2023' default: 'Jan 12, 2023'
- name: time - name: time
label: Time label: Time
widget: datetime widget: datetime
format: 'h:mm aaa' format: 'h:mm aaa'
date_format: false
time_format: 'h:mm aaa' time_format: 'h:mm aaa'
required: false required: false
- name: time_with_default - name: time_with_default
label: Time With Deafult label: Time With Default
widget: datetime widget: datetime
format: 'h:mm aaa' format: 'h:mm aaa'
date_format: false
time_format: 'h:mm aaa' time_format: 'h:mm aaa'
required: false required: false
default: '12:00 am' default: '12:00 am'
@ -641,7 +647,7 @@ collections:
- date - date
search_fields: search_fields:
- title - title
- body - date
value_field: title value_field: title
- label: Required With Default - label: Required With Default
name: with_default name: with_default
@ -652,7 +658,7 @@ collections:
- date - date
search_fields: search_fields:
- title - title
- body - date
value_field: title value_field: title
default: This is a YAML front matter post default: This is a YAML front matter post
- label: Optional Validation - label: Optional Validation
@ -665,7 +671,7 @@ collections:
- date - date
search_fields: search_fields:
- title - title
- body - date
value_field: title value_field: title
- label: Multiple - label: Multiple
name: multiple name: multiple
@ -678,7 +684,7 @@ collections:
- date - date
search_fields: search_fields:
- title - title
- body - date
value_field: title value_field: title
- label: Multiple With Default - label: Multiple With Default
name: multiple_with_default name: multiple_with_default
@ -694,7 +700,7 @@ collections:
- date - date
search_fields: search_fields:
- title - title
- body - date
value_field: title value_field: title
- name: select - name: select
label: Select label: Select

View File

@ -6,7 +6,27 @@
<title>Static CMS Development Test</title> <title>Static CMS Development Test</title>
<script> <script>
window.repoFiles = { window.repoFiles = {
assets: {
uploads: {
'moby-dick.jpg': {
content: '',
},
'lobby.jpg': {
content: '',
},
},
},
_posts: { _posts: {
assets: {
uploads: {
'moby-dick.jpg': {
content: '',
},
'lobby.jpg': {
content: '',
},
},
},
'2015-02-14-this-is-a-post.md': { '2015-02-14-this-is-a-post.md': {
content: content:
'---\ntitle: This is a YAML front matter post\ndraft: true\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', '---\ntitle: This is a YAML front matter post\ndraft: true\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n',
@ -15,10 +35,6 @@
content: content:
'{\n"title": "This is a JSON front matter post",\n"draft": false,\n"image": "/assets/uploads/moby-dick.jpg",\n"date": "2015-02-15T00:00:00.000Z"\n}\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', '{\n"title": "This is a JSON front matter post",\n"draft": false,\n"image": "/assets/uploads/moby-dick.jpg",\n"date": "2015-02-15T00:00:00.000Z"\n}\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n',
}, },
'2015-02-16-this-is-a-toml-frontmatter-post.md': {
content:
'+++\ntitle = "This is a TOML front matter post"\nimage = "/assets/uploads/moby-dick.jpg"\ndate = "2015-02-16T00:00:00.000Z"\n+++\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n',
},
'2015-02-14-this-is-a-post-with-a-different-extension.other': { '2015-02-14-this-is-a-post-with-a-different-extension.other': {
content: content:
'---\ntitle: This post should not appear because the extension is different\ndraft: false\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', '---\ntitle: This post should not appear because the extension is different\ndraft: false\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n',
@ -104,15 +120,15 @@
_i18n_playground: { _i18n_playground: {
'file1.en.md': { 'file1.en.md': {
content: content:
'---\nslug: file1\ndescription: Coffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\ndate: 2015-02-14T00:00:00.000Z\n---\n' '---\nslug: file1\ndescription: Coffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\ndate: 2015-02-14T00:00:00.000Z\n---\n',
}, },
'file1.de.md': { 'file1.de.md': {
content: content:
'---\ndescription: Kaffee ist ein kleiner Baum oder Strauch, der in seiner wilden Form im Unterholz des Waldes wächst und traditionell kommerziell unter anderen Bäumen angebaut wurde, die Schatten spendeten. Die waldähnliche Struktur schattiger Kaffeefarmen bietet Lebensraum für eine Vielzahl von wandernden und ansässigen Arten.\ndate: 2015-02-14T00:00:00.000Z\n---\n' '---\ndescription: Kaffee ist ein kleiner Baum oder Strauch, der in seiner wilden Form im Unterholz des Waldes wächst und traditionell kommerziell unter anderen Bäumen angebaut wurde, die Schatten spendeten. Die waldähnliche Struktur schattiger Kaffeefarmen bietet Lebensraum für eine Vielzahl von wandernden und ansässigen Arten.\ndate: 2015-02-14T00:00:00.000Z\n---\n',
}, },
'file1.fr.md': { 'file1.fr.md': {
content: content:
'---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d\'autres arbres qui fournissaient de l\'ombre. La structure forestière des plantations de café d\'ombre fournit un habitat à un grand nombre d\'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n' "---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d'autres arbres qui fournissaient de l'ombre. La structure forestière des plantations de café d'ombre fournit un habitat à un grand nombre d'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n",
}, },
}, },
}; };

View File

@ -11,11 +11,15 @@ const PostPreview = ({ entry, widgetFor }) => {
); );
}; };
const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => { const PostPreviewCard = ({ entry, theme }) => {
const date = new Date(entry.data.date);
const month = date.getMonth() + 1;
const day = date.getDate();
return h( return h(
'div', 'div',
{ style: { width: '100%' } }, { style: { width: '100%' } },
viewStyle === 'grid' ? widgetFor('image') : null,
h( h(
'div', 'div',
{ style: { padding: '16px', width: '100%' } }, { style: { padding: '16px', width: '100%' } },
@ -27,6 +31,8 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
width: '100%', width: '100%',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'start', alignItems: 'start',
gap: '4px',
color: theme === 'dark' ? 'white' : 'inherit',
}, },
}, },
h( h(
@ -34,13 +40,31 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
{ {
style: { style: {
display: 'flex', display: 'flex',
flexDirection: viewStyle === 'grid' ? 'column' : 'row', flexDirection: 'column',
alignItems: 'baseline', alignItems: 'baseline',
gap: '8px', gap: '4px',
}, },
}, },
h('strong', { style: { fontSize: '24px' } }, entry.data.title), h(
h('span', { style: { fontSize: '16px' } }, entry.data.date), 'div',
{
style: {
fontSize: '14px',
fontWeight: 700,
color: 'rgb(107, 114, 128)',
fontSize: '14px',
lineHeight: '18px',
},
},
entry.data.title,
),
h(
'span',
{ style: { fontSize: '14px' } },
`${date.getFullYear()}-${month < 10 ? `0${month}` : month}-${
day < 10 ? `0${day}` : day
}`,
),
), ),
h( h(
'div', 'div',
@ -49,12 +73,13 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
backgroundColor: entry.data.draft === true ? 'blue' : 'green', backgroundColor: entry.data.draft === true ? 'blue' : 'green',
color: 'white', color: 'white',
border: 'none', border: 'none',
padding: '4px 8px', padding: '2px 6px',
textAlign: 'center', textAlign: 'center',
textDecoration: 'none', textDecoration: 'none',
display: 'inline-block', display: 'inline-block',
cursor: 'pointer', cursor: 'pointer',
borderRadius: '4px', borderRadius: '4px',
fontSize: '14px',
}, },
}, },
entry.data.draft === true ? 'Draft' : 'Published', entry.data.draft === true ? 'Draft' : 'Published',
@ -64,6 +89,40 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
); );
}; };
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 GeneralPreview = ({ widgetsFor, entry, collection }) => {
const title = entry.data.site_title; const title = entry.data.site_title;
const posts = entry.data.posts; const posts = entry.data.posts;
@ -134,6 +193,8 @@ const CustomPage = () => {
CMS.registerPreviewTemplate('posts', PostPreview); CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerPreviewCard('posts', PostPreviewCard); CMS.registerPreviewCard('posts', PostPreviewCard);
CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview);
CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview);
CMS.registerPreviewTemplate('general', GeneralPreview); CMS.registerPreviewTemplate('general', GeneralPreview);
CMS.registerPreviewTemplate('authors', AuthorsPreview); CMS.registerPreviewTemplate('authors', AuthorsPreview);
// Pass the name of a registered control to reuse with a new widget preview. // Pass the name of a registered control to reuse with a new widget preview.
@ -170,7 +231,8 @@ CMS.registerShortcode('youtube', {
toArgs: ({ src }) => { toArgs: ({ src }) => {
return [src]; return [src];
}, },
control: ({ src, onChange }) => { control: ({ src, onChange, theme }) => {
console.log('[SHORTCUT] shortcut theme', theme);
return h('span', {}, [ return h('span', {}, [
h('input', { h('input', {
key: 'control-input', key: 'control-input',
@ -178,12 +240,18 @@ CMS.registerShortcode('youtube', {
onChange: event => { onChange: event => {
onChange({ src: event.target.value }); 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( h(
'iframe', 'iframe',
{ {
key: 'control-preview', key: 'control-preview',
width: '420', width: '100%',
height: '315', height: '315',
src: `https://www.youtube.com/embed/${src}`, src: `https://www.youtube.com/embed/${src}`,
}, },

View File

@ -13,6 +13,7 @@ module.exports = {
'\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts', '\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts',
}, },
setupFiles: ['./test/setupEnv.js'], setupFiles: ['./test/setupEnv.js'],
globalSetup: './test/globalSetup.js',
testRegex: '\\.spec\\.tsx?$', testRegex: '\\.spec\\.tsx?$',
snapshotSerializers: ['@emotion/jest/serializer'], snapshotSerializers: ['@emotion/jest/serializer'],
}; };

View File

@ -58,7 +58,7 @@
"@codemirror/search": "6.2.3", "@codemirror/search": "6.2.3",
"@codemirror/state": "6.2.0", "@codemirror/state": "6.2.0",
"@codemirror/theme-one-dark": "6.1.1", "@codemirror/theme-one-dark": "6.1.1",
"@codemirror/view": "6.9.1", "@codemirror/view": "6.9.3",
"@dnd-kit/core": "6.0.8", "@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2", "@dnd-kit/sortable": "7.0.2",
"@dnd-kit/utilities": "3.2.1", "@dnd-kit/utilities": "3.2.1",
@ -66,16 +66,23 @@
"@emotion/css": "11.10.6", "@emotion/css": "11.10.6",
"@emotion/react": "11.10.6", "@emotion/react": "11.10.6",
"@emotion/styled": "11.10.6", "@emotion/styled": "11.10.6",
"@headlessui/react": "1.7.7",
"@lezer/common": "1.0.2", "@lezer/common": "1.0.2",
"@mdx-js/mdx": "2.3.0", "@mdx-js/mdx": "2.3.0",
"@mdx-js/react": "2.3.0", "@mdx-js/react": "2.3.0",
"@mui/icons-material": "5.11.11", "@mui/base": "5.0.0-alpha.122",
"@mui/material": "5.11.13", "@mui/material": "5.11.13",
"@mui/system": "5.11.13", "@mui/system": "5.11.13",
"@mui/x-date-pickers": "5.0.20", "@mui/x-date-pickers": "5.0.20",
"@reduxjs/toolkit": "1.9.3", "@reduxjs/toolkit": "1.9.3",
"@styled-icons/bootstrap": "10.47.0",
"@styled-icons/fa-brands": "10.47.0",
"@styled-icons/fluentui-system-regular": "10.47.0", "@styled-icons/fluentui-system-regular": "10.47.0",
"@styled-icons/heroicons-outline": "10.47.0",
"@styled-icons/material": "10.47.0",
"@styled-icons/material-rounded": "10.47.0",
"@styled-icons/remix-editor": "10.46.0", "@styled-icons/remix-editor": "10.46.0",
"@styled-icons/simple-icons": "10.46.0",
"@udecode/plate": "19.7.0", "@udecode/plate": "19.7.0",
"@udecode/plate-juice": "20.0.0", "@udecode/plate-juice": "20.0.0",
"@udecode/plate-serializer-md": "20.0.0", "@udecode/plate-serializer-md": "20.0.0",
@ -84,7 +91,6 @@
"ajv": "8.12.0", "ajv": "8.12.0",
"ajv-errors": "3.0.0", "ajv-errors": "3.0.0",
"ajv-keywords": "5.1.0", "ajv-keywords": "5.1.0",
"array-move": "4.0.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"clean-stack": "5.1.0", "clean-stack": "5.1.0",
"codemirror": "6.0.1", "codemirror": "6.0.1",
@ -193,7 +199,7 @@
"@types/jwt-decode": "2.2.1", "@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/minimatch": "5.1.2", "@types/minimatch": "5.1.2",
"@types/node": "16.18.16", "@types/node": "18.11.18",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"@types/react": "18.0.28", "@types/react": "18.0.28",
"@types/react-color": "3.0.6", "@types/react-color": "3.0.6",
@ -205,6 +211,7 @@
"@types/uuid": "9.0.1", "@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.55.0", "@typescript-eslint/eslint-plugin": "5.55.0",
"@typescript-eslint/parser": "5.55.0", "@typescript-eslint/parser": "5.55.0",
"autoprefixer": "10.4.13",
"axios": "1.3.4", "axios": "1.3.4",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"babel-loader": "9.1.2", "babel-loader": "9.1.2",
@ -237,13 +244,14 @@
"jest": "29.5.0", "jest": "29.5.0",
"jest-environment-jsdom": "29.5.0", "jest-environment-jsdom": "29.5.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"mini-css-extract-plugin": "2.7.2",
"mockserver-client": "5.15.0", "mockserver-client": "5.15.0",
"mockserver-node": "5.15.0", "mockserver-node": "5.15.0",
"ncp": "2.0.0", "ncp": "2.0.0",
"node-fetch": "3.3.1", "node-fetch": "3.3.1",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"postcss": "8.4.21", "postcss": "8.4.21",
"postcss-scss": "4.0.6", "postcss-loader": "7.1.0",
"prettier": "2.8.4", "prettier": "2.8.4",
"process": "0.11.10", "process": "0.11.10",
"react-refresh": "0.14.0", "react-refresh": "0.14.0",
@ -252,6 +260,7 @@
"simple-git": "3.17.0", "simple-git": "3.17.0",
"source-map-loader": "4.0.1", "source-map-loader": "4.0.1",
"style-loader": "3.3.2", "style-loader": "3.3.2",
"tailwindcss": "3.2.4",
"to-string-loader": "1.2.0", "to-string-loader": "1.2.0",
"ts-jest": "29.0.5", "ts-jest": "29.0.5",
"tsconfig-paths-webpack-plugin": "4.0.1", "tsconfig-paths-webpack-plugin": "4.0.1",

View File

@ -0,0 +1,7 @@
module.exports = {
plugins: {
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -1,4 +0,0 @@
/* eslint-disable import/prefer-default-export */
const mockDisplatch = jest.fn();
export const useAppDispatch = jest.fn().mockReturnValue(mockDisplatch);
export const useAppSelector = jest.fn();

View File

@ -0,0 +1 @@
export default jest.fn();

View File

@ -0,0 +1,14 @@
/* eslint-disable react/display-name */
/* eslint-disable import/prefer-default-export */
import React from 'react';
import type { FC } from 'react';
export const translate = () => (Component: FC) => {
const t = (key: string, _options: unknown) => key;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (props: any) => {
return React.createElement(Component, { t, ...props });
};
};

View File

@ -0,0 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/display-name */
import React from 'react';
export default function (props: any) {
const { children } = props;
return React.createElement('div', {}, children({ width: 500, height: 1000 }));
}

View File

@ -0,0 +1,6 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
export function Waypoint() {
return React.createElement('div', {}, []);
}

View File

@ -52,14 +52,14 @@ import { addSnackbar } from '../store/slices/snackbars';
import { createAssetProxy } from '../valueObjects/AssetProxy'; import { createAssetProxy } from '../valueObjects/AssetProxy';
import createEntry from '../valueObjects/createEntry'; import createEntry from '../valueObjects/createEntry';
import { addAssets, getAsset } from './media'; import { addAssets, getAsset } from './media';
import { loadMedia, waitForMediaLibraryToLoad } from './mediaLibrary'; import { loadMedia } from './mediaLibrary';
import { waitUntil } from './waitUntil'; import { waitUntil } from './waitUntil';
import type { NavigateFunction } from 'react-router-dom'; import type { NavigateFunction } from 'react-router-dom';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
import type { Backend } from '../backend'; import type { Backend } from '../backend';
import type { CollectionViewStyle } from '../constants/collectionViews'; import type { ViewStyle } from '../constants/views';
import type { import type {
Collection, Collection,
Entry, Entry,
@ -345,7 +345,7 @@ export function groupByField(collection: Collection, group: ViewGroup) {
}; };
} }
export function changeViewStyle(viewStyle: CollectionViewStyle) { export function changeViewStyle(viewStyle: ViewStyle) {
return { return {
type: CHANGE_VIEW_STYLE, type: CHANGE_VIEW_STYLE,
payload: { payload: {
@ -586,12 +586,12 @@ export function deleteLocalBackup(collection: Collection, slug: string) {
export function loadEntry(collection: Collection, slug: string, silent = false) { export function loadEntry(collection: Collection, slug: string, silent = false) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
await waitForMediaLibraryToLoad(dispatch, getState());
if (!silent) { if (!silent) {
dispatch(entryLoading(collection, slug)); dispatch(entryLoading(collection, slug));
} }
try { try {
await dispatch(loadMedia());
const loadedEntry = await tryLoadEntry(getState(), collection, slug); const loadedEntry = await tryLoadEntry(getState(), collection, slug);
dispatch(entryLoaded(collection, loadedEntry)); dispatch(entryLoaded(collection, loadedEntry));
dispatch(createDraftFromEntry(loadedEntry)); dispatch(createDraftFromEntry(loadedEntry));
@ -836,10 +836,6 @@ export function createEmptyDraft(collection: Collection, search: string) {
const backend = currentBackend(configState.config); const backend = currentBackend(configState.config);
if (!('media_folder' in collection)) {
await waitForMediaLibraryToLoad(dispatch, getState());
}
const i18nFields = createEmptyDraftI18nData(collection, fields); const i18nFields = createEmptyDraftI18nData(collection, fields);
let newEntry = createEntry(collection.name, '', '', { let newEntry = createEntry(collection.name, '', '', {

View File

@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
import { THEME_CHANGE } from '../constants';
export function changeTheme(theme: 'dark' | 'light') {
return { type: THEME_CHANGE, payload: theme } as const;
}
export type GlobalUIAction = ReturnType<typeof changeTheme>;

View File

@ -8,7 +8,7 @@ import {
} from '../constants'; } from '../constants';
import { selectMediaFilePath } from '../lib/util/media.util'; import { selectMediaFilePath } from '../lib/util/media.util';
import { createAssetProxy } from '../valueObjects/AssetProxy'; import { createAssetProxy } from '../valueObjects/AssetProxy';
import { getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary'; import { getMediaFile } from './mediaLibrary';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
@ -55,7 +55,7 @@ async function loadAsset(
try { try {
dispatch(loadAssetRequest(resolvedPath)); dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend // load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState()); // await waitForMediaLibraryToLoad(dispatch, getState());
const { url } = await getMediaFile(getState(), resolvedPath); const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url }); const asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset)); dispatch(addAsset(asset));

View File

@ -1,5 +1,5 @@
import { currentBackend } from '../backend'; import { currentBackend } from '../backend';
import confirm from '../components/UI/Confirm'; import confirm from '../components/common/confirm/Confirm';
import { import {
MEDIA_DELETE_FAILURE, MEDIA_DELETE_FAILURE,
MEDIA_DELETE_REQUEST, MEDIA_DELETE_REQUEST,
@ -39,6 +39,7 @@ import type {
Field, Field,
ImplementationMediaFile, ImplementationMediaFile,
MediaFile, MediaFile,
MediaLibrarInsertOptions,
MediaLibraryInstance, MediaLibraryInstance,
UnknownField, UnknownField,
} from '../interface'; } from '../interface';
@ -81,11 +82,13 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
controlID?: string; controlID?: string;
forImage?: boolean; forImage?: boolean;
value?: string | string[]; value?: string | string[];
alt?: string;
allowMultiple?: boolean; allowMultiple?: boolean;
replaceIndex?: number; replaceIndex?: number;
config?: Record<string, unknown>; config?: Record<string, unknown>;
collection?: Collection<F>; collection?: Collection<F>;
field?: F; field?: F;
insertOptions?: MediaLibrarInsertOptions;
} = {}, } = {},
) { ) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
@ -94,12 +97,14 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
const { const {
controlID, controlID,
value, value,
alt,
config = {}, config = {},
allowMultiple, allowMultiple,
forImage, forImage,
replaceIndex, replaceIndex,
collection, collection,
field, field,
insertOptions,
} = payload; } = payload;
if (mediaLibrary) { if (mediaLibrary) {
@ -111,11 +116,13 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
controlID, controlID,
forImage, forImage,
value, value,
alt,
allowMultiple, allowMultiple,
replaceIndex, replaceIndex,
config, config,
collection: collection as Collection, collection: collection as Collection,
field: field as Field, field: field as Field,
insertOptions,
}), }),
); );
}; };
@ -132,7 +139,7 @@ export function closeMediaLibrary() {
}; };
} }
export function insertMedia(mediaPath: string | string[], field: Field | undefined) { export function insertMedia(mediaPath: string | string[], field: Field | undefined, alt?: string) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState(); const state = getState();
const config = state.config.config; const config = state.config.config;
@ -150,7 +157,7 @@ export function insertMedia(mediaPath: string | string[], field: Field | undefin
} else { } else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field); mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
} }
dispatch(mediaInserted(mediaPath)); dispatch(mediaInserted(mediaPath, alt));
}; };
} }
@ -230,7 +237,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
} }
const backend = currentBackend(config); const backend = currentBackend(config);
const files: MediaFile[] = selectMediaFiles(state, field); const files: MediaFile[] = selectMediaFiles(field)(state);
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug); const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName); const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
@ -417,11 +424,13 @@ function mediaLibraryOpened(payload: {
controlID?: string; controlID?: string;
forImage?: boolean; forImage?: boolean;
value?: string | string[]; value?: string | string[];
alt?: string;
replaceIndex?: number; replaceIndex?: number;
allowMultiple?: boolean; allowMultiple?: boolean;
config?: Record<string, unknown>; config?: Record<string, unknown>;
collection?: Collection; collection?: Collection;
field?: Field; field?: Field;
insertOptions?: MediaLibrarInsertOptions;
}) { }) {
return { type: MEDIA_LIBRARY_OPEN, payload } as const; return { type: MEDIA_LIBRARY_OPEN, payload } as const;
} }
@ -430,8 +439,8 @@ function mediaLibraryClosed() {
return { type: MEDIA_LIBRARY_CLOSE } as const; return { type: MEDIA_LIBRARY_CLOSE } as const;
} }
function mediaInserted(mediaPath: string | string[]) { export function mediaInserted(mediaPath: string | string[], alt?: string) {
return { type: MEDIA_INSERT, payload: { mediaPath } } as const; return { type: MEDIA_INSERT, payload: { mediaPath, alt } } as const;
} }
export function mediaLoading(page: number) { export function mediaLoading(page: number) {

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material/styles'; import { Bitbucket as BitbucketIcon } from '@styled-icons/fa-brands/Bitbucket';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage'; import Login from '@staticcms/core/components/login/Login';
import Icon from '@staticcms/core/components/UI/Icon';
import { ImplicitAuthenticator, NetlifyAuthenticator } from '@staticcms/core/lib/auth'; import { ImplicitAuthenticator, NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const BitbucketAuthenticationPage = ({ const BitbucketAuthenticationPage = ({
inProgress = false, inProgress = false,
@ -80,15 +75,12 @@ const BitbucketAuthenticationPage = ({
); );
return ( return (
<AuthenticationPage <Login
onLogin={handleLogin} login={handleLogin}
loginDisabled={inProgress} label={t('auth.loginWithBitbucket')}
loginErrorMessage={loginError} icon={BitbucketIcon}
logoUrl={config.logo_url} inProgress={inProgress}
siteUrl={config.site_url} error={loginError}
icon={<LoginButtonIcon type="bitbucket" />}
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')}
t={t}
/> />
); );
}; };

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage'; import Login from '@staticcms/core/components/login/Login';
import type { AuthenticationPageProps, TranslatedProps, User } from '@staticcms/core/interface'; import type { AuthenticationPageProps, TranslatedProps, User } from '@staticcms/core/interface';
@ -22,11 +22,7 @@ export interface GitGatewayAuthenticationPageProps
handleAuth: (email: string, password: string) => Promise<User | string>; handleAuth: (email: string, password: string) => Promise<User | string>;
} }
const GitGatewayAuthenticationPage = ({ const GitGatewayAuthenticationPage = ({ onLogin, t }: GitGatewayAuthenticationPageProps) => {
config,
onLogin,
t,
}: GitGatewayAuthenticationPageProps) => {
const [loggingIn, setLoggingIn] = useState(false); const [loggingIn, setLoggingIn] = useState(false);
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
@ -97,7 +93,7 @@ const GitGatewayAuthenticationPage = ({
} }
}, [onLogin]); }, [onLogin]);
const pageContent = useMemo(() => { const errorContent = useMemo(() => {
if (!window.netlifyIdentity) { if (!window.netlifyIdentity) {
return t('auth.errors.netlifyIdentityNotFound'); return t('auth.errors.netlifyIdentityNotFound');
} }
@ -118,15 +114,11 @@ const GitGatewayAuthenticationPage = ({
}, [errors.identity, t]); }, [errors.identity, t]);
return ( return (
<AuthenticationPage <Login
key="git-gateway-auth" login={handleIdentity}
logoUrl={config.logo_url} label={t('auth.loginWithNetlifyIdentity')}
siteUrl={config.site_url} inProgress={loggingIn}
onLogin={handleIdentity} error={errorContent}
buttonContent={t('auth.loginWithNetlifyIdentity')}
pageContent={pageContent}
loginDisabled={loggingIn}
t={t}
/> />
); );
}; };

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material/styles'; import { Gitea as GiteaIcon } from '@styled-icons/simple-icons/Gitea';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage'; import Login from '@staticcms/core/components/login/Login';
import Icon from '@staticcms/core/components/UI/Icon';
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth'; import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const GiteaAuthenticationPage = ({ const GiteaAuthenticationPage = ({
inProgress = false, inProgress = false,
@ -48,15 +43,12 @@ const GiteaAuthenticationPage = ({
); );
return ( return (
<AuthenticationPage <Login
onLogin={handleLogin} login={handleLogin}
loginDisabled={inProgress} label={t('auth.loginWithGitea')}
loginErrorMessage={loginError} icon={GiteaIcon}
logoUrl={config.logo_url} inProgress={inProgress}
siteUrl={config.site_url} error={loginError}
icon={<LoginButtonIcon type="gitea" />}
buttonContent={t('auth.loginWithGitea')}
t={t}
/> />
); );
}; };

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material/styles'; import { Github as GithubIcon } from '@styled-icons/simple-icons/Github';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage'; import Login from '@staticcms/core/components/login/Login';
import Icon from '@staticcms/core/components/UI/Icon';
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth'; import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const GitHubAuthenticationPage = ({ const GitHubAuthenticationPage = ({
inProgress = false, inProgress = false,
@ -48,15 +43,12 @@ const GitHubAuthenticationPage = ({
); );
return ( return (
<AuthenticationPage <Login
onLogin={handleLogin} login={handleLogin}
loginDisabled={inProgress} label={t('auth.loginWithGitHub')}
loginErrorMessage={loginError} icon={GithubIcon}
logoUrl={config.logo_url} inProgress={inProgress}
siteUrl={config.site_url} error={loginError}
icon={<LoginButtonIcon type="github" />}
buttonContent={t('auth.loginWithGitHub')}
t={t}
/> />
); );
}; };

View File

@ -1,21 +1,16 @@
import { styled } from '@mui/material/styles'; import { Gitlab as GitlabIcon } from '@styled-icons/simple-icons/Gitlab';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage'; import Login from '@staticcms/core/components/login/Login';
import Icon from '@staticcms/core/components/UI/Icon';
import { NetlifyAuthenticator, PkceAuthenticator } from '@staticcms/core/lib/auth'; import { NetlifyAuthenticator, PkceAuthenticator } from '@staticcms/core/lib/auth';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import type { MouseEvent } from 'react';
import type { import type {
AuthenticationPageProps, AuthenticationPageProps,
AuthenticatorConfig, AuthenticatorConfig,
TranslatedProps, TranslatedProps,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const clientSideAuthenticators = { const clientSideAuthenticators = {
pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config), pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config),
@ -83,15 +78,12 @@ const GitLabAuthenticationPage = ({
); );
return ( return (
<AuthenticationPage <Login
onLogin={handleLogin} login={handleLogin}
loginDisabled={inProgress} label={t('auth.loginWithGitLab')}
loginErrorMessage={loginError} icon={GitlabIcon}
logoUrl={config.logo_url} inProgress={inProgress}
siteUrl={config.site_url} error={loginError}
icon={<LoginButtonIcon type="gitlab" />}
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')}
t={t}
/> />
); );
}; };

View File

@ -1,30 +1,13 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import GoBackButton from '@staticcms/core/components/UI/GoBackButton'; import Login from '@staticcms/core/components/login/Login';
import Icon from '@staticcms/core/components/UI/Icon';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const StyledAuthenticationPage = styled('section')`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
`;
const PageLogoIcon = styled(Icon)`
color: #c4c6d2;
`;
const AuthenticationPage = ({ const AuthenticationPage = ({
inProgress = false, inProgress = false,
config,
onLogin, onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => { }: TranslatedProps<AuthenticationPageProps>) => {
const handleLogin = useCallback( const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => { (e: MouseEvent<HTMLButtonElement>) => {
@ -34,15 +17,7 @@ const AuthenticationPage = ({
[onLogin], [onLogin],
); );
return ( return <Login login={handleLogin} inProgress={inProgress} />;
<StyledAuthenticationPage>
<PageLogoIcon width={300} height={150} type="static-cms" />
<Button variant="contained" disabled={inProgress} onClick={handleLogin}>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</Button>
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
</StyledAuthenticationPage>
);
}; };
export default AuthenticationPage; export default AuthenticationPage;

View File

@ -1,30 +1,14 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import GoBackButton from '@staticcms/core/components/UI/GoBackButton'; import Login from '@staticcms/core/components/login/Login';
import Icon from '@staticcms/core/components/UI/Icon';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const StyledAuthenticationPage = styled('section')`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
`;
const PageLogoIcon = styled(Icon)`
color: #c4c6d2;
`;
const AuthenticationPage = ({ const AuthenticationPage = ({
inProgress = false, inProgress = false,
config, config,
onLogin, onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => { }: TranslatedProps<AuthenticationPageProps>) => {
useEffect(() => { useEffect(() => {
/** /**
@ -44,20 +28,7 @@ const AuthenticationPage = ({
[onLogin], [onLogin],
); );
return ( return <Login login={handleLogin} inProgress={inProgress} />;
<StyledAuthenticationPage>
<PageLogoIcon width={300} height={150} type="static-cms" />
<Button
disabled={inProgress}
onClick={handleLogin}
variant="contained"
sx={{ marginBottom: '32px' }}
>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</Button>
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
</StyledAuthenticationPage>
);
}; };
export default AuthenticationPage; export default AuthenticationPage;

View File

@ -5,16 +5,17 @@ import unset from 'lodash/unset';
import { extname } from 'path'; import { extname } from 'path';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { basename, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util'; import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import type { import type {
BackendEntry,
BackendClass, BackendClass,
BackendEntry,
Config, Config,
DisplayURL, DisplayURL,
ImplementationEntry, ImplementationEntry,
ImplementationFile, ImplementationFile,
ImplementationMediaFile,
User, User,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
@ -215,37 +216,35 @@ export default class TestBackend implements BackendClass {
return Promise.resolve(); return Promise.resolve();
} }
async getMedia(mediaFolder = this.mediaFolder) { async getMedia(mediaFolder = this.mediaFolder): Promise<ImplementationMediaFile[]> {
if (!mediaFolder) { if (!mediaFolder) {
return []; return [];
} }
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f => const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
f.path.startsWith(mediaFolder), f.path.startsWith(mediaFolder),
); );
return files.map(f => this.normalizeAsset(f.content as AssetProxy)); return files.map(f => ({
name: f.path,
id: f.path,
path: f.path,
displayURL: f.path,
}));
} }
async getMediaFile(path: string) { async getMediaFile(path: string) {
const asset = getFile(path, window.repoFiles).content as AssetProxy;
const url = asset?.toString() ?? '';
const name = basename(path);
const blob = await fetch(url).then(res => res.blob());
const fileObj = new File([blob], name);
return { return {
id: url, id: path,
displayURL: url, displayURL: path,
path, path,
name, name: path,
size: fileObj.size, size: 1,
file: fileObj, url: path,
url,
}; };
} }
normalizeAsset(assetProxy: AssetProxy) { normalizeAsset(assetProxy: AssetProxy): ImplementationMediaFile {
const fileObj = assetProxy.fileObj as File; const fileObj = assetProxy.fileObj as File;
const { name, size } = fileObj; const { name, size } = fileObj;
const objectUrl = attempt(window.URL.createObjectURL, fileObj); const objectUrl = attempt(window.URL.createObjectURL, fileObj);
const url = isError(objectUrl) ? '' : objectUrl; const url = isError(objectUrl) ? '' : objectUrl;

View File

@ -9,9 +9,9 @@ import { HashRouter as Router } from 'react-router-dom';
import 'what-input'; import 'what-input';
import { authenticateUser } from './actions/auth'; import { authenticateUser } from './actions/auth';
import { loadConfig } from './actions/config'; import { loadConfig } from './actions/config';
import App from './components/App/App'; import App from './components/App';
import './components/EditorWidgets'; import './components/entry-editor/widgets';
import { ErrorBoundary } from './components/UI'; import ErrorBoundary from './components/ErrorBoundary';
import addExtensions from './extensions'; import addExtensions from './extensions';
import { getPhrases } from './lib/phrases'; import { getPhrases } from './lib/phrases';
import './mediaLibrary'; import './mediaLibrary';
@ -23,6 +23,12 @@ import type { ConnectedProps } from 'react-redux';
import type { BaseField, Config, UnknownField } from './interface'; import type { BaseField, Config, UnknownField } from './interface';
import type { RootState } from './store'; import type { RootState } from './store';
import './styles/datetime/calendar.css';
import './styles/datetime/clock.css';
import './styles/datetime/datetime.css';
import './styles/inter.css';
import './styles/main.css';
const ROOT_ID = 'nc-root'; const ROOT_ID = 'nc-root';
const TranslatedApp = ({ locale, config }: AppRootProps) => { const TranslatedApp = ({ locale, config }: AppRootProps) => {

View File

@ -1,6 +1,4 @@
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import { createTheme, ThemeProvider } from '@mui/material/styles';
import Fab from '@mui/material/Fab';
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -19,19 +17,19 @@ import TopBarProgress from 'react-topbar-progress-indicator';
import { loginUser as loginUserAction } from '@staticcms/core/actions/auth'; import { loginUser as loginUserAction } from '@staticcms/core/actions/auth';
import { discardDraft } from '@staticcms/core/actions/entries'; import { discardDraft } from '@staticcms/core/actions/entries';
import { currentBackend } from '@staticcms/core/backend'; import { currentBackend } from '@staticcms/core/backend';
import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles'; import { changeTheme } from '../actions/globalUI';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { getDefaultPath } from '../lib/util/collection.util';
import { getDefaultPath } from '../../lib/util/collection.util'; import { selectTheme } from '../reducers/selectors/globalUI';
import CollectionRoute from '../collection/CollectionRoute'; import { useAppDispatch, useAppSelector } from '../store/hooks';
import EditorRoute from '../editor/EditorRoute'; import CollectionRoute from './collections/CollectionRoute';
import MediaLibrary from '../MediaLibrary/MediaLibrary'; import { Alert } from './common/alert/Alert';
import Page from '../page/Page'; import { Confirm } from './common/confirm/Confirm';
import Snackbars from '../snackbar/Snackbars'; import Loader from './common/progress/Loader';
import { Alert } from '../UI/Alert'; import EditorRoute from './entry-editor/EditorRoute';
import { Confirm } from '../UI/Confirm'; import MediaPage from './media-library/MediaPage';
import Loader from '../UI/Loader';
import ScrollTop from '../UI/ScrollTop';
import NotFoundPage from './NotFoundPage'; import NotFoundPage from './NotFoundPage';
import Page from './page/Page';
import Snackbars from './snackbar/Snackbars';
import type { Credentials, TranslatedProps } from '@staticcms/core/interface'; import type { Credentials, TranslatedProps } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; import type { RootState } from '@staticcms/core/store';
@ -40,35 +38,16 @@ import type { ConnectedProps } from 'react-redux';
TopBarProgress.config({ TopBarProgress.config({
barColors: { barColors: {
0: colors.active, 0: '#000',
'1.0': colors.active, '1.0': '#000',
}, },
shadowBlur: 0, shadowBlur: 0,
barThickness: 2, barThickness: 2,
}); });
const AppRoot = styled('div')` window.addEventListener('beforeunload', function (event) {
width: 100%; event.stopImmediatePropagation();
min-width: 1200px; });
height: 100vh;
position: relative;
`;
const AppWrapper = styled('div')`
width: 100%;
min-width: 1200px;
min-height: 100vh;
`;
const ErrorContainer = styled('div')`
margin: 20px;
`;
const ErrorCodeBlock = styled('pre')`
margin-left: 20px;
font-size: 15px;
line-height: 1.5;
`;
function CollectionSearchRedirect() { function CollectionSearchRedirect() {
const { name } = useParams(); const { name } = useParams();
@ -87,24 +66,43 @@ const App = ({
collections, collections,
loginUser, loginUser,
isFetching, isFetching,
useMediaLibrary,
t, t,
scrollSyncEnabled, scrollSyncEnabled,
}: TranslatedProps<AppProps>) => { }: TranslatedProps<AppProps>) => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const mode = useAppSelector(selectTheme);
const theme = React.useMemo(
() =>
createTheme({
palette: {
mode,
primary: {
main: 'rgb(37 99 235)',
},
...(mode === 'dark' && {
background: {
paper: 'rgb(15 23 42)',
},
}),
},
}),
[mode],
);
const configError = useCallback( const configError = useCallback(
(error?: string) => { (error?: string) => {
return ( return (
<ErrorContainer> <div>
<h1>{t('app.app.errorHeader')}</h1> <h1>{t('app.app.errorHeader')}</h1>
<div> <div>
<strong>{t('app.app.configErrors')}:</strong> <strong>{t('app.app.configErrors')}:</strong>
<ErrorCodeBlock>{error ?? config.error}</ErrorCodeBlock> <div>{error ?? config.error}</div>
<span>{t('app.app.checkConfigYml')}</span> <span>{t('app.app.checkConfigYml')}</span>
</div> </div>
</ErrorContainer> </div>
); );
}, },
[config.error, t], [config.error, t],
@ -140,20 +138,18 @@ const App = ({
} }
return ( return (
<div key="auth-page-wrapper"> <AuthComponent
<AuthComponent key="auth-page"
key="auth-page" onLogin={handleLogin}
onLogin={handleLogin} error={auth.error}
error={auth.error} inProgress={auth.isFetching}
inProgress={auth.isFetching} siteId={config.config.backend.site_domain}
siteId={config.config.backend.site_domain} base_url={config.config.backend.base_url}
base_url={config.config.backend.base_url} authEndpoint={config.config.backend.auth_endpoint}
authEndpoint={config.config.backend.auth_endpoint} config={config.config}
config={config.config} clearHash={() => navigate('/', { replace: true })}
clearHash={() => navigate('/', { replace: true })} t={t}
t={t} />
/>
</div>
); );
}, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]); }, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]);
@ -173,6 +169,21 @@ const App = ({
dispatch(discardDraft()); dispatch(discardDraft());
}, [dispatch, pathname, searchParams]); }, [dispatch, pathname, searchParams]);
useEffect(() => {
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (
localStorage.getItem('color-theme') === 'dark' ||
(!('color-theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
dispatch(changeTheme('dark'));
} else {
document.documentElement.classList.remove('dark');
dispatch(changeTheme('light'));
}
}, [dispatch]);
const content = useMemo(() => { const content = useMemo(() => {
if (!user) { if (!user) {
return authenticationPage; return authenticationPage;
@ -189,11 +200,8 @@ const App = ({
path="/error=access_denied&error_description=Signups+not+allowed+for+this+instance" path="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
element={<Navigate to={defaultPath} />} element={<Navigate to={defaultPath} />}
/> />
<Route path="/collections" element={<CollectionRoute collections={collections} />} /> <Route path="/collections" element={<CollectionRoute />} />
<Route <Route path="/collections/:name" element={<CollectionRoute />} />
path="/collections/:name"
element={<CollectionRoute collections={collections} />}
/>
<Route <Route
path="/collections/:name/new" path="/collections/:name/new"
element={<EditorRoute collections={collections} newRecord />} element={<EditorRoute collections={collections} newRecord />}
@ -204,26 +212,18 @@ const App = ({
/> />
<Route <Route
path="/collections/:name/search/:searchTerm" path="/collections/:name/search/:searchTerm"
element={ element={<CollectionRoute isSearchResults isSingleSearchResult />}
<CollectionRoute collections={collections} isSearchResults isSingleSearchResult />
}
/>
<Route
path="/collections/:name/filter/:filterTerm"
element={<CollectionRoute collections={collections} />}
/>
<Route
path="/search/:searchTerm"
element={<CollectionRoute collections={collections} isSearchResults />}
/> />
<Route path="/collections/:name/filter/:filterTerm" element={<CollectionRoute />} />
<Route path="/search/:searchTerm" element={<CollectionRoute isSearchResults />} />
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} /> <Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
<Route path="/page/:id" element={<Page />} /> <Route path="/page/:id" element={<Page />} />
<Route path="/media" element={<MediaPage />} />
<Route element={<NotFoundPage />} /> <Route element={<NotFoundPage />} />
</Routes> </Routes>
{useMediaLibrary ? <MediaLibrary /> : null}
</> </>
); );
}, [authenticationPage, collections, defaultPath, isFetching, useMediaLibrary, user]); }, [authenticationPage, collections, defaultPath, isFetching, user]);
if (!config.config) { if (!config.config) {
return configError(t('app.app.configNotFound')); return configError(t('app.app.configNotFound'));
@ -238,35 +238,28 @@ const App = ({
} }
return ( return (
<> <ThemeProvider theme={theme}>
<GlobalStyles key="global-styles" />
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}> <ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
<> <>
<div key="back-to-top-anchor" id="back-to-top-anchor" /> <div key="back-to-top-anchor" id="back-to-top-anchor" />
<AppRoot key="cms-root" id="cms-root"> <div key="cms-root" id="cms-root" className="h-full">
<AppWrapper key="cms-wrapper" className="cms-wrapper"> <div key="cms-wrapper" className="cms-wrapper">
<Snackbars key="snackbars" /> <Snackbars key="snackbars" />
{content} {content}
<Alert key="alert" /> <Alert key="alert" />
<Confirm key="confirm" /> <Confirm key="confirm" />
</AppWrapper> </div>
</AppRoot> </div>
<ScrollTop key="scroll-to-top">
<Fab size="small" aria-label="scroll back to top">
<KeyboardArrowUpIcon />
</Fab>
</ScrollTop>
</> </>
</ScrollSync> </ScrollSync>
</> </ThemeProvider>
); );
}; };
function mapStateToProps(state: RootState) { function mapStateToProps(state: RootState) {
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state; const { auth, config, collections, globalUI, scroll } = state;
const user = auth.user; const user = auth.user;
const isFetching = globalUI.isFetching; const isFetching = globalUI.isFetching;
const useMediaLibrary = !mediaLibrary.externalLibrary;
const scrollSyncEnabled = scroll.isScrolling; const scrollSyncEnabled = scroll.isScrolling;
return { return {
auth, auth,
@ -274,7 +267,6 @@ function mapStateToProps(state: RootState) {
collections, collections,
user, user,
isFetching, isFetching,
useMediaLibrary,
scrollSyncEnabled, scrollSyncEnabled,
}; };
} }

View File

@ -1,213 +0,0 @@
import DescriptionIcon from '@mui/icons-material/Description';
import ImageIcon from '@mui/icons-material/Image';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import AppBar from '@mui/material/AppBar';
import Button from '@mui/material/Button';
import Link from '@mui/material/Link';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';
import Toolbar from '@mui/material/Toolbar';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { logoutUser as logoutUserAction } from '@staticcms/core/actions/auth';
import { openMediaLibrary as openMediaLibraryAction } from '@staticcms/core/actions/mediaLibrary';
import { checkBackendStatus as checkBackendStatusAction } from '@staticcms/core/actions/status';
import { buttons, colors } from '@staticcms/core/components/UI/styles';
import { stripProtocol, getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
import NavLink from '../UI/NavLink';
import SettingsDropdown from '../UI/SettingsDropdown';
import type { TranslatedProps } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType, MouseEvent } from 'react';
import type { ConnectedProps } from 'react-redux';
const StyledAppBar = styled(AppBar)`
background-color: ${colors.foreground};
`;
const StyledToolbar = styled(Toolbar)`
gap: 12px;
`;
const StyledButton = styled(Button)`
${buttons.button};
background: none;
color: #7b8290;
font-family: inherit;
font-size: 16px;
font-weight: 500;
text-transform: none;
gap: 2px;
&:hover,
&:active,
&:focus {
color: ${colors.active};
}
`;
const StyledSpacer = styled('div')`
flex-grow: 1;
`;
const StyledAppHeaderActions = styled('div')`
display: inline-flex;
align-items: center;
gap: 8px;
`;
const Header = ({
user,
collections,
logoutUser,
openMediaLibrary,
displayUrl,
isTestRepo,
t,
showMediaButton,
checkBackendStatus,
}: TranslatedProps<HeaderProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const navigate = useNavigate();
const creatableCollections = useMemo(
() =>
Object.values(collections).filter(collection =>
'folder' in collection ? collection.create ?? false : false,
),
[collections],
);
useEffect(() => {
const intervalId = setInterval(() => {
checkBackendStatus();
}, 5 * 60 * 1000);
return () => {
clearInterval(intervalId);
};
}, [checkBackendStatus]);
const handleMediaClick = useCallback(() => {
openMediaLibrary();
}, [openMediaLibrary]);
return (
<StyledAppBar position="sticky">
<StyledToolbar>
<Link to="/collections" component={NavLink} activeClassName={'header-link-active'}>
<DescriptionIcon />
{t('app.header.content')}
</Link>
{showMediaButton ? (
<StyledButton onClick={handleMediaClick}>
<ImageIcon />
{t('app.header.media')}
</StyledButton>
) : null}
<StyledSpacer />
<StyledAppHeaderActions>
{creatableCollections.length > 0 && (
<div key="quick-create">
<Button
id="quick-create-button"
aria-controls={open ? 'quick-create-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant="contained"
endIcon={<KeyboardArrowDownIcon />}
>
{t('app.header.quickAdd')}
</Button>
<Menu
id="quick-create-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'quick-create-button',
}}
>
{creatableCollections.map(collection => (
<MenuItem
key={collection.name}
onClick={() => navigate(getNewEntryUrl(collection.name))}
>
{collection.label_singular || collection.label}
</MenuItem>
))}
</Menu>
</div>
)}
{isTestRepo && (
<Button
href="https://staticcms.org/docs/test-backend"
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none' }}
endIcon={<OpenInNewIcon />}
>
Test Backend
</Button>
)}
{displayUrl ? (
<Button
href={displayUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none' }}
endIcon={<OpenInNewIcon />}
>
{stripProtocol(displayUrl)}
</Button>
) : null}
<SettingsDropdown
displayUrl={displayUrl}
isTestRepo={isTestRepo}
imageUrl={user?.avatar_url}
onLogoutClick={logoutUser}
/>
</StyledAppHeaderActions>
</StyledToolbar>
</StyledAppBar>
);
};
function mapStateToProps(state: RootState) {
const { auth, config, collections, mediaLibrary } = state;
const user = auth.user;
const showMediaButton = mediaLibrary.showMediaButton;
return {
user,
collections,
displayUrl: config.config?.display_url,
isTestRepo: config.config?.backend.name === 'test-repo',
showMediaButton,
};
}
const mapDispatchToProps = {
checkBackendStatus: checkBackendStatusAction,
openMediaLibrary: openMediaLibraryAction,
logoutUser: logoutUserAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type HeaderProps = ConnectedProps<typeof connector>;
export default connector(translate()(Header) as ComponentType<HeaderProps>);

View File

@ -1,49 +0,0 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import TopBarProgress from 'react-topbar-progress-indicator';
import { colors } from '@staticcms/core/components/UI/styles';
import Header from './Header';
import type { ReactNode } from 'react';
TopBarProgress.config({
barColors: {
0: colors.active,
'1.0': colors.active,
},
shadowBlur: 0,
barThickness: 2,
});
const StyledMainContainerWrapper = styled('div')`
position: relative;
padding: 24px;
gap: 24px;
`;
const StyledMainContainer = styled('div')`
min-width: 1152px;
max-width: 1392px;
margin: 0 auto;
display: flex;
gap: 24px;
position: relative;
`;
interface MainViewProps {
children: ReactNode;
}
const MainView = ({ children }: MainViewProps) => {
return (
<>
<Header />
<StyledMainContainerWrapper>
<StyledMainContainer>{children}</StyledMainContainer>
</StyledMainContainerWrapper>
</>
);
};
export default MainView;

View File

@ -1,78 +0,0 @@
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import React, { useCallback } from 'react';
import { translate } from 'react-polyglot';
import { useNavigate } from 'react-router-dom';
import { components } from '@staticcms/core/components/UI/styles';
import type { Collection, TranslatedProps } from '@staticcms/core/interface';
const CollectionTopRow = styled('div')`
display: flex;
align-items: center;
justify-content: space-between;
`;
const CollectionTopHeading = styled('h1')`
${components.cardTopHeading};
`;
const CollectionTopDescription = styled('p')`
${components.cardTopDescription};
margin-bottom: 0;
`;
function getCollectionProps(collection: Collection) {
const collectionLabel = collection.label;
const collectionLabelSingular = collection.label_singular;
const collectionDescription = collection.description;
return {
collectionLabel,
collectionLabelSingular,
collectionDescription,
};
}
interface CollectionTopProps {
collection: Collection;
newEntryUrl?: string;
}
const CollectionTop = ({ collection, newEntryUrl, t }: TranslatedProps<CollectionTopProps>) => {
const navigate = useNavigate();
const { collectionLabel, collectionLabelSingular, collectionDescription } =
getCollectionProps(collection);
const onNewClick = useCallback(() => {
if (newEntryUrl) {
navigate(newEntryUrl);
}
}, [navigate, newEntryUrl]);
return (
<Card>
<CardContent>
<CollectionTopRow>
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
{newEntryUrl ? (
<Button onClick={onNewClick} variant="contained">
{t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || collectionLabel,
})}
</Button>
) : null}
</CollectionTopRow>
{collectionDescription ? (
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
) : null}
</CardContent>
</Card>
);
};
export default translate()(CollectionTop);

View File

@ -1,128 +0,0 @@
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getPreviewCard } from '@staticcms/core/lib/registry';
import {
selectEntryCollectionTitle,
selectFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
collection: Collection;
collectionLabel?: string;
viewStyle?: CollectionViewStyle;
}
const EntryCard = ({
collection,
entry,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
imageFieldName,
}: EntryCardProps) => {
const entryData = entry.data;
const path = useMemo(
() => `/collections/${collection.name}/entries/${entry.slug}`,
[collection.name, entry.slug],
);
const imageField = useMemo(
() =>
'fields' in collection
? (collection.fields?.find(
f => f.name === imageFieldName && f.widget === 'image',
) as FileOrImageField)
: undefined,
[collection, imageFieldName],
);
const image = useMemo(() => {
let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined;
if (i) {
i = encodeURI(i.trim());
}
return i;
}, [entryData, imageFieldName]);
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
const config = useAppSelector(selectConfig);
const { widgetFor, widgetsFor } = useWidgetsFor(config, collection, fields, entry);
const PreviewCardComponent = useMemo(
() => getPreviewCard(selectTemplateName(collection, entry.slug)) ?? null,
[collection, entry.slug],
);
if (PreviewCardComponent) {
return (
<Card>
<CardActionArea
component={Link}
to={path}
sx={{
height: '100%',
position: 'relative',
display: 'flex',
width: '100%',
justifyContent: 'start',
}}
>
<PreviewCardComponent
collection={collection}
fields={fields}
entry={entry}
viewStyle={viewStyle === VIEW_STYLE_LIST ? 'list' : 'grid'}
widgetFor={widgetFor}
widgetsFor={widgetsFor}
/>
</CardActionArea>
</Card>
);
}
return (
<Card>
<CardActionArea component={Link} to={path}>
{viewStyle === VIEW_STYLE_GRID && image && imageField ? (
<CardMedia component="img" height="140" image={imageUrl} />
) : null}
<CardContent>
{collectionLabel ? (
<Typography gutterBottom variant="h5" component="div">
{collectionLabel}
</Typography>
) : null}
<Typography gutterBottom variant="h6" component="div" sx={{ margin: 0 }}>
{summary}
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
};
export default EntryCard;

View File

@ -1,86 +0,0 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import React, { useCallback, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
interface FilterControlProps {
filter: Record<string, FilterMap>;
viewFilters: ViewFilter[];
onFilterClick: (viewFilter: ViewFilter) => void;
}
const FilterControl = ({
viewFilters,
t,
onFilterClick,
filter,
}: TranslatedProps<FilterControlProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
return (
<div>
<Button
id="basic-button"
aria-controls={open ? 'basic-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant={anyActive ? 'contained' : 'outlined'}
endIcon={<KeyboardArrowDownIcon />}
>
{t('collection.collectionTop.filterBy')}
</Button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
{viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`;
return (
<MenuItem
key={viewFilter.id}
onClick={() => onFilterClick(viewFilter)}
sx={{ height: '36px' }}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={viewFilter.label} />
</MenuItem>
);
})}
</Menu>
</div>
);
};
export default translate()(FilterControl);

View File

@ -1,80 +0,0 @@
import CheckIcon from '@mui/icons-material/Check';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import Button from '@mui/material/Button';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const StyledMenuIconWrapper = styled('div')`
width: 32px;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-end;
`;
interface GroupControlProps {
group: Record<string, GroupMap>;
viewGroups: ViewGroup[];
onGroupClick: (viewGroup: ViewGroup) => void;
}
const GroupControl = ({
viewGroups,
group,
t,
onGroupClick,
}: TranslatedProps<GroupControlProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
return (
<div>
<Button
id="basic-button"
aria-controls={open ? 'basic-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant={activeGroup ? 'contained' : 'outlined'}
endIcon={<KeyboardArrowDownIcon />}
>
{t('collection.collectionTop.groupBy')}
</Button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
{viewGroups.map(viewGroup => (
<MenuItem key={viewGroup.id} onClick={() => onGroupClick(viewGroup)}>
<ListItemText>{viewGroup.label}</ListItemText>
<StyledMenuIconWrapper>
{viewGroup.id === activeGroup?.id ? <CheckIcon fontSize="small" /> : null}
</StyledMenuIconWrapper>
</MenuItem>
))}
</Menu>
</div>
);
};
export default translate()(GroupControl);

View File

@ -1,188 +0,0 @@
import { styled } from '@mui/material/styles';
import ArticleIcon from '@mui/icons-material/Article';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import { useNavigate } from 'react-router-dom';
import { colors } from '@staticcms/core/components/UI/styles';
import { getAdditionalLinks, getIcon } from '@staticcms/core/lib/registry';
import NavLink from '../UI/NavLink';
import CollectionSearch from './CollectionSearch';
import NestedCollection from './NestedCollection';
import type { ReactNode } from 'react';
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
const StyledSidebar = styled('div')`
position: sticky;
top: 88px;
align-self: flex-start;
`;
const StyledListItemIcon = styled(ListItemIcon)`
min-width: 0;
margin-right: 12px;
`;
interface SidebarProps {
collections: Collections;
collection: Collection;
isSearchEnabled: boolean;
searchTerm: string;
filterTerm: string;
}
const Sidebar = ({
collections,
collection,
isSearchEnabled,
searchTerm,
t,
filterTerm,
}: TranslatedProps<SidebarProps>) => {
const navigate = useNavigate();
function searchCollections(query: string, collection?: string) {
if (collection) {
navigate(`/collections/${collection}/search/${query}`);
} else {
navigate(`/search/${query}`);
}
}
const collectionLinks = useMemo(
() =>
Object.values(collections)
.filter(collection => collection.hide !== true)
.map(collection => {
const collectionName = collection.name;
const iconName = collection.icon;
let icon: ReactNode = <ArticleIcon />;
if (iconName) {
const StoredIcon = getIcon(iconName);
if (StoredIcon) {
icon = <StoredIcon />;
}
}
if ('nested' in collection) {
return (
<li key={`nested-${collectionName}`}>
<NestedCollection
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
</li>
);
}
return (
<ListItem
key={collectionName}
to={`/collections/${collectionName}`}
component={NavLink}
disablePadding
activeClassName="sidebar-active"
>
<ListItemButton>
<StyledListItemIcon>{icon}</StyledListItemIcon>
<ListItemText primary={collection.label} />
</ListItemButton>
</ListItem>
);
}),
[collections, filterTerm],
);
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
const links = useMemo(
() =>
Object.values(additionalLinks).map(
({ id, title, data, options: { icon: iconName } = {} }) => {
let icon: ReactNode = <ArticleIcon />;
if (iconName) {
const StoredIcon = getIcon(iconName);
if (StoredIcon) {
icon = <StoredIcon />;
}
}
const content = (
<>
<StyledListItemIcon>{icon}</StyledListItemIcon>
<ListItemText primary={title} />
</>
);
return typeof data === 'string' ? (
<ListItem
key={title}
href={data}
component="a"
disablePadding
target="_blank"
rel="noopener"
sx={{
color: colors.inactive,
'&:hover': {
color: colors.active,
'.MuiListItemIcon-root': {
color: colors.active,
},
},
}}
>
<ListItemButton>{content}</ListItemButton>
</ListItem>
) : (
<ListItem
key={title}
to={`/page/${id}`}
component={NavLink}
disablePadding
activeClassName="sidebar-active"
>
<ListItemButton>{content}</ListItemButton>
</ListItem>
);
},
),
[additionalLinks],
);
return (
<StyledSidebar>
<Card sx={{ minWidth: 275 }}>
<CardContent sx={{ paddingBottom: 0 }}>
<Typography gutterBottom variant="h5" component="div">
{t('collection.sidebar.collections')}
</Typography>
{isSearchEnabled && (
<CollectionSearch
searchTerm={searchTerm}
collections={collections}
collection={collection}
onSubmit={(query: string, collection?: string) =>
searchCollections(query, collection)
}
/>
)}
</CardContent>
<List>
{collectionLinks}
{links}
</List>
</Card>
</StyledSidebar>
);
};
export default translate()(Sidebar);

View File

@ -1,122 +0,0 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import Button from '@mui/material/Button';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import {
SORT_DIRECTION_ASCENDING,
SORT_DIRECTION_DESCENDING,
SORT_DIRECTION_NONE,
} from '@staticcms/core/constants';
import type {
SortableField,
SortDirection,
SortMap,
TranslatedProps,
} from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
const StyledMenuIconWrapper = styled('div')`
width: 32px;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-end;
`;
function nextSortDirection(direction: SortDirection) {
switch (direction) {
case SORT_DIRECTION_ASCENDING:
return SORT_DIRECTION_DESCENDING;
case SORT_DIRECTION_DESCENDING:
return SORT_DIRECTION_NONE;
default:
return SORT_DIRECTION_ASCENDING;
}
}
interface SortControlProps {
fields: SortableField[];
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
sort: SortMap | undefined;
}
const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortControlProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const selectedSort = useMemo(() => {
if (!sort) {
return { key: undefined, direction: undefined };
}
const sortValues = Object.values(sort);
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SORT_DIRECTION_NONE) {
return { key: undefined, direction: undefined };
}
return sortValues[0];
}, [sort]);
return (
<div>
<Button
id="sort-button"
aria-controls={open ? 'sort-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant={selectedSort.key ? 'contained' : 'outlined'}
endIcon={<KeyboardArrowDownIcon />}
>
{t('collection.collectionTop.sortBy')}
</Button>
<Menu
id="sort-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'sort-button',
}}
>
{fields.map(field => {
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
const nextSortDir = nextSortDirection(sortDir);
return (
<MenuItem
key={field.name}
onClick={() => onSortClick(field.name, nextSortDir)}
selected={field.name === selectedSort.key}
>
<ListItemText>{field.label ?? field.name}</ListItemText>
<StyledMenuIconWrapper>
{field.name === selectedSort.key ? (
selectedSort.direction === SORT_DIRECTION_ASCENDING ? (
<KeyboardArrowUpIcon fontSize="small" />
) : (
<KeyboardArrowDownIcon fontSize="small" />
)
) : null}
</StyledMenuIconWrapper>
</MenuItem>
);
})}
</Menu>
</div>
);
};
export default translate()(SortControl);

View File

@ -1,44 +0,0 @@
import { styled } from '@mui/material/styles';
import GridViewSharpIcon from '@mui/icons-material/GridViewSharp';
import ReorderSharpIcon from '@mui/icons-material/ReorderSharp';
import IconButton from '@mui/material/IconButton';
import React from 'react';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
const ViewControlsSection = styled('div')`
margin-left: 24px;
display: flex;
align-items: center;
justify-content: flex-end;
`;
interface ViewStyleControlPros {
viewStyle: CollectionViewStyle;
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
}
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
return (
<ViewControlsSection>
<IconButton
color={viewStyle === VIEW_STYLE_LIST ? 'primary' : 'default'}
aria-label="list view"
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
>
<ReorderSharpIcon />
</IconButton>
<IconButton
color={viewStyle === VIEW_STYLE_GRID ? 'primary' : 'default'}
aria-label="grid view"
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
>
<GridViewSharpIcon />
</IconButton>
</ViewControlsSection>
);
};
export default ViewStyleControl;

View File

@ -1,243 +0,0 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';
import get from 'lodash/get';
import React, { useCallback, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import { changeDraftField as changeDraftFieldAction } from '@staticcms/core/actions/entries';
import {
getI18nInfo,
getLocaleDataPath,
hasI18n,
isFieldDuplicate,
isFieldHidden,
isFieldTranslatable,
} from '@staticcms/core/lib/i18n';
import EditorControl from './EditorControl';
import type { ButtonProps } from '@mui/material/Button';
import type {
Collection,
Entry,
Field,
FieldsErrors,
I18nSettings,
TranslatedProps,
ValueOrNestedValue,
} from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { MouseEvent } from 'react';
import type { ConnectedProps } from 'react-redux';
const ControlPaneContainer = styled('div')`
max-width: 1000px;
width: 100%;
font-size: 16px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const LocaleRowWrapper = styled('div')`
display: flex;
gap: 8px;
`;
const DefaultLocaleWritingIn = styled('div')`
display: flex;
align-items: center;
height: 36.5px;
`;
interface LocaleDropdownProps {
locales: string[];
defaultLocale: string;
dropdownText: string;
color: ButtonProps['color'];
canChangeLocale: boolean;
onLocaleChange?: (locale: string) => void;
}
const LocaleDropdown = ({
locales,
defaultLocale,
dropdownText,
color,
canChangeLocale,
onLocaleChange,
}: LocaleDropdownProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const handleLocaleChange = useCallback(
(locale: string) => {
onLocaleChange?.(locale);
handleClose();
},
[handleClose, onLocaleChange],
);
if (!canChangeLocale) {
return <DefaultLocaleWritingIn>{dropdownText}</DefaultLocaleWritingIn>;
}
return (
<div>
<Button
id="basic-button"
aria-controls={open ? 'basic-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant="contained"
endIcon={<KeyboardArrowDownIcon />}
color={color}
>
{dropdownText}
</Button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
{locales
.filter(locale => locale !== defaultLocale)
.map(locale => (
<MenuItem
key={locale}
onClick={() => handleLocaleChange(locale)}
sx={{ minWidth: '80px' }}
>
{locale}
</MenuItem>
))}
</Menu>
</div>
);
};
function getFieldValue(
field: Field,
entry: Entry,
isTranslatable: boolean,
locale: string | undefined,
): ValueOrNestedValue {
if (isTranslatable && locale) {
const dataPath = getLocaleDataPath(locale);
return get(entry, [...dataPath, field.name]);
}
return entry.data?.[field.name];
}
const EditorControlPane = ({
collection,
entry,
fields,
fieldsErrors,
submitted,
locale,
canChangeLocale = false,
onLocaleChange,
t,
}: TranslatedProps<EditorControlPaneProps>) => {
const i18n = useMemo(() => {
if (hasI18n(collection)) {
const { locales, defaultLocale } = getI18nInfo(collection);
return {
currentLocale: locale ?? locales[0],
locales,
defaultLocale,
} as I18nSettings;
}
return undefined;
}, [collection, locale]);
if (!collection || !fields) {
return null;
}
if (!entry || entry.partial === true) {
return null;
}
return (
<ControlPaneContainer>
{i18n?.locales && locale ? (
<LocaleRowWrapper>
<LocaleDropdown
locales={i18n.locales}
defaultLocale={i18n.defaultLocale}
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
locale: locale?.toUpperCase(),
})}
color="primary"
canChangeLocale={canChangeLocale}
onLocaleChange={onLocaleChange}
/>
</LocaleRowWrapper>
) : null}
{fields.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;
return (
<EditorControl
key={key}
field={field}
value={getFieldValue(field, entry, isTranslatable, locale)}
fieldsErrors={fieldsErrors}
submitted={submitted}
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
locale={locale}
parentPath=""
i18n={i18n}
/>
);
})}
</ControlPaneContainer>
);
};
export interface EditorControlPaneOwnProps {
collection: Collection;
entry: Entry;
fields: Field[];
fieldsErrors: FieldsErrors;
submitted: boolean;
locale?: string;
canChangeLocale?: boolean;
onLocaleChange?: (locale: string) => void;
}
function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) {
return {
...ownProps,
};
}
const mapDispatchToProps = {
changeDraftField: changeDraftFieldAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EditorControlPaneProps = ConnectedProps<typeof connector>;
export default connector(EditorControlPane);

View File

@ -1,382 +0,0 @@
import HeightIcon from '@mui/icons-material/Height';
import LanguageIcon from '@mui/icons-material/Language';
import VisibilityIcon from '@mui/icons-material/Visibility';
import Fab from '@mui/material/Fab';
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import { colorsRaw, components, zIndex } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n';
import { getFileFromSlug } from '@staticcms/core/lib/util/collection.util';
import EditorControlPane from './EditorControlPane/EditorControlPane';
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import type {
Collection,
EditorPersistOptions,
Entry,
Field,
FieldsErrors,
TranslatedProps,
User,
} from '@staticcms/core/interface';
const PREVIEW_VISIBLE = 'cms.preview-visible';
const I18N_VISIBLE = 'cms.i18n-visible';
const StyledSplitPane = styled('div')`
display: grid;
grid-template-columns: min(864px, 50%) auto;
height: calc(100vh - 64px);
`;
const NoPreviewContainer = styled('div')`
${components.card};
border-radius: 0;
height: 100%;
`;
const EditorContainer = styled('div')`
width: 100%;
min-width: 1200px;
height: 100vh;
overflow: hidden;
`;
const Editor = styled('div')`
height: calc(100vh - 64px);
position: relative;
background-color: ${colorsRaw.white};
overflow-y: auto;
`;
interface PreviewPaneContainerProps {
$blockEntry?: boolean;
}
const PreviewPaneContainer = styled(
'div',
transientOptions,
)<PreviewPaneContainerProps>(
({ $blockEntry }) => `
height: 100%;
pointer-events: ${$blockEntry ? 'none' : 'auto'};
overflow-y: auto;
`,
);
interface ControlPaneContainerProps {
$hidden?: boolean;
}
const ControlPaneContainer = styled(
PreviewPaneContainer,
transientOptions,
)<ControlPaneContainerProps>(
({ $hidden = false }) => `
padding: 24px 16px 16px;
position: relative;
overflow-x: hidden;
display: ${$hidden ? 'none' : 'flex'};
align-items: flex-start;
justify-content: center;
`,
);
const StyledViewControls = styled('div')`
position: fixed;
bottom: 4px;
right: 8px;
z-index: ${zIndex.zIndex299};
display: flex;
flex-direction: column;
gap: 4px;
`;
interface EditorContentProps {
i18nVisible: boolean;
previewVisible: boolean;
editor: JSX.Element;
editorSideBySideLocale: JSX.Element;
editorWithPreview: JSX.Element;
}
const EditorContent = ({
i18nVisible,
previewVisible,
editor,
editorSideBySideLocale,
editorWithPreview,
}: EditorContentProps) => {
if (i18nVisible) {
return editorSideBySideLocale;
} else if (previewVisible) {
return editorWithPreview;
} else {
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
}
};
interface EditorInterfaceProps {
draftKey: string;
entry: Entry;
collection: Collection;
fields: Field[] | undefined;
fieldsErrors: FieldsErrors;
onPersist: (opts?: EditorPersistOptions) => void;
onDelete: () => Promise<void>;
onDuplicate: () => void;
showDelete: boolean;
user: User | undefined;
hasChanged: boolean;
displayUrl: string | undefined;
isNewEntry: boolean;
isModification: boolean;
onLogoutClick: () => void;
editorBackLink: string;
toggleScroll: () => Promise<void>;
scrollSyncEnabled: boolean;
loadScroll: () => void;
submitted: boolean;
}
const EditorInterface = ({
collection,
entry,
fields = [],
fieldsErrors,
showDelete,
onDelete,
onDuplicate,
onPersist,
user,
hasChanged,
displayUrl,
isNewEntry,
isModification,
onLogoutClick,
draftKey,
editorBackLink,
scrollSyncEnabled,
t,
loadScroll,
toggleScroll,
submitted,
}: TranslatedProps<EditorInterfaceProps>) => {
const [previewVisible, setPreviewVisible] = useState(
localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
);
const [i18nVisible, setI18nVisible] = useState(localStorage.getItem(I18N_VISIBLE) !== 'false');
useEffect(() => {
loadScroll();
}, [loadScroll]);
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
const [selectedLocale, setSelectedLocale] = useState<string>(locales?.[1] ?? 'en');
const handleOnPersist = useCallback(
async (opts: EditorPersistOptions = {}) => {
const { createNew = false, duplicate = false } = opts;
// await switchToDefaultLocale();
onPersist({ createNew, duplicate });
},
[onPersist],
);
const handleTogglePreview = useCallback(() => {
const newPreviewVisible = !previewVisible;
setPreviewVisible(newPreviewVisible);
localStorage.setItem(PREVIEW_VISIBLE, `${newPreviewVisible}`);
}, [previewVisible]);
const handleToggleScrollSync = useCallback(() => {
toggleScroll();
}, [toggleScroll]);
const handleToggleI18n = useCallback(() => {
const newI18nVisible = !i18nVisible;
setI18nVisible(newI18nVisible);
localStorage.setItem(I18N_VISIBLE, `${newI18nVisible}`);
}, [i18nVisible]);
const handleLocaleChange = useCallback((locale: string) => {
setSelectedLocale(locale);
}, []);
const [previewEnabled, previewInFrame] = useMemo(() => {
let preview = collection.editor?.preview ?? true;
let frame = collection.editor?.frame ?? true;
if ('files' in collection) {
const file = getFileFromSlug(collection, entry.slug);
if (file?.editor?.preview !== undefined) {
preview = file.editor.preview;
}
if (file?.editor?.frame !== undefined) {
frame = file.editor.frame;
}
}
return [preview, frame];
}, [collection, entry.slug]);
const collectionI18nEnabled = hasI18n(collection);
const editor = (
<ControlPaneContainer key={defaultLocale} id="control-pane">
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={defaultLocale}
submitted={submitted}
t={t}
/>
</ControlPaneContainer>
);
const editorLocale = useMemo(
() =>
(locales ?? [])
.filter(locale => locale !== defaultLocale)
.map(locale => (
<ControlPaneContainer key={locale} $hidden={locale !== selectedLocale}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={locale}
onLocaleChange={handleLocaleChange}
submitted={submitted}
canChangeLocale
t={t}
/>
</ControlPaneContainer>
)),
[
collection,
defaultLocale,
entry,
fields,
fieldsErrors,
handleLocaleChange,
locales,
selectedLocale,
submitted,
t,
],
);
const previewEntry = collectionI18nEnabled
? getPreviewEntry(entry, selectedLocale[0], defaultLocale)
: entry;
const editorWithPreview = (
<>
<StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<PreviewPaneContainer>
<EditorPreviewPane
collection={collection}
previewInFrame={previewInFrame}
entry={previewEntry}
fields={fields}
/>
</PreviewPaneContainer>
</StyledSplitPane>
</>
);
const editorSideBySideLocale = (
<ScrollSync enabled={scrollSyncEnabled}>
<div>
<StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<ScrollSyncPane>
<>{editorLocale}</>
</ScrollSyncPane>
</StyledSplitPane>
</div>
</ScrollSync>
);
const finalI18nVisible = collectionI18nEnabled && i18nVisible;
const finalPreviewVisible = previewEnabled && previewVisible;
const scrollSyncVisible = finalI18nVisible || finalPreviewVisible;
return (
<EditorContainer>
<EditorToolbar
isPersisting={entry.isPersisting}
isDeleting={entry.isDeleting}
onPersist={handleOnPersist}
onPersistAndNew={() => handleOnPersist({ createNew: true })}
onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })}
onDelete={onDelete}
showDelete={showDelete}
onDuplicate={onDuplicate}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
collection={collection}
isNewEntry={isNewEntry}
isModification={isModification}
onLogoutClick={onLogoutClick}
editorBackLink={editorBackLink}
/>
<Editor key={draftKey}>
<StyledViewControls>
{collectionI18nEnabled && (
<Fab
size="small"
color={finalI18nVisible ? 'primary' : 'default'}
aria-label="add"
onClick={handleToggleI18n}
title={t('editor.editorInterface.toggleI18n')}
>
<LanguageIcon />
</Fab>
)}
{previewEnabled && (
<Fab
size="small"
color={finalPreviewVisible ? 'primary' : 'default'}
aria-label="add"
onClick={handleTogglePreview}
title={t('editor.editorInterface.togglePreview')}
>
<VisibilityIcon />
</Fab>
)}
{scrollSyncVisible && (
<Fab
size="small"
color={scrollSyncEnabled ? 'primary' : 'default'}
aria-label="add"
onClick={handleToggleScrollSync}
title={t('editor.editorInterface.toggleScrollSync')}
>
<HeightIcon />
</Fab>
)}
</StyledViewControls>
<EditorContent
i18nVisible={finalI18nVisible}
previewVisible={finalPreviewVisible}
editor={editor}
editorSideBySideLocale={editorSideBySideLocale}
editorWithPreview={editorWithPreview}
/>
</Editor>
</EditorContainer>
);
};
export default EditorInterface;

View File

@ -1,44 +0,0 @@
import React, { useRef } from 'react';
import { FrameContextConsumer } from 'react-frame-component';
import { ScrollSyncPane } from 'react-scroll-sync';
import EditorPreviewContent from './EditorPreviewContent';
import type {
EntryData,
TemplatePreviewComponent,
TemplatePreviewProps,
UnknownField,
} from '@staticcms/core/interface';
import type { FC } from 'react';
interface PreviewFrameContentProps {
previewComponent: TemplatePreviewComponent<EntryData, UnknownField>;
previewProps: Omit<TemplatePreviewProps<EntryData, UnknownField>, 'document' | 'window'>;
}
const PreviewFrameContent: FC<PreviewFrameContentProps> = ({ previewComponent, previewProps }) => {
const ref = useRef<HTMLElement>();
return (
<FrameContextConsumer>
{context => {
if (!ref.current) {
ref.current = context.document?.scrollingElement as HTMLElement;
}
return (
<ScrollSyncPane key="preview-frame-scroll-sync" attachTo={ref}>
<EditorPreviewContent
key="preview-frame-content"
previewComponent={previewComponent}
previewProps={{ ...previewProps, document: context.document, window: context.window }}
/>
</ScrollSyncPane>
);
}}
</FrameContextConsumer>
);
};
export default PreviewFrameContent;

View File

@ -1,305 +0,0 @@
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import AppBar from '@mui/material/AppBar';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import { green } from '@mui/material/colors';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';
import Toolbar from '@mui/material/Toolbar';
import React, { useCallback, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { colors, components, zIndex } from '@staticcms/core/components/UI/styles';
import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util';
import { SettingsDropdown } from '../UI';
import NavLink from '../UI/NavLink';
import type { MouseEvent } from 'react';
import type {
Collection,
EditorPersistOptions,
TranslatedProps,
User,
} from '@staticcms/core/interface';
const StyledAppBar = styled(AppBar)`
background-color: ${colors.foreground};
z-index: ${zIndex.zIndex100};
`;
const StyledToolbar = styled(Toolbar)`
gap: 12px;
`;
const StyledToolbarSectionBackLink = styled('div')`
display: flex;
margin: -32px -24px;
height: 64px;
a {
display: flex;
height: 100%;
padding: 16px;
align-items: center;
}
`;
const StyledToolbarSectionMain = styled('div')`
flex-grow: 1;
display: flex;
gap: 8px;
padding: 0 16px;
margin-left: 24px;
`;
const StyledBackCollection = styled('div')`
color: ${colors.textLead};
font-size: 14px;
`;
const StyledBackStatus = styled('div')`
margin-top: 6px;
`;
const StyledBackStatusUnchanged = styled(StyledBackStatus)`
${components.textBadgeSuccess};
`;
const StyledBackStatusChanged = styled(StyledBackStatus)`
${components.textBadgeDanger};
`;
const StyledButtonWrapper = styled('div')`
position: relative;
`;
export interface EditorToolbarProps {
isPersisting?: boolean;
isDeleting?: boolean;
onPersist: (opts?: EditorPersistOptions) => Promise<void>;
onPersistAndNew: () => Promise<void>;
onPersistAndDuplicate: () => Promise<void>;
onDelete: () => Promise<void>;
showDelete: boolean;
onDuplicate: () => void;
user: User;
hasChanged: boolean;
displayUrl: string | undefined;
collection: Collection;
isNewEntry: boolean;
isModification?: boolean;
onLogoutClick: () => void;
editorBackLink: string;
}
const EditorToolbar = ({
user,
hasChanged,
displayUrl,
collection,
onLogoutClick,
onDuplicate,
isPersisting,
onPersist,
onPersistAndDuplicate,
onPersistAndNew,
isNewEntry,
showDelete,
onDelete,
t,
editorBackLink,
}: TranslatedProps<EditorToolbarProps>) => {
const canCreate = useMemo(
() => ('folder' in collection && collection.create) ?? false,
[collection],
);
const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]);
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const handleMenuOptionClick = useCallback(
(callback: () => Promise<void> | void) => () => {
handleClose();
callback();
},
[handleClose],
);
const handlePersistAndNew = useMemo(
() => handleMenuOptionClick(onPersistAndNew),
[handleMenuOptionClick, onPersistAndNew],
);
const handlePersistAndDuplicate = useMemo(
() => handleMenuOptionClick(onPersistAndDuplicate),
[handleMenuOptionClick, onPersistAndDuplicate],
);
const handleDuplicate = useMemo(
() => handleMenuOptionClick(onDuplicate),
[handleMenuOptionClick, onDuplicate],
);
const handlePersist = useMemo(
() => handleMenuOptionClick(() => onPersist()),
[handleMenuOptionClick, onPersist],
);
const handleDelete = useMemo(
() => handleMenuOptionClick(onDelete),
[handleMenuOptionClick, onDelete],
);
const menuItems = useMemo(() => {
const items: JSX.Element[] = [];
if (!isPublished) {
items.push(
<MenuItem key="publishNow" onClick={handlePersist}>
{t('editor.editorToolbar.publishNow')}
</MenuItem>,
);
if (canCreate) {
items.push(
<MenuItem key="publishAndCreateNew" onClick={handlePersistAndNew}>
{t('editor.editorToolbar.publishAndCreateNew')}
</MenuItem>,
<MenuItem key="publishAndDuplicate" onClick={handlePersistAndDuplicate}>
{t('editor.editorToolbar.publishAndDuplicate')}
</MenuItem>,
);
}
}
if (canCreate) {
items.push(
<MenuItem key="duplicate" onClick={handleDuplicate}>
{t('editor.editorToolbar.duplicate')}
</MenuItem>,
);
}
return items;
}, [
canCreate,
handleDuplicate,
handlePersist,
handlePersistAndDuplicate,
handlePersistAndNew,
isPublished,
t,
]);
const controls = useMemo(
() => (
<StyledToolbarSectionMain>
<div>
<StyledButtonWrapper>
<Button
id="existing-published-button"
aria-controls={open ? 'existing-published-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant="contained"
color={isPublished ? 'success' : 'primary'}
endIcon={<KeyboardArrowDownIcon />}
disabled={menuItems.length === 0 || isPersisting}
>
{isPublished
? t('editor.editorToolbar.published')
: isPersisting
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</Button>
{isPersisting ? (
<CircularProgress
size={24}
sx={{
color: green[500],
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
) : null}
</StyledButtonWrapper>
<Menu
id="existing-published-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'existing-published-button',
}}
>
{menuItems}
</Menu>
</div>
{showDelete && canDelete ? (
<Button variant="outlined" color="error" key="delete-button" onClick={handleDelete}>
{t('editor.editorToolbar.deleteEntry')}
</Button>
) : null}
</StyledToolbarSectionMain>
),
[
anchorEl,
canDelete,
handleClick,
handleClose,
handleDelete,
isPersisting,
isPublished,
menuItems,
open,
showDelete,
t,
],
);
return (
<StyledAppBar position="relative">
<StyledToolbar>
<StyledToolbarSectionBackLink>
<Button component={NavLink} to={editorBackLink}>
<ArrowBackIcon />
<div>
<StyledBackCollection>
{t('editor.editorToolbar.backCollection', {
collectionLabel: collection.label,
})}
</StyledBackCollection>
{hasChanged ? (
<StyledBackStatusChanged key="back-changed">
{t('editor.editorToolbar.unsavedChanges')}
</StyledBackStatusChanged>
) : (
<StyledBackStatusUnchanged key="back-unchanged">
{t('editor.editorToolbar.changesSaved')}
</StyledBackStatusUnchanged>
)}
</div>
</Button>
</StyledToolbarSectionBackLink>
{controls}
<SettingsDropdown
displayUrl={displayUrl}
imageUrl={user?.avatar_url}
onLogoutClick={onLogoutClick}
/>
</StyledToolbar>
</StyledAppBar>
);
};
export default translate()(EditorToolbar);

View File

@ -1,4 +1,3 @@
import { styled } from '@mui/material/styles';
import cleanStack from 'clean-stack'; import cleanStack from 'clean-stack';
import copyToClipboard from 'copy-text-to-clipboard'; import copyToClipboard from 'copy-text-to-clipboard';
import truncate from 'lodash/truncate'; import truncate from 'lodash/truncate';
@ -6,11 +5,10 @@ import React, { Component } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import yaml from 'yaml'; import yaml from 'yaml';
import { buttons, colors } from '@staticcms/core/components/UI/styles';
import { localForage } from '@staticcms/core/lib/util'; import { localForage } from '@staticcms/core/lib/util';
import type { Config, TranslatedProps } from '@staticcms/core/interface'; import type { Config, TranslatedProps } from '@staticcms/core/interface';
import type { ReactNode } from 'react'; import type { ComponentClass, ReactNode } from 'react';
const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?'; const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?';
@ -38,14 +36,14 @@ ${config}
`; `;
} }
function buildIssueTemplate(config: Config) { function buildIssueTemplate(config?: Config) {
let version = ''; let version = '';
if (typeof STATIC_CMS_CORE_VERSION === 'string') { if (typeof STATIC_CMS_CORE_VERSION === 'string') {
version = `static-cms@${STATIC_CMS_CORE_VERSION}`; version = `static-cms@${STATIC_CMS_CORE_VERSION}`;
} }
const template = getIssueTemplate( const template = getIssueTemplate(
version, version,
config?.backend?.name, config?.backend?.name ?? 'Unknown',
navigator.userAgent, navigator.userAgent,
yaml.stringify(config), yaml.stringify(config),
); );
@ -53,7 +51,7 @@ function buildIssueTemplate(config: Config) {
return template; return template;
} }
function buildIssueUrl(title: string, config: Config) { function buildIssueUrl(title: string, config?: Config) {
try { try {
const body = buildIssueTemplate(config); const body = buildIssueTemplate(config);
@ -69,48 +67,6 @@ function buildIssueUrl(title: string, config: Config) {
} }
} }
const ErrorBoundaryContainer = styled('div')`
padding: 40px;
h1 {
font-size: 28px;
color: ${colors.text};
}
h2 {
font-size: 20px;
}
strong {
color: ${colors.textLead};
font-weight: 500;
}
hr {
width: 200px;
margin: 30px 0;
border: 0;
height: 1px;
background-color: ${colors.text};
}
a {
color: ${colors.active};
}
`;
const PrivacyWarning = styled('span')`
color: ${colors.text};
`;
const CopyButton = styled('button')`
${buttons.button};
${buttons.default};
${buttons.gray};
display: block;
margin: 12px 0;
`;
interface RecoveredEntryProps { interface RecoveredEntryProps {
entry: string; entry: string;
} }
@ -122,9 +78,9 @@ const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
<hr /> <hr />
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2> <h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong> <strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
<CopyButton onClick={() => copyToClipboard(entry)}> <button onClick={() => copyToClipboard(entry)}>
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')} {t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
</CopyButton> </button>
<pre> <pre>
<code>{entry}</code> <code>{entry}</code>
</pre> </pre>
@ -134,7 +90,7 @@ const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode;
config: Config; config?: Config;
showBackup?: boolean; showBackup?: boolean;
} }
@ -145,10 +101,7 @@ interface ErrorBoundaryState {
backup: string; backup: string;
} }
export class ErrorBoundary extends Component< class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, ErrorBoundaryState> {
TranslatedProps<ErrorBoundaryProps>,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { state: ErrorBoundaryState = {
hasError: false, hasError: false,
errorMessage: '', errorMessage: '',
@ -194,7 +147,7 @@ export class ErrorBoundary extends Component<
return this.props.children; return this.props.children;
} }
return ( return (
<ErrorBoundaryContainer key="error-boundary-container"> <div key="error-boundary-container">
<h1>{t('ui.errorBoundary.title')}</h1> <h1>{t('ui.errorBoundary.title')}</h1>
<p> <p>
<span>{t('ui.errorBoundary.details')}</span> <span>{t('ui.errorBoundary.details')}</span>
@ -211,7 +164,7 @@ export class ErrorBoundary extends Component<
{t('ui.errorBoundary.privacyWarning') {t('ui.errorBoundary.privacyWarning')
.split('\n') .split('\n')
.map((item, index) => [ .map((item, index) => [
<PrivacyWarning key={`private-warning-${index}`}>{item}</PrivacyWarning>, <span key={`private-warning-${index}`}>{item}</span>,
<br key={`break-${index}`} />, <br key={`break-${index}`} />,
])} ])}
</p> </p>
@ -219,9 +172,9 @@ export class ErrorBoundary extends Component<
<h2>{t('ui.errorBoundary.detailsHeading')}</h2> <h2>{t('ui.errorBoundary.detailsHeading')}</h2>
<p>{errorMessage}</p> <p>{errorMessage}</p>
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />} {backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
</ErrorBoundaryContainer> </div>
); );
} }
} }
export default translate()(ErrorBoundary); export default translate()(ErrorBoundary) as ComponentClass<ErrorBoundaryProps>;

View File

@ -0,0 +1,63 @@
import React from 'react';
import TopBarProgress from 'react-topbar-progress-indicator';
import classNames from '../lib/util/classNames.util';
import Navbar from './navbar/Navbar';
import Sidebar from './navbar/Sidebar';
import type { ReactNode } from 'react';
import type { Breadcrumb } from '../interface';
TopBarProgress.config({
barColors: {
0: '#000',
'1.0': '#000',
},
shadowBlur: 0,
barThickness: 2,
});
interface MainViewProps {
breadcrumbs?: Breadcrumb[];
showQuickCreate?: boolean;
navbarActions?: ReactNode;
showLeftNav?: boolean;
noMargin?: boolean;
noScroll?: boolean;
children: ReactNode;
}
const MainView = ({
children,
breadcrumbs,
showQuickCreate = false,
showLeftNav = false,
noMargin = false,
noScroll = false,
navbarActions,
}: MainViewProps) => {
return (
<>
<Navbar
breadcrumbs={breadcrumbs}
showQuickCreate={showQuickCreate}
navbarActions={navbarActions}
/>
<div className="flex bg-slate-50 dark:bg-slate-900">
{showLeftNav ? <Sidebar /> : null}
<div
className={classNames(
showLeftNav ? 'w-main left-64' : 'w-full',
!noMargin && 'px-5 py-4',
noScroll ? 'overflow-hidden' : 'overflow-y-auto',
'h-main relative',
)}
>
{children}
</div>
</div>
</>
);
};
export default MainView;

View File

@ -1,24 +0,0 @@
import { styled } from '@mui/material/styles';
import React from 'react';
const EmptyMessageContainer = styled('div')`
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
`;
interface EmptyMessageProps {
content: string;
}
const EmptyMessage = ({ content }: EmptyMessageProps) => {
return (
<EmptyMessageContainer>
<h1>{content}</h1>
</EmptyMessageContainer>
);
};
export default EmptyMessage;

View File

@ -1,398 +0,0 @@
import fuzzy from 'fuzzy';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { connect } from 'react-redux';
import {
closeMediaLibrary as closeMediaLibraryAction,
deleteMedia as deleteMediaAction,
insertMedia as insertMediaAction,
loadMedia as loadMediaAction,
loadMediaDisplayURL as loadMediaDisplayURLAction,
persistMedia as persistMediaAction,
} from '@staticcms/core/actions/mediaLibrary';
import { fileExtension } from '@staticcms/core/lib/util';
import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent';
import { selectMediaFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import alert from '../UI/Alert';
import confirm from '../UI/Confirm';
import MediaLibraryModal from './MediaLibraryModal';
import type { MediaFile } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ChangeEvent, KeyboardEvent } from 'react';
import type { ConnectedProps } from 'react-redux';
/**
* Extensions used to determine which files to show when the media library is
* accessed from an image insertion field.
*/
const IMAGE_EXTENSIONS_VIEWABLE = [
'jpg',
'jpeg',
'webp',
'gif',
'png',
'bmp',
'tiff',
'svg',
'avif',
];
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
const MediaLibrary = ({
isVisible,
loadMediaDisplayURL,
displayURLs,
canInsert,
files = [],
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
isPaginating,
config: mediaConfig,
loadMedia,
dynamicSearchQuery,
page,
persistMedia,
deleteMedia,
insertMedia,
closeMediaLibrary,
collection,
field,
}: MediaLibraryProps) => {
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined);
const [prevIsVisible, setPrevIsVisible] = useState(false);
useEffect(() => {
loadMedia({});
}, [loadMedia]);
useEffect(() => {
if (!prevIsVisible && isVisible) {
setSelectedFile(null);
setQuery('');
loadMedia();
} else if (prevIsVisible && !isVisible) {
window.dispatchEvent(new MediaLibraryCloseEvent());
}
setPrevIsVisible(isVisible);
}, [isVisible, loadMedia, prevIsVisible]);
const loadDisplayURL = useCallback(
(file: MediaFile) => {
loadMediaDisplayURL(file);
},
[loadMediaDisplayURL],
);
/**
* Filter an array of file data to include only images.
*/
const filterImages = useCallback((files: MediaFile[]) => {
return files.filter(file => {
const ext = fileExtension(file.name).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext);
});
}, []);
/**
* Transform file data for table display.
*/
const toTableData = useCallback((files: MediaFile[]) => {
const tableData =
files &&
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
id,
name,
path,
type: ext.toUpperCase(),
size,
queryOrder,
displayURL,
draft,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
};
});
/**
* Get the sort order for use with `lodash.orderBy`, and always add the
* `queryOrder` sort as the lowest priority sort order.
*/
// TODO Sorting?
// const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
// const directions = map(sortFields, 'direction').concat('asc');
// return orderBy(tableData, fieldNames, directions);
return tableData;
}, []);
const handleClose = useCallback(() => {
closeMediaLibrary();
}, [closeMediaLibrary]);
/**
* Toggle asset selection on click.
*/
const handleAssetClick = useCallback(
(asset: MediaFile) => {
if (selectedFile?.key !== asset.key) {
setSelectedFile(asset);
}
},
[selectedFile?.key],
);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollToTop = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
};
/**
* Upload a file.
*/
const handlePersist = useCallback(
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => {
/**
* Stop the browser from automatically handling the file input click, and
* get the file for upload, and retain the synthetic event for access after
* the asynchronous persist operation.
*/
let fileList: FileList | null;
if ('dataTransfer' in event) {
fileList = event.dataTransfer?.files ?? null;
} else {
event.persist();
fileList = event.target.files;
}
if (!fileList) {
return;
}
event.stopPropagation();
event.preventDefault();
const files = [...Array.from(fileList)];
const file = files[0];
const maxFileSize =
typeof mediaConfig.max_file_size === 'number' ? mediaConfig.max_file_size : 512000;
if (maxFileSize && file.size > maxFileSize) {
alert({
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
body: {
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
options: {
size: Math.floor(maxFileSize / 1000),
},
},
});
} else {
await persistMedia(file, { field });
setSelectedFile(files[0] as unknown as MediaFile);
scrollToTop();
}
if (!('dataTransfer' in event)) {
event.target.value = '';
}
},
[mediaConfig.max_file_size, field, persistMedia],
);
/**
* Stores the public path of the file in the application store, where the
* editor field that launched the media library can retrieve it.
*/
const handleInsert = useCallback(() => {
if (!selectedFile) {
return;
}
const { path } = selectedFile;
insertMedia(path, field);
handleClose();
}, [field, handleClose, insertMedia, selectedFile]);
/**
* Removes the selected file from the backend.
*/
const handleDelete = useCallback(async () => {
if (
!(await confirm({
title: 'mediaLibrary.mediaLibrary.onDeleteTitle',
body: 'mediaLibrary.mediaLibrary.onDeleteBody',
color: 'error',
}))
) {
return;
}
const file = files.find(file => selectedFile?.key === file.key);
if (file) {
deleteMedia(file).then(() => {
setSelectedFile(null);
});
}
}, [deleteMedia, files, selectedFile?.key]);
/**
* Downloads the selected file.
*/
const handleDownload = useCallback(() => {
if (!selectedFile) {
return;
}
const url = displayURLs[selectedFile.id]?.url ?? selectedFile.url;
if (!url) {
return;
}
const filename = selectedFile.name;
const element = document.createElement('a');
element.setAttribute('href', url);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
setSelectedFile(null);
}, [displayURLs, selectedFile]);
const handleLoadMore = useCallback(() => {
loadMedia({ query: dynamicSearchQuery, page: (page ?? 0) + 1 });
}, [dynamicSearchQuery, loadMedia, page]);
/**
* Executes media library search for implementations that support dynamic
* search via request. For these implementations, the Enter key must be
* pressed to execute search. If assets are being stored directly through
* the GitHub backend, search is in-memory and occurs as the query is typed,
* so this handler has no impact.
*/
const handleSearchKeyDown = useCallback(
async (event: KeyboardEvent) => {
if (event.key === 'Enter' && dynamicSearch) {
await loadMedia({ query });
scrollToTop();
}
},
[dynamicSearch, loadMedia, query],
);
/**
* Updates query state as the user types in the search field.
*/
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
}, []);
/**
* Filters files that do not match the query. Not used for dynamic search.
*/
const queryFilter = useCallback((query: string, files: MediaFile[]): MediaFile[] => {
/**
* Because file names don't have spaces, typing a space eliminates all
* potential matches, so we strip them all out internally before running the
* query.
*/
const strippedQuery = query.replace(/ /g, '');
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
return matches.map((match, queryIndex) => {
const file = files[match.index];
return { ...file, queryIndex };
}) as MediaFile[];
}, []);
return (
<MediaLibraryModal
isVisible={isVisible}
canInsert={canInsert}
files={files}
dynamicSearch={dynamicSearch}
dynamicSearchActive={dynamicSearchActive}
forImage={forImage}
isLoading={isLoading}
isPersisting={isPersisting}
isDeleting={isDeleting}
hasNextPage={hasNextPage}
isPaginating={isPaginating}
query={query}
selectedFile={selectedFile ?? undefined}
handleFilter={filterImages}
handleQuery={queryFilter}
toTableData={toTableData}
handleClose={handleClose}
handleSearchChange={handleSearchChange}
handleSearchKeyDown={handleSearchKeyDown}
handlePersist={handlePersist}
handleDelete={handleDelete}
handleInsert={handleInsert}
handleDownload={handleDownload}
scrollContainerRef={scrollContainerRef}
handleAssetClick={handleAssetClick}
handleLoadMore={handleLoadMore}
displayURLs={displayURLs}
loadDisplayURL={loadDisplayURL}
collection={collection}
field={field}
/>
);
};
function mapStateToProps(state: RootState) {
const { mediaLibrary } = state;
const field = mediaLibrary.field;
const mediaLibraryProps = {
isVisible: mediaLibrary.isVisible,
canInsert: mediaLibrary.canInsert,
files: selectMediaFiles(state, field),
displayURLs: mediaLibrary.displayURLs,
dynamicSearch: mediaLibrary.dynamicSearch,
dynamicSearchActive: mediaLibrary.dynamicSearchActive,
dynamicSearchQuery: mediaLibrary.dynamicSearchQuery,
forImage: mediaLibrary.forImage,
isLoading: mediaLibrary.isLoading,
isPersisting: mediaLibrary.isPersisting,
isDeleting: mediaLibrary.isDeleting,
config: mediaLibrary.config,
page: mediaLibrary.page,
hasNextPage: mediaLibrary.hasNextPage,
isPaginating: mediaLibrary.isPaginating,
collection: mediaLibrary.collection,
field,
};
return { ...mediaLibraryProps };
}
const mapDispatchToProps = {
loadMedia: loadMediaAction,
persistMedia: persistMediaAction,
deleteMedia: deleteMediaAction,
insertMedia: insertMediaAction,
loadMediaDisplayURL: loadMediaDisplayURLAction,
closeMediaLibrary: closeMediaLibraryAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type MediaLibraryProps = ConnectedProps<typeof connector>;
export default connector(MediaLibrary);

View File

@ -1,146 +0,0 @@
import { styled } from '@mui/material/styles';
import React, { useEffect } from 'react';
import { borders, colors, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import type { MediaLibraryDisplayURL } from '@staticcms/core/reducers/mediaLibrary';
import type { Field, Collection } from '@staticcms/core/interface';
const IMAGE_HEIGHT = 160;
interface CardProps {
$width: string;
$height: string;
$margin: string;
$isSelected: boolean;
}
const Card = styled(
'div',
transientOptions,
)<CardProps>(
({ $width, $height, $margin, $isSelected }) => `
width: ${$width};
height: ${$height};
margin: ${$margin};
border: ${borders.textField};
${$isSelected ? `border-color: ${colors.active};` : ''}
border-radius: ${lengths.borderRadius};
cursor: pointer;
overflow: hidden;
&:focus {
outline: none;
}
`,
);
const CardImageWrapper = styled('div')`
height: ${IMAGE_HEIGHT + 2}px;
${effects.checkerboard};
${shadows.inset};
border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder};
position: relative;
`;
const CardImage = styled('img')`
width: 100%;
height: ${IMAGE_HEIGHT}px;
object-fit: contain;
border-radius: 2px 2px 0 0;
`;
const CardFileIcon = styled('div')`
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 2px 2px 0 0;
padding: 1em;
font-size: 3em;
`;
const CardText = styled('p')`
color: ${colors.text};
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3;
`;
const DraftText = styled('p')`
color: ${colors.mediaDraftText};
background-color: ${colors.mediaDraftBackground};
position: absolute;
padding: 8px;
border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0;
`;
interface MediaLibraryCardProps {
isSelected?: boolean;
displayURL: MediaLibraryDisplayURL;
text: string;
onClick: () => void;
draftText: string;
width: string;
height: string;
margin: string;
type?: string;
isViewableImage: boolean;
loadDisplayURL: () => void;
isDraft?: boolean;
collection?: Collection;
field?: Field;
}
const MediaLibraryCard = ({
isSelected = false,
displayURL,
text,
onClick,
draftText,
width,
height,
margin,
type,
isViewableImage,
isDraft,
collection,
field,
loadDisplayURL,
}: MediaLibraryCardProps) => {
const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(displayURL.url, collection, field, entry);
useEffect(() => {
if (!displayURL.url) {
loadDisplayURL();
}
}, [displayURL.url, loadDisplayURL]);
return (
<Card
$isSelected={isSelected}
$width={width}
$height={height}
$margin={margin}
onClick={onClick}
tabIndex={-1}
>
<CardImageWrapper>
{isDraft ? <DraftText data-testid="draft-text">{draftText}</DraftText> : null}
{url && isViewableImage ? (
<CardImage src={url} />
) : (
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
)}
</CardImageWrapper>
<CardText>{text}</CardText>
</Card>
);
};
export default MediaLibraryCard;

View File

@ -1,231 +0,0 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Waypoint } from 'react-waypoint';
import { FixedSizeGrid as Grid } from 'react-window';
import MediaLibraryCard from './MediaLibraryCard';
import type { Collection, Field, MediaFile } from '@staticcms/core/interface';
import type {
MediaLibraryDisplayURL,
MediaLibraryState,
} from '@staticcms/core/reducers/mediaLibrary';
import type { GridChildComponentProps } from 'react-window';
export interface MediaLibraryCardItem {
displayURL?: MediaLibraryDisplayURL;
id: string;
key: string;
name: string;
type: string;
draft: boolean;
isViewableImage?: boolean;
url?: string;
}
export interface MediaLibraryCardGridProps {
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
mediaItems: MediaFile[];
isSelectedFile: (file: MediaFile) => boolean;
onAssetClick: (asset: MediaFile) => void;
canLoadMore?: boolean;
onLoadMore: () => void;
isPaginating?: boolean;
paginatingMessage?: string;
cardDraftText: string;
cardWidth: string;
cardHeight: string;
cardMargin: string;
loadDisplayURL: (asset: MediaFile) => void;
displayURLs: MediaLibraryState['displayURLs'];
collection?: Collection;
field?: Field;
}
export type CardGridItemData = MediaLibraryCardGridProps & {
columnCount: number;
gutter: number;
};
const CardWrapper = ({
rowIndex,
columnIndex,
style,
data: {
mediaItems,
isSelectedFile,
onAssetClick,
cardDraftText,
cardWidth,
cardHeight,
displayURLs,
loadDisplayURL,
columnCount,
gutter,
collection,
field,
},
}: GridChildComponentProps<CardGridItemData>) => {
const index = rowIndex * columnCount + columnIndex;
if (index >= mediaItems.length) {
return null;
}
const file = mediaItems[index];
return (
<div
style={{
...style,
left: typeof style.left === 'number' ? style.left ?? gutter * columnIndex : style.left,
top: style.top,
width: typeof style.width === 'number' ? style.width - gutter : style.width,
height: typeof style.height === 'number' ? style.height - gutter : style.height,
}}
>
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
text={file.name}
onClick={() => onAssetClick(file)}
isDraft={file.draft}
draftText={cardDraftText}
width={cardWidth}
height={cardHeight}
margin={'0px'}
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
loadDisplayURL={() => loadDisplayURL(file)}
type={file.type}
isViewableImage={file.isViewableImage ?? false}
collection={collection}
field={field}
/>
</div>
);
};
interface StyledCardGridContainerProps {
$width?: number;
$height?: number;
}
const StyledCardGridContainer = styled('div')<StyledCardGridContainerProps>(
({ $width, $height }) => `
overflow-y: auto;
overflow-x: hidden;
width: ${$width ? `${$width}px` : '100%'};
height: ${$height ? `${$height}px` : '100%'};ƒ
`,
);
const CardGrid = styled('div')`
display: flex;
flex-wrap: wrap;
margin-left: -10px;
margin-right: -10px;
`;
const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
const {
cardWidth: inputCardWidth,
cardHeight: inputCardHeight,
cardMargin,
mediaItems,
scrollContainerRef,
} = props;
return (
<AutoSizer>
{({ height, width }) => {
const cardWidth = parseInt(inputCardWidth, 10);
const cardHeight = parseInt(inputCardHeight, 10);
const gutter = parseInt(cardMargin, 10);
const columnWidth = cardWidth + gutter;
const rowHeight = cardHeight + gutter;
const columnCount = Math.floor(width / columnWidth);
const rowCount = Math.ceil(mediaItems.length / columnCount);
return (
<StyledCardGridContainer $width={width} $height={height} ref={scrollContainerRef}>
<Grid
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
height={height}
itemData={
{
...props,
gutter,
columnCount,
} as CardGridItemData
}
style={{ overflow: 'hidden', overflowY: 'scroll' }}
>
{CardWrapper}
</Grid>
</StyledCardGridContainer>
);
}}
</AutoSizer>
);
};
const PaginatedGrid = ({
scrollContainerRef,
mediaItems,
isSelectedFile,
onAssetClick,
cardDraftText,
cardWidth,
cardHeight,
cardMargin,
displayURLs,
loadDisplayURL,
canLoadMore,
onLoadMore,
isPaginating,
paginatingMessage,
collection,
field,
}: MediaLibraryCardGridProps) => {
return (
<StyledCardGridContainer ref={scrollContainerRef}>
<CardGrid>
{mediaItems.map(file => (
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
text={file.name}
onClick={() => onAssetClick(file)}
isDraft={file.draft}
draftText={cardDraftText}
width={cardWidth}
height={cardHeight}
margin={cardMargin}
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
loadDisplayURL={() => loadDisplayURL(file)}
type={file.type}
isViewableImage={file.isViewableImage ?? false}
collection={collection}
field={field}
/>
))}
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
</CardGrid>
{!isPaginating ? null : <h1>{paginatingMessage}</h1>}
</StyledCardGridContainer>
);
};
function MediaLibraryCardGrid(props: MediaLibraryCardGridProps) {
const { canLoadMore, isPaginating } = props;
if (canLoadMore || isPaginating) {
return <PaginatedGrid {...props} />;
}
return <VirtualizedGrid {...props} />;
}
export default MediaLibraryCardGrid;

View File

@ -1,206 +0,0 @@
import CloseIcon from '@mui/icons-material/Close';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import Fab from '@mui/material/Fab';
import { styled } from '@mui/material/styles';
import isEmpty from 'lodash/isEmpty';
import React from 'react';
import { translate } from 'react-polyglot';
import EmptyMessage from './EmptyMessage';
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
import MediaLibraryTop from './MediaLibraryTop';
import type { Collection, Field, MediaFile, TranslatedProps } from '@staticcms/core/interface';
import type { MediaLibraryState } from '@staticcms/core/reducers/mediaLibrary';
import type { ChangeEvent, ChangeEventHandler, FC, KeyboardEventHandler } from 'react';
const StyledFab = styled(Fab)`
position: absolute;
top: -20px;
left: -20px;
`;
/**
* TODO Responsive styling needs to be overhauled. Current setup requires specifying
* widths per breakpoint.
*/
const cardWidth = `278px`;
const cardHeight = `240px`;
const cardMargin = `10px`;
/**
* cardWidth + cardMargin * 2 = cardOutsideWidth
* (not using calc because this will be nested in other calcs)
*/
const cardOutsideWidth = `300px`;
const StyledModal = styled(Dialog)`
.MuiDialog-paper {
display: flex;
flex-direction: column;
overflow: visible;
height: 80%;
width: calc(${cardOutsideWidth} + 20px);
max-width: calc(${cardOutsideWidth} + 20px);
@media (min-width: 800px) {
width: calc(${cardOutsideWidth} * 2 + 20px);
max-width: calc(${cardOutsideWidth} * 2 + 20px);
}
@media (min-width: 1120px) {
width: calc(${cardOutsideWidth} * 3 + 20px);
max-width: calc(${cardOutsideWidth} * 3 + 20px);
}
@media (min-width: 1440px) {
width: calc(${cardOutsideWidth} * 4 + 20px);
max-width: calc(${cardOutsideWidth} * 4 + 20px);
}
@media (min-width: 1760px) {
width: calc(${cardOutsideWidth} * 5 + 20px);
max-width: calc(${cardOutsideWidth} * 5 + 20px);
}
@media (min-width: 2080px) {
width: calc(${cardOutsideWidth} * 6 + 20px);
max-width: calc(${cardOutsideWidth} * 6 + 20px);
}
}
`;
interface MediaLibraryModalProps {
isVisible?: boolean;
canInsert?: boolean;
files: MediaFile[];
dynamicSearch?: boolean;
dynamicSearchActive?: boolean;
forImage?: boolean;
isLoading?: boolean;
isPersisting?: boolean;
isDeleting?: boolean;
hasNextPage?: boolean;
isPaginating?: boolean;
query?: string;
selectedFile?: MediaFile;
handleFilter: (files: MediaFile[]) => MediaFile[];
handleQuery: (query: string, files: MediaFile[]) => MediaFile[];
toTableData: (files: MediaFile[]) => MediaFile[];
handleClose: () => void;
handleSearchChange: ChangeEventHandler<HTMLInputElement>;
handleSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
handlePersist: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
handleDelete: () => void;
handleInsert: () => void;
handleDownload: () => void;
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
handleAssetClick: (asset: MediaFile) => void;
handleLoadMore: () => void;
loadDisplayURL: (file: MediaFile) => void;
displayURLs: MediaLibraryState['displayURLs'];
collection?: Collection;
field?: Field;
}
const MediaLibraryModal = ({
isVisible = false,
canInsert,
files,
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
isPaginating,
query,
selectedFile,
handleFilter,
handleQuery,
toTableData,
handleClose,
handleSearchChange,
handleSearchKeyDown,
handlePersist,
handleDelete,
handleInsert,
handleDownload,
scrollContainerRef,
handleAssetClick,
handleLoadMore,
loadDisplayURL,
displayURLs,
collection,
field,
t,
}: TranslatedProps<MediaLibraryModalProps>) => {
const filteredFiles = forImage ? handleFilter(files) : files;
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
const tableData = toTableData(queriedFiles);
const hasFiles = files && !!files.length;
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
const hasSearchResults = queriedFiles && !!queriedFiles.length;
const hasMedia = hasSearchResults;
const shouldShowEmptyMessage = !hasMedia;
const emptyMessage =
(isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) ||
(dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) ||
(!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) ||
(!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) ||
(!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults')) ||
'';
const hasSelection = hasMedia && !isEmpty(selectedFile);
return (
<StyledModal open={isVisible} onClose={handleClose}>
<StyledFab color="default" aria-label="add" onClick={handleClose} size="small">
<CloseIcon />
</StyledFab>
<MediaLibraryTop
t={t}
onClose={handleClose}
forImage={forImage}
onDownload={handleDownload}
onUpload={handlePersist}
query={query}
onSearchChange={handleSearchChange}
onSearchKeyDown={handleSearchKeyDown}
searchDisabled={!dynamicSearchActive && !hasFilteredFiles}
onDelete={handleDelete}
canInsert={canInsert}
onInsert={handleInsert}
hasSelection={hasSelection}
isPersisting={isPersisting}
isDeleting={isDeleting}
selectedFile={selectedFile}
/>
<DialogContent>
{!shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} />}
<MediaLibraryCardGrid
scrollContainerRef={scrollContainerRef}
mediaItems={tableData}
isSelectedFile={file => selectedFile?.key === file.key}
onAssetClick={handleAssetClick}
canLoadMore={hasNextPage}
onLoadMore={handleLoadMore}
isPaginating={isPaginating}
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
cardWidth={cardWidth}
cardHeight={cardHeight}
cardMargin={cardMargin}
loadDisplayURL={loadDisplayURL}
displayURLs={displayURLs}
collection={collection}
field={field}
/>
</DialogContent>
</StyledModal>
);
};
export default translate()(MediaLibraryModal) as FC<MediaLibraryModalProps>;

View File

@ -1,43 +0,0 @@
import SearchIcon from '@mui/icons-material/Search';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import React from 'react';
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
export interface MediaLibrarySearchProps {
value?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown: KeyboardEventHandler<HTMLInputElement>;
placeholder: string;
disabled?: boolean;
}
const MediaLibrarySearch = ({
value = '',
onChange,
onKeyDown,
placeholder,
disabled,
}: MediaLibrarySearchProps) => {
return (
<TextField
onKeyDown={onKeyDown}
placeholder={placeholder}
value={value}
onChange={onChange}
variant="outlined"
size="small"
disabled={disabled}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
);
};
export default MediaLibrarySearch;

View File

@ -1,165 +0,0 @@
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import React from 'react';
import { CopyToClipBoardButton } from './MediaLibraryButtons';
import MediaLibrarySearch from './MediaLibrarySearch';
import { buttons, shadows, zIndex } from '@staticcms/core/components/UI/styles';
import FileUploadButton from '../UI/FileUploadButton';
import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react';
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
const LibraryTop = styled('div')`
position: relative;
display: flex;
flex-direction: column;
`;
const StyledButtonsContainer = styled('div')`
flex-shrink: 0;
display: flex;
gap: 8px;
`;
const StyledDialogTitle = styled(DialogTitle)`
display: flex;
justify-content: space-between;
align-items: center;
`;
const UploadButton = styled(FileUploadButton)`
${buttons.button};
${buttons.default};
display: inline-block;
margin-left: 15px;
margin-right: 2px;
&[disabled] {
${buttons.disabled};
cursor: default;
}
${buttons.gray};
${shadows.dropMain};
margin-bottom: 0;
span {
font-size: 14px;
font-weight: 500;
display: flex;
justify-content: center;
align-items: center;
}
input {
height: 0.1px;
width: 0.1px;
margin: 0;
padding: 0;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: ${zIndex.zIndex0};
outline: none;
}
`;
export interface MediaLibraryTopProps {
onClose: () => void;
forImage?: boolean;
onDownload: () => void;
onUpload: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
query?: string;
onSearchChange: ChangeEventHandler<HTMLInputElement>;
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
searchDisabled: boolean;
onDelete: () => void;
canInsert?: boolean;
onInsert: () => void;
hasSelection: boolean;
isPersisting?: boolean;
isDeleting?: boolean;
selectedFile?: MediaFile;
}
const MediaLibraryTop = ({
t,
forImage,
onDownload,
onUpload,
query,
onSearchChange,
onSearchKeyDown,
searchDisabled,
onDelete,
canInsert,
onInsert,
hasSelection,
isPersisting,
isDeleting,
selectedFile,
}: TranslatedProps<MediaLibraryTopProps>) => {
const shouldShowButtonLoader = isPersisting || isDeleting;
const uploadEnabled = !shouldShowButtonLoader;
const deleteEnabled = !shouldShowButtonLoader && hasSelection;
const uploadButtonLabel = isPersisting
? t('mediaLibrary.mediaLibraryModal.uploading')
: t('mediaLibrary.mediaLibraryModal.upload');
const deleteButtonLabel = isDeleting
? t('mediaLibrary.mediaLibraryModal.deleting')
: t('mediaLibrary.mediaLibraryModal.deleteSelected');
const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download');
const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected');
return (
<LibraryTop>
<StyledDialogTitle>
{forImage
? t('mediaLibrary.mediaLibraryModal.images')
: t('mediaLibrary.mediaLibraryModal.mediaAssets')}
<StyledButtonsContainer>
<CopyToClipBoardButton
disabled={!hasSelection}
path={selectedFile?.path}
name={selectedFile?.name}
draft={selectedFile?.draft}
t={t}
/>
<Button color="inherit" variant="contained" onClick={onDownload} disabled={!hasSelection}>
{downloadButtonLabel}
</Button>
<UploadButton
label={uploadButtonLabel}
imagesOnly={forImage}
onChange={onUpload}
disabled={!uploadEnabled}
/>
</StyledButtonsContainer>
</StyledDialogTitle>
<StyledDialogTitle>
<MediaLibrarySearch
value={query}
onChange={onSearchChange}
onKeyDown={onSearchKeyDown}
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
disabled={searchDisabled}
/>
<StyledButtonsContainer>
<Button color="error" variant="outlined" onClick={onDelete} disabled={!deleteEnabled}>
{deleteButtonLabel}
</Button>
{!canInsert ? null : (
<Button color="success" variant="contained" onClick={onInsert} disabled={!hasSelection}>
{insertButtonLabel}
</Button>
)}
</StyledButtonsContainer>
</StyledDialogTitle>
</LibraryTop>
);
};
export default MediaLibraryTop;

View File

@ -1,21 +1,14 @@
import { styled } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { lengths } from '@staticcms/core/components/UI/styles';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { TranslateProps } from 'react-polyglot'; import type { TranslateProps } from 'react-polyglot';
const NotFoundContainer = styled('div')`
margin: ${lengths.pageMargin};
`;
const NotFoundPage = ({ t }: TranslateProps) => { const NotFoundPage = ({ t }: TranslateProps) => {
return ( return (
<NotFoundContainer> <div>
<h2>{t('app.notFoundPage.header')}</h2> <h2>{t('app.notFoundPage.header')}</h2>
</NotFoundContainer> </div>
); );
}; };

View File

@ -1,86 +0,0 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React from 'react';
import GoBackButton from './GoBackButton';
import Icon from './Icon';
import type { MouseEventHandler, ReactNode } from 'react';
import type { TranslatedProps } from '@staticcms/core/interface';
const StyledAuthenticationPage = styled('section')`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
`;
const CustomIconWrapper = styled('span')`
width: 300px;
height: 150px;
margin-top: -150px;
`;
const SimpleLogoIcon = styled(Icon)`
color: #c4c6d2;
`;
const StaticCustomIcon = styled(Icon)`
color: #c4c6d2;
`;
const CustomLogoIcon = ({ url }: { url: string }) => {
return (
<CustomIconWrapper>
<img src={url} alt="Logo" />
</CustomIconWrapper>
);
};
const renderPageLogo = (logoUrl?: string) => {
if (logoUrl) {
return <CustomLogoIcon url={logoUrl} />;
}
return <SimpleLogoIcon width={300} height={150} type="static-cms" />;
};
export interface AuthenticationPageProps {
onLogin?: MouseEventHandler<HTMLButtonElement>;
logoUrl?: string;
siteUrl?: string;
loginDisabled?: boolean;
loginErrorMessage?: ReactNode;
icon?: ReactNode;
buttonContent?: ReactNode;
pageContent?: ReactNode;
}
const AuthenticationPage = ({
onLogin,
loginDisabled,
loginErrorMessage,
icon,
buttonContent,
pageContent,
logoUrl,
siteUrl,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
return (
<StyledAuthenticationPage>
{renderPageLogo(logoUrl)}
{loginErrorMessage ? <p>{loginErrorMessage}</p> : null}
{pageContent ?? null}
{buttonContent ? (
<Button variant="contained" disabled={loginDisabled} onClick={onLogin} startIcon={icon}>
{buttonContent}
</Button>
) : null}
{siteUrl ? <GoBackButton href={siteUrl} t={t} /> : null}
{logoUrl ? <StaticCustomIcon width={100} height={100} type="static-cms" /> : null}
</StyledAuthenticationPage>
);
};
export default AuthenticationPage;

View File

@ -1,55 +0,0 @@
import Typography from '@mui/material/Typography';
import React from 'react';
import { colors } from './styles';
import type { MouseEventHandler } from 'react';
const stateColors = {
default: {
text: colors.controlLabel,
},
error: {
text: colors.errorText,
},
};
export interface StyledLabelProps {
hasErrors: boolean;
}
function getStateColors({ hasErrors }: StyledLabelProps) {
if (hasErrors) {
return stateColors.error;
}
return stateColors.default;
}
interface FieldLabelProps {
children: string | string[];
htmlFor?: string;
hasErrors?: boolean;
isActive?: boolean;
onClick?: MouseEventHandler<HTMLLabelElement>;
}
const FieldLabel = ({ children, htmlFor, onClick, hasErrors = false }: FieldLabelProps) => {
return (
<Typography
key="field-label"
variant="body2"
component="label"
htmlFor={htmlFor}
onClick={onClick}
sx={{
color: getStateColors({ hasErrors }).text,
marginLeft: '4px',
}}
>
{children}
</Typography>
);
};
export default FieldLabel;

View File

@ -1,29 +0,0 @@
import Button from '@mui/material/Button';
import React from 'react';
import type { ChangeEventHandler } from 'react';
export interface FileUploadButtonProps {
label: string;
imagesOnly?: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
disabled?: boolean;
}
const FileUploadButton = ({ label, imagesOnly, onChange, disabled }: FileUploadButtonProps) => {
return (
<Button variant="contained" component="label">
{label}
<input
hidden
multiple
type="file"
accept={imagesOnly ? 'image/*' : '*/*'}
onChange={onChange}
disabled={disabled}
/>
</Button>
);
};
export default FileUploadButton;

View File

@ -1,97 +0,0 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import icons from './Icon/icons';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import type { IconType } from './Icon/icons';
interface IconWrapperProps {
$width: number;
$height: number;
$rotation: string;
}
const IconWrapper = styled(
'span',
transientOptions,
)<IconWrapperProps>(
({ $width, $height, $rotation }) => `
display: inline-block;
line-height: 0;
width: ${$width}px;
height: ${$height}px;
transform: rotate(${$rotation});
& path:not(.no-fill),
& circle:not(.no-fill),
& polygon:not(.no-fill),
& rect:not(.no-fill) {
fill: currentColor;
}
& path.clipped {
fill: transparent;
}
svg {
width: 100%;
height: 100%;
}
`,
);
const rotations = { right: 90, down: 180, left: 270, up: 360 };
export type Direction = keyof typeof rotations;
/**
* Calculates rotation for icons that have a `direction` property configured
* in the imported icon definition object. If no direction is configured, a
* neutral rotation value is returned.
*
* Returned value is a string of shape `${degrees}deg`, for use in a CSS
* transform.
*/
function getRotation(iconDirection?: Direction, newDirection?: Direction) {
if (!iconDirection || !newDirection) {
return '0deg';
}
const degrees = rotations[newDirection] - rotations[iconDirection];
return `${degrees}deg`;
}
const sizes = {
xsmall: 12,
small: 18,
medium: 24,
large: 32,
};
export interface IconProps {
type: IconType;
direction?: Direction;
width?: number;
height?: number;
size?: keyof typeof sizes;
className?: string;
}
const Icon = ({ type, direction, width, height, size = 'medium', className }: IconProps) => {
const IconSvg = icons[type].image;
return (
<IconWrapper
className={className}
$width={width ? width : size in sizes ? sizes[size as keyof typeof sizes] : sizes['medium']}
$height={
height ? height : size in sizes ? sizes[size as keyof typeof sizes] : sizes['medium']
}
$rotation={getRotation(icons[type].direction, direction)}
>
<IconSvg />
</IconWrapper>
);
};
export default Icon;

View File

@ -1,42 +0,0 @@
import images from './images/_index';
import type { Direction } from '../Icon';
export type IconType = keyof typeof images;
/**
* This module outputs icon objects with the following shape:
*
* {
* image: <svg>...</svg>,
* ...props
* }
*
* `props` here are config properties defined in this file for specific icons.
* For example, an icon may face a specific direction, and the Icon component
* accepts a `direction` prop to rotate directional icons, which relies on
* defining the default direction here.
*/
interface IconTypeConfig {
direction: Direction;
}
export interface IconTypeProps extends Partial<IconTypeConfig> {
image: () => JSX.Element;
}
/**
* Record icon definition objects - imported object of images simply maps the icon
* name to the raw svg, so we move that to the `image` property of the
* definition object and set any additional configured properties for each icon.
*/
const icons = (Object.keys(images) as IconType[]).reduce((acc, name) => {
const image = images[name];
acc[name] = {
image,
};
return acc;
}, {} as Record<IconType, IconTypeProps>);
export default icons;

View File

@ -1,15 +0,0 @@
import bitbucket from './bitbucket.svg';
import github from './github.svg';
import gitlab from './gitlab.svg';
import gitea from './gitea.svg';
import staticCms from './static-cms-logo.svg';
const images = {
bitbucket,
github,
gitlab,
gitea,
'static-cms': staticCms,
};
export default images;

View File

@ -1,3 +0,0 @@
<svg width="26px" height="26px" viewBox="0 0 26 26" version="1.1">
<path d="M2.77580579,3.0000546 C2.58222841,2.99755793 2.39745454,3.08078757 2.27104968,3.2274172 C2.14464483,3.37404683 2.08954809,3.5690671 2.12053915,3.76016391 L4.90214605,20.6463853 C4.97368482,21.0729296 5.34116371,21.38653 5.77365069,21.3901129 L19.1181559,21.3901129 C19.4427702,21.3942909 19.7215068,21.1601522 19.7734225,20.839689 L22.5550294,3.76344024 C22.5860205,3.57234343 22.5309237,3.37732317 22.4045189,3.23069353 C22.278114,3.0840639 22.0933402,3.00083426 21.8997628,3.00333094 L2.77580579,3.0000546 Z M14.488697,15.2043958 L10.2294639,15.2043958 L9.07619457,9.17921905 L15.520742,9.17921905 L14.488697,15.2043958 Z" id="Shape" fill="#2684FF" fill-rule="nonzero" />
</svg>

Before

Width:  |  Height:  |  Size: 758 B

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
<g>
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8 c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4 c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
<g>
<g>
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2 c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5 c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5 c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3 c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1 C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4 c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7 S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55 c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8 l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4 c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1 c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9 c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3 c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3 c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29 c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8 C343.2,346.5,335,363.3,326.8,380.1z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>

Before

Width:  |  Height:  |  Size: 670 B

View File

@ -1 +0,0 @@
<svg width="26" height="26" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path d="M22.616 14.971L21.52 11.5l-2.173-6.882a.37.37 0 0 0-.71 0l-2.172 6.882H9.252L7.079 4.617a.37.37 0 0 0-.71 0l-2.172 6.882L3.1 14.971c-.1.317.01.664.27.86l9.487 7.094 9.487-7.094a.781.781 0 0 0 .27-.86" fill="#FC6D26"/><path d="M12.858 22.925L16.465 11.5H9.251z" fill="#E24329"/><path d="M12.858 22.925L9.251 11.5H4.197z" fill="#FC6D26"/><path d="M4.197 11.499L3.1 14.971c-.1.317.01.664.27.86l9.487 7.094L4.197 11.5z" fill="#FCA326"/><path d="M4.197 11.499H9.25L7.08 4.617a.37.37 0 0 0-.71 0l-2.172 6.882z" fill="#E24329"/><path d="M12.858 22.925L16.465 11.5h5.055z" fill="#FC6D26"/><path d="M21.52 11.499l1.096 3.472c.1.317-.01.664-.271.86l-9.487 7.094L21.52 11.5z" fill="#FCA326"/><path d="M21.52 11.499h-5.055l2.172-6.882a.37.37 0 0 1 .71 0l2.173 6.882z" fill="#E24329"/></g></svg>

Before

Width:  |  Height:  |  Size: 889 B

View File

@ -1,137 +0,0 @@
import CloseIcon from '@mui/icons-material/Close';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import IconButton from '@mui/material/IconButton';
import { styled } from '@mui/material/styles';
import React from 'react';
import { transientOptions } from '@staticcms/core/lib/util';
import { buttons, colors, lengths, transitions } from './styles';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { MouseEvent, ReactNode } from 'react';
interface TopBarProps {
$isVariableTypesList: boolean;
$collapsed: boolean;
}
const TopBar = styled(
'div',
transientOptions,
)<TopBarProps>(
({ $isVariableTypesList, $collapsed }) => `
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 2px 8px;
border-radius: ${
!$isVariableTypesList
? $collapsed
? lengths.borderRadius
: `${lengths.borderRadius} ${lengths.borderRadius} 0 0`
: $collapsed
? `0 ${lengths.borderRadius} ${lengths.borderRadius} ${lengths.borderRadius}`
: `0 ${lengths.borderRadius} 0 0`
};
position: relative;
`,
);
const TopBarButton = styled('button')`
${buttons.button};
color: ${colors.controlLabel};
background: transparent;
font-size: 16px;
line-height: 1;
padding: 0;
width: 32px;
text-align: center;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
position: relative;
`;
const StyledTitle = styled('div')`
position: absolute;
top: 0;
left: 48px;
line-height: 40px;
white-space: nowrap;
cursor: pointer;
z-index: 1;
width: 220px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: block;
}
`;
const TopBarButtonSpan = TopBarButton.withComponent('span');
const DragIconContainer = styled(TopBarButtonSpan)`
width: 100%;
cursor: move;
`;
export interface DragHandleProps {
listeners: SyntheticListenerMap | undefined;
}
const DragHandle = ({ listeners }: DragHandleProps) => {
return (
<DragIconContainer {...listeners}>
<DragHandleIcon />
</DragIconContainer>
);
};
export interface ListItemTopBarProps {
className?: string;
title: ReactNode;
collapsed?: boolean;
onCollapseToggle?: (event: MouseEvent) => void;
onRemove: (event: MouseEvent) => void;
isVariableTypesList?: boolean;
listeners: SyntheticListenerMap | undefined;
}
const ListItemTopBar = ({
className,
title,
collapsed = false,
onCollapseToggle,
onRemove,
isVariableTypesList = false,
listeners,
}: ListItemTopBarProps) => {
return (
<TopBar className={className} $collapsed={collapsed} $isVariableTypesList={isVariableTypesList}>
{onCollapseToggle ? (
<IconButton onClick={onCollapseToggle} data-testid="expand-button">
<ExpandMoreIcon
sx={{
transform: `rotateZ(${collapsed ? '-90deg' : '0deg'})`,
transition: `transform ${transitions.main};`,
}}
/>
</IconButton>
) : null}
<StyledTitle key="title" onClick={onCollapseToggle} data-testid="list-item-title">
{title}
</StyledTitle>
{listeners ? <DragHandle listeners={listeners} /> : null}
{onRemove ? (
<TopBarButton data-testid="remove-button" onClick={onRemove}>
<CloseIcon />
</TopBarButton>
) : null}
</TopBar>
);
};
export default ListItemTopBar;

View File

@ -1,74 +0,0 @@
import React, { forwardRef } from 'react';
import { NavLink as NavLinkBase } from 'react-router-dom';
import { styled } from '@mui/material/styles';
import { colors } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import type { RefAttributes } from 'react';
import type { NavLinkProps as RouterNavLinkProps } from 'react-router-dom';
export type NavLinkBaseProps = RouterNavLinkProps & RefAttributes<HTMLAnchorElement>;
export interface NavLinkProps extends RouterNavLinkProps {
activeClassName?: string;
}
interface StyledNavLinkProps {
$activeClassName?: string;
}
const StyledNavLinkWrapper = styled(
'div',
transientOptions,
)<StyledNavLinkProps>(
({ $activeClassName }) => `
position: relative;
a {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: ${colors.inactive};
:hover {
color: ${colors.active};
.MuiListItemIcon-root {
color: ${colors.active};
}
}
}
${
$activeClassName
? `
& > .${$activeClassName} {
color: ${colors.active};
.MuiListItemIcon-root {
color: ${colors.active};
}
}
`
: ''
}
`,
);
const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(
({ activeClassName, ...props }, ref) => (
<StyledNavLinkWrapper $activeClassName={activeClassName}>
<NavLinkBase
ref={ref}
{...props}
className={({ isActive }) => (isActive ? activeClassName : '')}
/>
</StyledNavLinkWrapper>
),
);
NavLink.displayName = 'NavLink';
export default NavLink;

View File

@ -1,171 +0,0 @@
import AddIcon from '@mui/icons-material/Add';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { styled } from '@mui/material/styles';
import React, { useCallback, useState } from 'react';
import { transientOptions } from '@staticcms/core/lib';
import { colors, colorsRaw, transitions } from './styles';
import type { ObjectField, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent, ReactNode } from 'react';
const TopBarContainer = styled('div')`
position: relative;
align-items: center;
background-color: ${colors.textFieldBorder};
display: flex;
justify-content: space-between;
padding: 2px 8px;
`;
interface ExpandButtonContainerProps {
$hasError: boolean;
}
const ExpandButtonContainer = styled(
'div',
transientOptions,
)<ExpandButtonContainerProps>(
({ $hasError }) => `
display: flex;
align-items: center;
color: rgba(0, 0, 0, 0.6);
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-weight: 400;
font-size: 1rem;
line-height: 1.4375em;
letter-spacing: 0.00938em;
${$hasError ? `color: ${colorsRaw.red}` : ''}
`,
);
export interface ObjectWidgetTopBarProps {
allowAdd?: boolean;
types?: ObjectField[];
onAdd?: (event: MouseEvent) => void;
onAddType?: (name: string) => void;
onCollapseToggle: (event: MouseEvent) => void;
collapsed: boolean;
heading: ReactNode;
label?: string;
hasError?: boolean;
testId?: string;
}
const ObjectWidgetTopBar = ({
allowAdd,
types,
onAdd,
onAddType,
onCollapseToggle,
collapsed,
heading,
label,
hasError = false,
t,
testId,
}: TranslatedProps<ObjectWidgetTopBarProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const handleAddType = useCallback(
(type: ObjectField) => () => {
handleClose();
onAddType?.(type.name);
},
[handleClose, onAddType],
);
const renderTypesDropdown = useCallback(
(types: ObjectField[]) => {
return (
<div>
<Button
id="types-button"
aria-controls={open ? 'types-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
variant="outlined"
size="small"
endIcon={<AddIcon fontSize="small" />}
>
{t('editor.editorWidgets.list.addType', { item: label })}
</Button>
<Menu
id="types-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'types-button',
}}
>
{types.map((type, idx) =>
type ? (
<MenuItem key={idx} onClick={handleAddType(type)}>
{type.label ?? type.name}
</MenuItem>
) : null,
)}
</Menu>
</div>
);
},
[open, handleClick, t, label, anchorEl, handleClose, handleAddType],
);
const renderAddButton = useCallback(() => {
return (
<Button
onClick={onAdd}
endIcon={<AddIcon fontSize="small" />}
size="small"
variant="outlined"
data-testid="add-button"
>
{t('editor.editorWidgets.list.add', { item: label })}
</Button>
);
}, [t, label, onAdd]);
const renderAddUI = useCallback(() => {
if (!allowAdd) {
return null;
}
if (types && types.length > 0) {
return renderTypesDropdown(types);
} else {
return renderAddButton();
}
}, [allowAdd, types, renderTypesDropdown, renderAddButton]);
return (
<TopBarContainer data-testid={testId}>
<ExpandButtonContainer $hasError={hasError}>
<IconButton onClick={onCollapseToggle} data-testid="expand-button">
<ExpandMoreIcon
sx={{
transform: `rotateZ(${collapsed ? '-90deg' : '0deg'})`,
transition: `transform ${transitions.main};`,
color: hasError ? colorsRaw.red : undefined,
}}
/>
</IconButton>
{heading}
</ExpandButtonContainer>
{renderAddUI()}
</TopBarContainer>
);
};
export default ObjectWidgetTopBar;

View File

@ -1,60 +0,0 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
interface StyledOutlineProps {
$active: boolean;
$hasError: boolean;
$hasLabel: boolean;
}
const StyledOutline = styled(
'div',
transientOptions,
)<StyledOutlineProps>(
({ $active, $hasError, $hasLabel }) => `
position: absolute;
bottom: 0;
right: 0;
top: ${$hasLabel ? 22 : 0}px;
left: 0;
margin: 0;
padding: 0 8px;
pointer-events: none;
border-radius: 4px;
border-style: solid;
border-width: 1px;
overflow: hidden;
min-width: 0%;
border-color: rgba(0, 0, 0, 0.23);
${
$active
? `
border-color: #1976d2;
border-width: 2px;
`
: ''
}
${
$hasError
? `
border-color: #d32f2f;
border-width: 2px;
`
: ''
}
`,
);
interface OutlineProps {
active?: boolean;
hasError?: boolean;
hasLabel?: boolean;
}
const Outline = ({ active = false, hasError = false, hasLabel = false }: OutlineProps) => {
return <StyledOutline $active={active} $hasError={hasError} $hasLabel={hasLabel} />;
};
export default Outline;

View File

@ -1,45 +0,0 @@
import Fade from '@mui/material/Fade';
import { styled } from '@mui/material/styles';
import useScrollTrigger from '@mui/material/useScrollTrigger';
import React, { useCallback } from 'react';
import type { ReactNode, MouseEvent } from 'react';
const StyledScrollTop = styled('div')`
position: fixed;
bottom: 16px;
right: 16px;
`;
interface ScrollTopProps {
children: ReactNode;
}
const ScrollTop = ({ children }: ScrollTopProps) => {
const trigger = useScrollTrigger({
disableHysteresis: true,
threshold: 100,
});
const handleClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
const anchor = ((event.target as HTMLDivElement).ownerDocument || document).querySelector(
'#back-to-top-anchor',
);
if (anchor) {
anchor.scrollIntoView({
block: 'center',
});
}
}, []);
return (
<Fade in={trigger}>
<StyledScrollTop onClick={handleClick} role="presentation">
{children}
</StyledScrollTop>
</Fade>
);
};
export default ScrollTop;

View File

@ -1,75 +0,0 @@
import PersonIcon from '@mui/icons-material/Person';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react';
import { translate } from 'react-polyglot';
import type { TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
interface AvatarImageProps {
imageUrl: string | undefined;
}
const AvatarImage = ({ imageUrl }: AvatarImageProps) => {
return imageUrl ? (
<Avatar sx={{ width: 32, height: 32 }} src={imageUrl} />
) : (
<Avatar sx={{ width: 32, height: 32 }}>
<PersonIcon />
</Avatar>
);
};
interface SettingsDropdownProps {
imageUrl?: string;
onLogoutClick: () => void;
}
const SettingsDropdown = ({
imageUrl,
onLogoutClick,
t,
}: TranslatedProps<SettingsDropdownProps>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
return (
<div>
<Tooltip title="Account settings">
<IconButton
onClick={handleClick}
size="small"
sx={{ ml: 2 }}
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
>
<AvatarImage imageUrl={imageUrl} />
</IconButton>
</Tooltip>
<Menu
id="settings-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'settings-button',
}}
>
<MenuItem onClick={onLogoutClick}>{t('ui.settingsDropdown.logOut')}</MenuItem>
</Menu>
</div>
);
};
export default translate()(SettingsDropdown);

View File

@ -1,13 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
interface WidgetPreviewContainerProps {
children?: ReactNode;
}
const WidgetPreviewContainer = ({ children }: WidgetPreviewContainerProps) => {
return <div>{children}</div>;
};
export default WidgetPreviewContainer;

View File

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

View File

@ -1,550 +0,0 @@
import React from 'react';
import { css, Global } from '@emotion/react';
import type { CSSProperties } from 'react';
export const quantifier = '.cms-wrapper';
/**
* Font Stacks
*/
const fonts = {
primary: `
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol"
`,
mono: `
'SFMono-Regular',
Consolas,
"Liberation Mono",
Menlo,
Courier,
monospace;
`,
};
/**
* Theme Colors
*/
const colorsRaw = {
white: '#fff',
grayLight: '#eff0f4',
gray: '#798291',
grayDark: '#313d3e',
blue: '#3a69c7',
blueLight: '#e8f5fe',
green: '#005614',
greenLight: '#caef6f',
brown: '#754e00',
yellow: '#ffee9c',
red: '#ff003b',
redLight: '#fcefea',
purple: '#70399f',
purpleLight: '#f6d8ff',
teal: '#17a2b8',
tealLight: '#ddf5f9',
};
const colors = {
statusDraftText: colorsRaw.purple,
statusDraftBackground: colorsRaw.purpleLight,
statusReviewText: colorsRaw.brown,
statusReviewBackground: colorsRaw.yellow,
statusReadyText: colorsRaw.green,
statusReadyBackground: colorsRaw.greenLight,
text: colorsRaw.gray,
textLight: colorsRaw.white,
textLead: colorsRaw.grayDark,
background: colorsRaw.grayLight,
foreground: colorsRaw.white,
active: colorsRaw.blue,
activeBackground: colorsRaw.blueLight,
inactive: colorsRaw.gray,
button: colorsRaw.gray,
buttonText: colorsRaw.white,
inputBackground: colorsRaw.white,
infoText: colorsRaw.blue,
infoBackground: colorsRaw.blueLight,
successText: colorsRaw.green,
successBackground: colorsRaw.greenLight,
warnText: colorsRaw.brown,
warnBackground: colorsRaw.yellow,
errorText: colorsRaw.red,
errorBackground: colorsRaw.redLight,
textFieldBorder: '#f7f9fc',
controlLabel: '#7a8291',
checkerboardLight: '#f2f2f2',
checkerboardDark: '#e6e6e6',
mediaDraftText: colorsRaw.purple,
mediaDraftBackground: colorsRaw.purpleLight,
};
const lengths = {
topBarHeight: '56px',
inputPadding: '16px 20px',
borderRadius: '5px',
richTextEditorMinHeight: '300px',
borderWidth: '2px',
pageMargin: '24px',
objectWidgetTopBarContainerPadding: '0 14px 0',
};
const borders = {
textField: `solid ${lengths.borderWidth} ${colors.textFieldBorder}`,
};
const transitions = {
main: '.2s ease',
};
const shadows = {
drop: `
&& {
box-shadow: 0 2px 4px 0 rgba(19, 39, 48, 0.12);
}
`,
dropMain: `
&& {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1);
}
`,
dropMiddle: `
&& {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.15), 0 1px 3px 0 rgba(68, 74, 87, 0.3);
}
`,
dropDeep: `
&& {
box-shadow: 0 4px 12px 0 rgba(68, 74, 87, 0.15), 0 1px 3px 0 rgba(68, 74, 87, 0.25);
}
`,
inset: `
&& {
box-shadow: inset 0 0 4px rgba(68, 74, 87, 0.3);
}
`,
};
const text = {
fieldLabel: css`
&& {
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
}
`,
};
const gradients = {
checkerboard: `
linear-gradient(
45deg,
${colors.checkerboardDark} 25%,
transparent 25%,
transparent 75%,
${colors.checkerboardDark} 75%,
${colors.checkerboardDark}
)
`,
};
const effects = {
checkerboard: css`
&& {
background-color: ${colors.checkerboardLight};
background-size: 16px 16px;
background-position: 0 0, 8px 8px;
background-image: ${gradients.checkerboard}, ${gradients.checkerboard};
}
`,
};
const badge = css`
&& {
font-size: 13px;
line-height: 1;
}
`;
const backgroundBadge = css`
&& {
${badge};
display: block;
border-radius: ${lengths.borderRadius};
padding: 4px 10px;
text-align: center;
}
`;
const textBadge = css`
&& {
${badge};
display: inline-block;
font-weight: 700;
text-transform: uppercase;
}
`;
const card = css`
&& {
${shadows.dropMain};
border-radius: 5px;
background-color: #fff;
}
`;
const buttons = {
button: css`
&& {
border: 0;
border-radius: ${lengths.borderRadius};
cursor: pointer;
}
`,
default: css`
&& {
height: 36px;
line-height: 36px;
font-weight: 500;
padding: 0 15px;
background-color: ${colorsRaw.gray};
color: ${colorsRaw.white};
}
`,
medium: css`
&& {
height: 27px;
line-height: 27px;
font-size: 12px;
font-weight: 600;
border-radius: 3px;
padding: 0 24px 0 14px;
}
`,
small: css`
&& {
font-size: 13px;
height: 23px;
line-height: 23px;
}
`,
gray: css`
&& {
background-color: ${colors.button};
color: ${colors.buttonText};
&:focus,
&:hover {
color: ${colorsRaw.white};
background-color: #555a65;
}
}
`,
grayText: css`
&& {
background-color: transparent;
color: ${colorsRaw.gray};
}
`,
green: css`
&& {
background-color: #aae31f;
color: ${colorsRaw.green};
}
`,
lightRed: css`
&& {
background-color: ${colorsRaw.redLight};
color: ${colorsRaw.red};
}
`,
lightBlue: css`
&& {
background-color: ${colorsRaw.blueLight};
color: ${colorsRaw.blue};
}
`,
lightTeal: css`
&& {
background-color: ${colorsRaw.tealLight};
color: #1195aa;
}
`,
teal: css`
&& {
background-color: ${colorsRaw.teal};
color: ${colorsRaw.white};
}
`,
disabled: css`
&& {
background-color: ${colorsRaw.grayLight};
color: ${colorsRaw.gray};
}
`,
};
const caret = css`
color: ${colorsRaw.white};
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
`;
const components = {
card,
caretDown: css`
${caret};
border-top: 6px solid currentColor;
border-bottom: 0;
`,
caretRight: css`
${caret};
border-left: 6px solid currentColor;
border-right: 0;
`,
badge: css`
&& {
${backgroundBadge};
color: ${colors.infoText};
background-color: ${colors.infoBackground};
}
`,
badgeSuccess: css`
&& {
${backgroundBadge};
color: ${colors.successText};
background-color: ${colors.successBackground};
}
`,
badgeDanger: css`
&& {
${backgroundBadge};
color: ${colorsRaw.red};
background-color: #fbe0d7;
}
`,
textBadge: css`
&& {
${textBadge};
color: ${colors.infoText};
}
`,
textBadgeSuccess: css`
&& {
${textBadge};
color: ${colors.successText};
}
`,
textBadgeDanger: css`
&& {
${textBadge};
color: ${colorsRaw.red};
}
`,
loaderSize: css`
&& {
width: 2.28571429rem;
height: 2.28571429rem;
}
`,
cardTop: css`
&& {
${card};
max-width: 100%;
padding: 18px 20px;
margin-bottom: 28px;
}
`,
cardTopHeading: css`
&& {
font-size: 22px;
font-weight: 600;
line-height: 37px;
margin: 0;
padding: 0;
}
`,
cardTopDescription: css`
&& {
color: ${colors.text};
font-size: 14px;
margin-top: 16px;
}
`,
objectWidgetTopBarContainer: css`
&& {
padding: ${lengths.objectWidgetTopBarContainerPadding};
}
`,
dropdownList: css`
&& {
${shadows.dropDeep};
background-color: ${colorsRaw.white};
border-radius: ${lengths.borderRadius};
overflow: hidden;
}
`,
dropdownItem: css`
&& {
${buttons.button};
background-color: transparent;
border-radius: 0;
color: ${colorsRaw.gray};
font-weight: 500;
border-bottom: 1px solid #eaebf1;
padding: 8px 14px;
display: flex;
justify-content: space-between;
align-items: center;
min-width: max-content;
&:last-of-type {
border-bottom: 0;
}
&.active,
&:hover,
&:active,
&:focus {
color: ${colors.active};
background-color: ${colors.activeBackground};
}
}
`,
viewControlsText: css`
&& {
font-size: 14px;
color: ${colors.text};
margin-right: 12px;
white-space: nowrap;
}
`,
};
export interface OptionStyleState {
isSelected: boolean;
isFocused: boolean;
}
export interface IndicatorSeparatorStyleState {
hasValue: boolean;
selectProps: {
isClearable: boolean;
};
}
const zIndex = {
zIndex0: 0,
zIndex1: 1,
zIndex2: 2,
zIndex10: 10,
zIndex100: 100,
zIndex200: 200,
zIndex299: 299,
zIndex300: 300,
zIndex1000: 1000,
zIndex1001: 1001,
zIndex1002: 1002,
};
const reactSelectStyles = {
control: (styles: CSSProperties) => ({
...styles,
border: 0,
boxShadow: 'none',
padding: '9px 0 9px 12px',
}),
option: (styles: CSSProperties, state: OptionStyleState) => ({
...styles,
backgroundColor: state.isSelected
? `${colors.active}`
: state.isFocused
? `${colors.activeBackground}`
: 'transparent',
paddingLeft: '22px',
}),
menu: (styles: CSSProperties) => ({ ...styles, right: 0, zIndex: zIndex.zIndex300 }),
container: (styles: CSSProperties) => ({ ...styles, padding: '0' }),
indicatorSeparator: (styles: CSSProperties, state: IndicatorSeparatorStyleState) =>
state.hasValue && state.selectProps.isClearable
? { ...styles, backgroundColor: `${colors.textFieldBorder}` }
: { display: 'none' },
dropdownIndicator: (styles: CSSProperties) => ({ ...styles, color: `${colors.controlLabel}` }),
clearIndicator: (styles: CSSProperties) => ({ ...styles, color: `${colors.controlLabel}` }),
multiValue: (styles: CSSProperties) => ({
...styles,
backgroundColor: colors.background,
}),
multiValueLabel: (styles: CSSProperties) => ({
...styles,
color: colors.textLead,
fontWeight: 500,
}),
multiValueRemove: (styles: CSSProperties) => ({
...styles,
color: colors.controlLabel,
':hover': {
color: colors.errorText,
backgroundColor: colors.errorBackground,
},
}),
};
function GlobalStyles() {
return (
<Global
styles={css`
body {
margin: 0;
}
img {
max-width: 100%;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
${quantifier} {
background-color: ${colors.background};
margin: 0;
.ol-viewport {
position: absolute !important;
top: 0;
}
}
`}
/>
);
}
export {
fonts,
colorsRaw,
colors,
lengths,
components,
buttons,
text,
shadows,
borders,
transitions,
effects,
zIndex,
reactSelectStyles,
GlobalStyles,
};

View File

@ -1,4 +1,3 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -9,7 +8,6 @@ import {
groupByField as groupByFieldAction, groupByField as groupByFieldAction,
sortByField as sortByFieldAction, sortByField as sortByFieldAction,
} from '@staticcms/core/actions/entries'; } from '@staticcms/core/actions/entries';
import { components } from '@staticcms/core/components/UI/styles';
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants'; import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper'; import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
import { import {
@ -23,11 +21,11 @@ import {
selectEntriesSort, selectEntriesSort,
selectViewStyle, selectViewStyle,
} from '@staticcms/core/reducers/selectors/entries'; } from '@staticcms/core/reducers/selectors/entries';
import Card from '../common/card/Card';
import CollectionControls from './CollectionControls'; import CollectionControls from './CollectionControls';
import CollectionTop from './CollectionTop'; import CollectionHeader from './CollectionHeader';
import EntriesCollection from './Entries/EntriesCollection'; import EntriesCollection from './entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch'; import EntriesSearch from './entries/EntriesSearch';
import Sidebar from './Sidebar';
import type { import type {
Collection, Collection,
@ -40,24 +38,11 @@ import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
const CollectionMain = styled('main')`
width: 100%;
`;
const SearchResultContainer = styled('div')`
${components.cardTop};
margin-bottom: 22px;
`;
const SearchResultHeading = styled('h1')`
${components.cardTopHeading};
`;
const CollectionView = ({ const CollectionView = ({
collection, collection,
collections, collections,
collectionName, collectionName,
isSearchEnabled, // TODO isSearchEnabled,
isSearchResults, isSearchResults,
isSingleSearchResult, isSingleSearchResult,
searchTerm, searchTerm,
@ -211,49 +196,45 @@ const CollectionView = ({
}; };
}, [collection, onSortClick, prevCollection, readyToLoad, sort]); }, [collection, onSortClick, prevCollection, readyToLoad, sort]);
const collectionDescription = collection?.description;
return ( return (
<> <div className="flex flex-col">
<Sidebar <div className="flex items-center mb-4">
collections={collections} {isSearchResults ? (
collection={(!isSearchResults || isSingleSearchResult) && collection} <>
isSearchEnabled={isSearchEnabled} <div className="flex-grow">
searchTerm={searchTerm} <div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
filterTerm={filterTerm} </div>
/> <CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} />
<CollectionMain> </>
<> ) : (
{isSearchResults ? ( <>
<> <CollectionHeader collection={collection} newEntryUrl={newEntryUrl} />
<SearchResultContainer> <CollectionControls
<SearchResultHeading> viewStyle={viewStyle}
{t(searchResultKey, { searchTerm, collection: collection?.label })} onChangeViewStyle={changeViewStyle}
</SearchResultHeading> sortableFields={sortableFields}
</SearchResultContainer> onSortClick={onSortClick}
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} /> sort={sort}
</> viewFilters={viewFilters ?? []}
) : ( viewGroups={viewGroups ?? []}
<> t={t}
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} /> onFilterClick={onFilterClick}
<CollectionControls onGroupClick={onGroupClick}
viewStyle={viewStyle} filter={filter}
onChangeViewStyle={changeViewStyle} group={group}
sortableFields={sortableFields} />
onSortClick={onSortClick} </>
sort={sort} )}
viewFilters={viewFilters ?? []} </div>
viewGroups={viewGroups ?? []} {collectionDescription ? (
t={t} <div className="flex flex-grow mb-4">
onFilterClick={onFilterClick} <Card className="flex-grow px-3.5 py-2.5 text-sm">{collectionDescription}</Card>
onGroupClick={onGroupClick} </div>
filter={filter} ) : null}
group={group} {entries}
/> </div>
</>
)}
{entries}
</>
</CollectionMain>
</>
); );
}; };
@ -281,7 +262,7 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionV
const sortableFields = selectSortableFields(collection, t); const sortableFields = selectSortableFields(collection, t);
const viewFilters = selectViewFilters(collection); const viewFilters = selectViewFilters(collection);
const viewGroups = selectViewGroups(collection); const viewGroups = selectViewGroups(collection);
const filter = selectEntriesFilter(state, collection?.name); const filter = selectEntriesFilter(collection?.name)(state);
const group = selectEntriesGroup(state, collection?.name); const group = selectEntriesGroup(state, collection?.name);
const viewStyle = selectViewStyle(state); const viewStyle = selectViewStyle(state);

View File

@ -1,12 +1,11 @@
import { styled } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import FilterControl from './FilterControl'; import FilterControl from './FilterControl';
import GroupControl from './GroupControl'; import GroupControl from './GroupControl';
import SortControl from './SortControl'; import SortControl from './SortControl';
import ViewStyleControl from './ViewStyleControl'; import ViewStyleControl from '../common/view-style/ViewStyleControl';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { import type {
FilterMap, FilterMap,
GroupMap, GroupMap,
@ -18,21 +17,9 @@ import type {
ViewGroup, ViewGroup,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
const CollectionControlsContainer = styled('div')`
display: flex;
align-items: center;
flex-direction: row-reverse;
margin-top: 22px;
max-width: 100%;
& > div {
margin-left: 6px;
}
`;
interface CollectionControlsProps { interface CollectionControlsProps {
viewStyle: CollectionViewStyle; viewStyle: ViewStyle;
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void; onChangeViewStyle: (viewStyle: ViewStyle) => void;
sortableFields?: SortableField[]; sortableFields?: SortableField[];
onSortClick?: (key: string, direction?: SortDirection) => Promise<void>; onSortClick?: (key: string, direction?: SortDirection) => Promise<void>;
sort?: SortMap | undefined; sort?: SortMap | undefined;
@ -59,7 +46,7 @@ const CollectionControls = ({
group, group,
}: TranslatedProps<CollectionControlsProps>) => { }: TranslatedProps<CollectionControlsProps>) => {
return ( return (
<CollectionControlsContainer> <div className="flex gap-2 items-center relative z-20">
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} /> <ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{viewGroups && onGroupClick && group {viewGroups && onGroupClick && group
? viewGroups.length > 0 && ( ? viewGroups.length > 0 && (
@ -81,7 +68,7 @@ const CollectionControls = ({
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} /> <SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
) )
: null} : null}
</CollectionControlsContainer> </div>
); );
}; };

View File

@ -0,0 +1,52 @@
import React, { useCallback } from 'react';
import { translate } from 'react-polyglot';
import { useNavigate } from 'react-router-dom';
import useIcon from '@staticcms/core/lib/hooks/useIcon';
import Button from '../common/button/Button';
import type { Collection, TranslatedProps } from '@staticcms/core/interface';
interface CollectionHeaderProps {
collection: Collection;
newEntryUrl?: string;
}
const CollectionHeader = ({
collection,
newEntryUrl,
t,
}: TranslatedProps<CollectionHeaderProps>) => {
const navigate = useNavigate();
const collectionLabel = collection.label;
const collectionLabelSingular = collection.label_singular;
const onNewClick = useCallback(() => {
if (newEntryUrl) {
navigate(newEntryUrl);
}
}, [navigate, newEntryUrl]);
const icon = useIcon(collection.icon);
return (
<>
<div className="flex flex-grow gap-4">
<h2 className="text-xl font-semibold flex items-center text-gray-800 dark:text-gray-300">
<div className="mr-2 flex">{icon}</div>
{collectionLabel}
</h2>
{newEntryUrl ? (
<Button onClick={onNewClick}>
{t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || collectionLabel,
})}
</Button>
) : null}
</div>
</>
);
};
export default translate()(CollectionHeader);

View File

@ -1,30 +1,26 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import {
selectCollection,
selectCollections,
} from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { getDefaultPath } from '../../lib/util/collection.util'; import { getDefaultPath } from '../../lib/util/collection.util';
import MainView from '../App/MainView'; import MainView from '../MainView';
import Collection from './Collection'; import Collection from './Collection';
import type { Collections } from '@staticcms/core/interface';
interface CollectionRouteProps { interface CollectionRouteProps {
isSearchResults?: boolean; isSearchResults?: boolean;
isSingleSearchResult?: boolean; isSingleSearchResult?: boolean;
collections: Collections;
} }
const CollectionRoute = ({ const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => {
isSearchResults,
isSingleSearchResult,
collections,
}: CollectionRouteProps) => {
const { name, searchTerm, filterTerm } = useParams(); const { name, searchTerm, filterTerm } = useParams();
const collection = useMemo(() => {
if (!name) { const collectionSelector = useMemo(() => selectCollection(name), [name]);
return false; const collection = useAppSelector(collectionSelector);
} const collections = useAppSelector(selectCollections);
return collections[name];
}, [collections, name]);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
@ -37,7 +33,7 @@ const CollectionRoute = ({
} }
return ( return (
<MainView> <MainView breadcrumbs={[{ name: collection?.label }]} showQuickCreate showLeftNav>
<Collection <Collection
name={name} name={name}
searchTerm={searchTerm} searchTerm={searchTerm}

View File

@ -1,64 +1,10 @@
import SearchIcon from '@mui/icons-material/Search'; import PopperUnstyled from '@mui/base/PopperUnstyled';
import InputAdornment from '@mui/material/InputAdornment'; import { Search as SearchIcon } from '@styled-icons/material/Search';
import Popover from '@mui/material/Popover';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { colors, colorsRaw, lengths } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
const SearchContainer = styled('div')`
position: relative;
`;
const Suggestions = styled('ul')`
padding: 10px 0;
margin: 0;
list-style: none;
border-radius: ${lengths.borderRadius};
width: 240px;
`;
const SuggestionHeader = styled('li')`
padding: 0 6px 6px 32px;
font-size: 12px;
color: ${colors.text};
`;
interface SuggestionItemProps {
$isActive: boolean;
}
const SuggestionItem = styled(
'li',
transientOptions,
)<SuggestionItemProps>(
({ $isActive }) => `
color: ${$isActive ? colors.active : colorsRaw.grayDark};
background-color: ${$isActive ? colors.activeBackground : 'inherit'};
padding: 6px 6px 6px 32px;
cursor: pointer;
position: relative;
&:hover {
color: ${colors.active};
background-color: ${colors.activeBackground};
}
`,
);
const SuggestionDivider = styled('div')`
width: 100%;
`;
const StyledPopover = styled(Popover)`
margin-left: -44px;
`;
interface CollectionSearchProps { interface CollectionSearchProps {
collections: Collections; collections: Collections;
@ -191,27 +137,116 @@ const CollectionSearch = ({
); );
return ( return (
<SearchContainer> <div>
<TextField <div className="relative">
onKeyDown={handleKeyDown} <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
placeholder={t('collection.sidebar.searchAll')} <SearchIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
onBlur={handleBlur} </div>
onFocus={handleFocus} <input
value={query} type="text"
onChange={handleQueryChange} id="first_name"
variant="outlined" className="
size="small" block
fullWidth w-full
InputProps={{ p-1.5
inputRef, pl-10
startAdornment: ( text-sm
<InputAdornment position="start"> text-gray-900
<SearchIcon /> border
</InputAdornment> border-gray-300
), rounded-lg
}} bg-gray-50
/> focus-visible:outline-none
<StyledPopover focus:ring-4
focus:ring-gray-200
dark:bg-gray-700
dark:border-gray-600
dark:placeholder-gray-400
dark:text-white
dark:focus:ring-slate-700
"
placeholder={t('collection.sidebar.searchAll')}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
value={query}
onChange={handleQueryChange}
/>
</div>
<PopperUnstyled
open={open}
component="div"
placement="top"
anchorEl={anchorEl}
tabIndex={0}
className="
absolute
overflow-auto
rounded-md
bg-white
text-base
shadow-lg
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
"
>
<div
key="edit-content"
contentEditable={false}
className="
flex
flex-col
min-w-[200px]
"
>
<div
className="
text-md
text-slate-500
dark:text-slate-400
py-2
px-3
"
>
{t('collection.sidebar.searchIn')}
</div>
<div
className="
cursor-pointer
hover:bg-blue-500
hover:color-gray-100
py-2
px-3
"
onClick={e => handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
{t('collection.sidebar.allCollections')}
</div>
{collections.map((collection, idx) => (
<div
key={idx}
onClick={e => handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
className="
cursor-pointer
hover:bg-blue-500
hover:color-gray-100
py-2
px-3
"
>
{collection.label}
</div>
))}
</div>
</PopperUnstyled>
{/* <Popover
id="search-popover" id="search-popover"
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
@ -224,30 +259,27 @@ const CollectionSearch = ({
vertical: 'bottom', vertical: 'bottom',
horizontal: 'left', horizontal: 'left',
}} }}
sx={{
width: 300
}}
> >
<Suggestions> <div>
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader> <div>{t('collection.sidebar.searchIn')}</div>
<SuggestionItem <div onClick={e => handleSuggestionClick(e, -1)} onMouseDown={e => e.preventDefault()}>
$isActive={selectedCollectionIdx === -1}
onClick={e => handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
{t('collection.sidebar.allCollections')} {t('collection.sidebar.allCollections')}
</SuggestionItem> </div>
<SuggestionDivider />
{collections.map((collection, idx) => ( {collections.map((collection, idx) => (
<SuggestionItem <div
key={idx} key={idx}
$isActive={idx === selectedCollectionIdx}
onClick={e => handleSuggestionClick(e, idx)} onClick={e => handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
> >
{collection.label} {collection.label}
</SuggestionItem> </div>
))} ))}
</Suggestions> </div>
</StyledPopover> </Popover> */}
</SearchContainer> </div>
); );
}; };

View File

@ -0,0 +1,66 @@
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import Menu from '../common/menu/Menu';
import MenuItemButton from '../common/menu/MenuItemButton';
import MenuGroup from '../common/menu/MenuGroup';
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
import type { ChangeEvent, MouseEvent } from 'react';
interface FilterControlProps {
filter: Record<string, FilterMap>;
viewFilters: ViewFilter[];
onFilterClick: (viewFilter: ViewFilter) => void;
}
const FilterControl = ({
viewFilters,
t,
onFilterClick,
filter,
}: TranslatedProps<FilterControlProps>) => {
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
const handleFilterClick = useCallback(
(viewFilter: ViewFilter) => (event: MouseEvent | ChangeEvent) => {
event.stopPropagation();
onFilterClick(viewFilter);
},
[onFilterClick],
);
return (
<Menu
label={t('collection.collectionTop.filterBy')}
variant={anyActive ? 'contained' : 'outlined'}
>
<MenuGroup>
{viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`;
return (
<MenuItemButton key={viewFilter.id} onClick={handleFilterClick(viewFilter)}>
<input
id={labelId}
type="checkbox"
value=""
className=""
checked={checked}
onChange={handleFilterClick(viewFilter)}
/>
<label
htmlFor={labelId}
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>
{viewFilter.label}
</label>
</MenuItemButton>
);
})}
</MenuGroup>
</Menu>
);
};
export default translate()(FilterControl);

View File

@ -0,0 +1,45 @@
import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
interface GroupControlProps {
group: Record<string, GroupMap>;
viewGroups: ViewGroup[];
onGroupClick: (viewGroup: ViewGroup) => void;
}
const GroupControl = ({
viewGroups,
group,
t,
onGroupClick,
}: TranslatedProps<GroupControlProps>) => {
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
return (
<Menu
label={t('collection.collectionTop.groupBy')}
variant={activeGroup ? 'contained' : 'outlined'}
>
<MenuGroup>
{viewGroups.map(viewGroup => (
<MenuItemButton
key={viewGroup.id}
onClick={() => onGroupClick(viewGroup)}
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
>
{viewGroup.label}
</MenuItemButton>
))}
</MenuGroup>
</Menu>
);
};
export default translate()(GroupControl);

View File

@ -1,78 +1,17 @@
import ArticleIcon from '@mui/icons-material/Article'; import { Article as ArticleIcon } from '@styled-icons/material/Article';
import { styled } from '@mui/material/styles';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { dirname, sep } from 'path'; import { dirname, sep } from 'path';
import React, { Fragment, useCallback, useEffect, useState } from 'react'; import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import { colors, components } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useEntries from '@staticcms/core/lib/hooks/useEntries';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util'; import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { stringTemplate } from '@staticcms/core/lib/widgets'; import { stringTemplate } from '@staticcms/core/lib/widgets';
import NavLink from '../navbar/NavLink';
import type { Collection, Entry } from '@staticcms/core/interface'; import type { Collection, Entry } from '@staticcms/core/interface';
const { addFileTemplateFields } = stringTemplate; const { addFileTemplateFields } = stringTemplate;
const NodeTitleContainer = styled('div')`
display: flex;
justify-content: center;
align-items: center;
`;
const NodeTitle = styled('div')`
margin-right: 4px;
`;
const Caret = styled('div')`
position: relative;
top: 2px;
`;
const CaretDown = styled(Caret)`
${components.caretDown};
color: currentColor;
`;
const CaretRight = styled(Caret)`
${components.caretRight};
color: currentColor;
left: 2px;
`;
interface TreeNavLinkProps {
$activeClassName: string;
$depth: number;
}
const TreeNavLink = styled(
NavLink,
transientOptions,
)<TreeNavLinkProps>(
({ $activeClassName, $depth }) => `
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px;
padding-left: ${$depth * 20 + 12}px;
border-left: 2px solid #fff;
&:hover,
&:active,
&.${$activeClassName} {
color: ${colors.active};
background-color: ${colors.activeBackground};
border-left-color: #4863c6;
.MuiListItemIcon-root {
color: ${colors.active};
}
}
`,
);
interface BaseTreeNodeData { interface BaseTreeNodeData {
title: string | undefined; title: string | undefined;
path: string; path: string;
@ -122,19 +61,19 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
return ( return (
<Fragment key={node.path}> <Fragment key={node.path}>
<TreeNavLink <NavLink
to={to} to={to}
$activeClassName="sidebar-active"
onClick={() => onToggle({ node, expanded: !node.expanded })} onClick={() => onToggle({ node, expanded: !node.expanded })}
$depth={depth}
data-testid={node.path} data-testid={node.path}
> >
<ArticleIcon /> {/* TODO $activeClassName="sidebar-active" */}
<NodeTitleContainer> {/* TODO $depth={depth} */}
<NodeTitle>{title}</NodeTitle> <ArticleIcon className="h-5 w-5" />
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)} <div>
</NodeTitleContainer> <div>{title}</div>
</TreeNavLink> {hasChildren && (node.expanded ? <div /> : <div />)}
</div>
</NavLink>
{node.expanded && ( {node.expanded && (
<TreeNode <TreeNode
collection={collection} collection={collection}

View File

@ -0,0 +1,84 @@
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import { KeyboardArrowUp as KeyboardArrowUpIcon } from '@styled-icons/material/KeyboardArrowUp';
import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import {
SORT_DIRECTION_ASCENDING,
SORT_DIRECTION_DESCENDING,
SORT_DIRECTION_NONE,
} from '@staticcms/core/constants';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
import type {
SortableField,
SortDirection,
SortMap,
TranslatedProps,
} from '@staticcms/core/interface';
function nextSortDirection(direction: SortDirection) {
switch (direction) {
case SORT_DIRECTION_ASCENDING:
return SORT_DIRECTION_DESCENDING;
case SORT_DIRECTION_DESCENDING:
return SORT_DIRECTION_NONE;
default:
return SORT_DIRECTION_ASCENDING;
}
}
interface SortControlProps {
fields: SortableField[];
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
sort: SortMap | undefined;
}
const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortControlProps>) => {
const selectedSort = useMemo(() => {
if (!sort) {
return { key: undefined, direction: undefined };
}
const sortValues = Object.values(sort);
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SORT_DIRECTION_NONE) {
return { key: undefined, direction: undefined };
}
return sortValues[0];
}, [sort]);
return (
<Menu
label={t('collection.collectionTop.sortBy')}
variant={selectedSort.key ? 'contained' : 'outlined'}
>
<MenuGroup>
{fields.map(field => {
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
const nextSortDir = nextSortDirection(sortDir);
return (
<MenuItemButton
key={field.name}
onClick={() => onSortClick(field.name, nextSortDir)}
active={field.name === selectedSort.key}
endIcon={
field.name === selectedSort.key
? selectedSort.direction === SORT_DIRECTION_ASCENDING
? KeyboardArrowUpIcon
: KeyboardArrowDownIcon
: undefined
}
>
{field.label ?? field.name}
</MenuItemButton>
);
})}
</MenuGroup>
</Menu>
);
};
export default translate()(SortControl);

View File

@ -1,28 +1,18 @@
import { styled } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import Loader from '@staticcms/core/components/UI/Loader'; import Loader from '@staticcms/core/components/common/progress/Loader';
import EntryListing from './EntryListing'; import EntryListing from './EntryListing';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor'; import type Cursor from '@staticcms/core/lib/util/Cursor';
const PaginationMessage = styled('div')`
padding: 16px;
text-align: center;
`;
const NoEntriesMessage = styled(PaginationMessage)`
margin-top: 16px;
`;
export interface BaseEntriesProps { export interface BaseEntriesProps {
entries: Entry[]; entries: Entry[];
page?: number; page?: number;
isFetching: boolean; isFetching: boolean;
viewStyle: CollectionViewStyle; viewStyle: ViewStyle;
cursor: Cursor; cursor: Cursor;
handleCursorActions?: (action: string) => void; handleCursorActions?: (action: string) => void;
} }
@ -83,13 +73,13 @@ const Entries = ({
/> />
)} )}
{isFetching && page !== undefined && entries.length > 0 ? ( {isFetching && page !== undefined && entries.length > 0 ? (
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage> <div>{t('collection.entries.loadingEntries')}</div>
) : null} ) : null}
</> </>
); );
} }
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>; return <div>{t('collection.entries.noEntries')}</div>;
}; };
export default translate()(Entries); export default translate()(Entries);

View File

@ -1,10 +1,8 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries'; import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries';
import { colors } from '@staticcms/core/components/UI/styles';
import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useGroups from '@staticcms/core/lib/hooks/useGroups'; import useGroups from '@staticcms/core/lib/hooks/useGroups';
import { Cursor } from '@staticcms/core/lib/util'; import { Cursor } from '@staticcms/core/lib/util';
@ -13,21 +11,13 @@ import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/
import Entries from './Entries'; import Entries from './Entries';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { useAppDispatch } from '@staticcms/core/store/hooks';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { t } from 'react-polyglot'; import type { t } from 'react-polyglot';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
const GroupHeading = styled('h2')`
font-size: 23px;
font-weight: 600;
color: ${colors.textLead};
`;
const GroupContainer = styled('div')``;
function getGroupEntries(entries: Entry[], paths: Set<string>) { function getGroupEntries(entries: Entry[], paths: Set<string>) {
return entries.filter(entry => paths.has(entry.path)); return entries.filter(entry => paths.has(entry.path));
} }
@ -118,8 +108,8 @@ const EntriesCollection = ({
{groups.map(group => { {groups.map(group => {
const title = getGroupTitle(group, t); const title = getGroupTitle(group, t);
return ( return (
<GroupContainer key={group.id} id={group.id}> <div key={group.id} id={group.id}>
<GroupHeading>{title}</GroupHeading> <h2>{title}</h2>
<Entries <Entries
collection={collection} collection={collection}
entries={getGroupEntries(filteredEntries, group.paths)} entries={getGroupEntries(filteredEntries, group.paths)}
@ -130,7 +120,7 @@ const EntriesCollection = ({
handleCursorActions={handleCursorActions} handleCursorActions={handleCursorActions}
page={page} page={page}
/> />
</GroupContainer> </div>
); );
})} })}
</>; </>;
@ -153,7 +143,7 @@ const EntriesCollection = ({
interface EntriesCollectionOwnProps { interface EntriesCollectionOwnProps {
collection: Collection; collection: Collection;
viewStyle: CollectionViewStyle; viewStyle: ViewStyle;
readyToLoad: boolean; readyToLoad: boolean;
filterTerm: string; filterTerm: string;
} }

View File

@ -10,7 +10,7 @@ import { Cursor } from '@staticcms/core/lib/util';
import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/entries'; import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/entries';
import Entries from './Entries'; import Entries from './Entries';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collections } from '@staticcms/core/interface'; import type { Collections } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
@ -25,7 +25,6 @@ const EntriesSearch = ({
searchEntries, searchEntries,
clearSearch, clearSearch,
}: EntriesSearchProps) => { }: EntriesSearchProps) => {
console.log('collections', collections);
const collectionNames = useMemo(() => Object.keys(collections), [collections]); const collectionNames = useMemo(() => Object.keys(collections), [collections]);
const getCursor = useCallback(() => { const getCursor = useCallback(() => {
@ -72,7 +71,7 @@ const EntriesSearch = ({
interface EntriesSearchOwnProps { interface EntriesSearchOwnProps {
searchTerm: string; searchTerm: string;
collections: Collections; collections: Collections;
viewStyle: CollectionViewStyle; viewStyle: ViewStyle;
} }
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) { function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
@ -81,7 +80,6 @@ function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
const isFetching = state.search.isFetching; const isFetching = state.search.isFetching;
const page = state.search.page; const page = state.search.page;
const entries = selectSearchedEntries(state, collectionNames); const entries = selectSearchedEntries(state, collectionNames);
console.log('searched entries', entries);
return { isFetching, page, collections, viewStyle, entries, searchTerm }; return { isFetching, page, collections, viewStyle, entries, searchTerm };
} }

View File

@ -0,0 +1,160 @@
import get from 'lodash/get';
import React, { useMemo } from 'react';
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/views';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { getFieldPreview, getPreviewCard } from '@staticcms/core/lib/registry';
import {
selectEntryCollectionTitle,
selectFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import Card from '../../common/card/Card';
import CardActionArea from '../../common/card/CardActionArea';
import CardContent from '../../common/card/CardContent';
import CardMedia from '../../common/card/CardMedia';
import TableCell from '../../common/table/TableCell';
import TableRow from '../../common/table/TableRow';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface';
export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
collection: Collection;
collectionLabel?: string;
viewStyle: ViewStyle;
summaryFields: string[];
}
const EntryCard = ({
collection,
entry,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
imageFieldName,
summaryFields,
}: EntryCardProps) => {
const entryData = entry.data;
const path = useMemo(
() => `/collections/${collection.name}/entries/${entry.slug}`,
[collection.name, entry.slug],
);
const imageField = useMemo(
() =>
'fields' in collection
? (collection.fields?.find(
f => f.name === imageFieldName && f.widget === 'image',
) as FileOrImageField)
: undefined,
[collection, imageFieldName],
);
const image = useMemo(() => {
let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined;
if (i) {
i = encodeURI(i.trim());
}
return i;
}, [entryData, imageFieldName]);
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection as Collection<MediaField>, imageField, entry);
const config = useAppSelector(selectConfig);
const { widgetFor, widgetsFor } = useWidgetsFor(config, collection, fields, entry);
const templateName = useMemo(
() => selectTemplateName(collection, entry.slug),
[collection, entry.slug],
);
const PreviewCardComponent = useMemo(() => getPreviewCard(templateName) ?? null, [templateName]);
const theme = useAppSelector(selectTheme);
if (viewStyle === VIEW_STYLE_LIST) {
return (
<TableRow
className="
hover:bg-gray-200
dark:hover:bg-slate-700/70
"
>
{collectionLabel ? (
<TableCell key="collectionLabel" to={path}>
{collectionLabel}
</TableCell>
) : null}
{summaryFields.map(fieldName => {
if (fieldName === 'summary') {
return (
<TableCell key={fieldName} to={path}>
{summary}
</TableCell>
);
}
const field = fields.find(f => f.name === fieldName);
const value = get(entry.data, fieldName);
const FieldPreviewComponent = getFieldPreview(templateName, fieldName);
return (
<TableCell key={fieldName} to={path}>
{field && FieldPreviewComponent ? (
<FieldPreviewComponent
collection={collection}
field={field}
value={value}
theme={theme}
/>
) : (
String(value)
)}
</TableCell>
);
})}
</TableRow>
);
}
if (PreviewCardComponent) {
return (
<Card>
<CardActionArea to={path}>
<PreviewCardComponent
collection={collection}
fields={fields}
entry={entry}
widgetFor={widgetFor}
widgetsFor={widgetsFor}
theme={theme}
/>
</CardActionArea>
</Card>
);
}
return (
<Card>
<CardActionArea to={path}>
{image && imageField ? <CardMedia height="140" image={imageUrl} /> : null}
<CardContent>{summary}</CardContent>
</CardActionArea>
</Card>
);
};
export default EntryCard;

View File

@ -1,45 +1,17 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { Waypoint } from 'react-waypoint'; import { Waypoint } from 'react-waypoint';
import { VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
import { transientOptions } from '@staticcms/core/lib';
import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util'; import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util';
import Table from '../../common/table/Table';
import EntryCard from './EntryCard'; import EntryCard from './EntryCard';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Collections, Entry, Field } from '@staticcms/core/interface'; import type { Collection, Collections, Entry, Field } from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor'; import type Cursor from '@staticcms/core/lib/util/Cursor';
interface CardsGridProps {
$layout: CollectionViewStyle;
}
const CardsGrid = styled(
'div',
transientOptions,
)<CardsGridProps>(
({ $layout }) => `
${
$layout === VIEW_STYLE_LIST
? `
display: flex;
flex-direction: column;
`
: `
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
`
}
width: 100%;
margin-top: 16px;
gap: 16px;
`,
);
export interface BaseEntryListingProps { export interface BaseEntryListingProps {
entries: Entry[]; entries: Entry[];
viewStyle: CollectionViewStyle; viewStyle: ViewStyle;
cursor?: Cursor; cursor?: Cursor;
handleCursorActions?: (action: string) => void; handleCursorActions?: (action: string) => void;
page?: number; page?: number;
@ -97,6 +69,20 @@ const EntryListing = ({
[], [],
); );
const summaryFields = useMemo(() => {
let fields: string[] | undefined;
if ('collection' in otherProps) {
fields = otherProps.collection.summary_fields;
}
return fields ?? ['summary'];
}, [otherProps]);
const isSingleCollectionInList = useMemo(
() => !('collections' in otherProps) || Object.keys(otherProps.collections).length === 1,
[otherProps],
);
const renderedCards = useMemo(() => { const renderedCards = useMemo(() => {
if ('collection' in otherProps) { if ('collection' in otherProps) {
const inferredFields = inferFields(otherProps.collection); const inferredFields = inferFields(otherProps.collection);
@ -107,16 +93,17 @@ const EntryListing = ({
viewStyle={viewStyle} viewStyle={viewStyle}
entry={entry} entry={entry}
key={entry.slug} key={entry.slug}
summaryFields={summaryFields}
/> />
)); ));
} }
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
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,
); );
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined; const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
const inferredFields = inferFields(collection); const inferredFields = inferFields(collection);
return collection ? ( return collection ? (
@ -124,19 +111,38 @@ const EntryListing = ({
collection={collection} collection={collection}
entry={entry} entry={entry}
imageFieldName={inferredFields.imageField} imageFieldName={inferredFields.imageField}
viewStyle={viewStyle}
collectionLabel={collectionLabel} collectionLabel={collectionLabel}
key={entry.slug} key={entry.slug}
summaryFields={summaryFields}
/> />
) : null; ) : null;
}); });
}, [entries, inferFields, otherProps, viewStyle]); }, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, viewStyle]);
return ( if (viewStyle === 'VIEW_STYLE_LIST') {
<div> return (
<CardsGrid $layout={viewStyle}> <Table columns={!isSingleCollectionInList ? ['Collection', ...summaryFields] : summaryFields}>
{renderedCards} {renderedCards}
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />} {hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
</CardsGrid> </Table>
);
}
return (
<div
className="
grid
gap-4
sm:grid-cols-1
md:grid-cols-1
lg:grid-cols-2
xl:grid-cols-3
2xl:grid-cols-4
"
>
{renderedCards}
{hasMore && handleLoadMore && <Waypoint key={page} onEnter={handleLoadMore} />}
</div> </div>
); );
}; };

Some files were not shown because too many files have changed in this diff Show More