feat: ui overhaul (#676)
@ -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
|
||||
- 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
|
||||
|
@ -55,23 +55,6 @@ module.exports = {
|
||||
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',
|
||||
},
|
||||
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
|
||||
|
@ -57,11 +57,12 @@
|
||||
"@babel/preset-typescript": "7.21.0",
|
||||
"@emotion/eslint-plugin": "11.10.0",
|
||||
"@emotion/jest": "11.10.5",
|
||||
"@types/node": "16.18.16",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "5.55.0",
|
||||
"@typescript-eslint/parser": "5.55.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-loader": "9.1.2",
|
||||
"babel-plugin-emotion": "11.0.0",
|
||||
@ -73,6 +74,7 @@
|
||||
"babel-plugin-transform-export-extensions": "6.22.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "0.4.4",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "6.7.3",
|
||||
"dotenv": "16.0.3",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-import-resolver-typescript": "3.5.3",
|
||||
@ -82,6 +84,7 @@
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-unicorn": "46.0.0",
|
||||
"mini-css-extract-plugin": "2.7.2",
|
||||
"npm-run-all": "4.1.5",
|
||||
"postcss": "8.4.21",
|
||||
"postcss-scss": "4.0.6",
|
||||
|
7
packages/app/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
7
packages/app/tailwind.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
const baseConfig = require('../../tailwind.base.config');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['../core/src/**/*.tsx'],
|
||||
...baseConfig,
|
||||
};
|
@ -49,7 +49,7 @@
|
||||
"@staticcms/string/*": ["../core/src/widgets/string/*"],
|
||||
"@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/*"]
|
||||
},
|
||||
"types": ["@emotion/react/types/css-prop", "@types/jest", "@testing-library/jest-dom"]
|
||||
|
@ -2,6 +2,7 @@ const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const devServerPort = parseInt(process.env.STATIC_CMS_DEV_SERVER_PORT || `${8080}`);
|
||||
@ -44,14 +45,11 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: ['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath),
|
||||
include: [...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), path.resolve(__dirname, '..', 'core', 'src')],
|
||||
use: [
|
||||
{
|
||||
loader: 'style-loader',
|
||||
},
|
||||
{
|
||||
loader: 'css-loader',
|
||||
},
|
||||
!isProduction ? 'style-loader' : MiniCssExtractPlugin.loader,
|
||||
'css-loader',
|
||||
'postcss-loader',
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -85,6 +83,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
!isProduction && new ReactRefreshWebpackPlugin(),
|
||||
isProduction && new MiniCssExtractPlugin(),
|
||||
new webpack.IgnorePlugin({ resourceRegExp: /^esprima$/ }),
|
||||
new webpack.IgnorePlugin({ resourceRegExp: /moment\/locale\// }),
|
||||
new webpack.ProvidePlugin({
|
||||
|
@ -55,23 +55,6 @@ module.exports = {
|
||||
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',
|
||||
},
|
||||
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
|
||||
@ -81,7 +64,7 @@ module.exports = {
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: 'packages/core/tsconfig.json',
|
||||
project: 'packages/core/tsconfig-dev.json',
|
||||
}, // this loads <rootdir>/tsconfig.json to eslint
|
||||
},
|
||||
'import/core-modules': ['src'],
|
||||
|
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 808 KiB |
BIN
packages/core/dev-test/_posts/assets/uploads/moby-dick.jpg
Normal file
After Width: | Height: | Size: 310 KiB |
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 808 KiB |
Before Width: | Height: | Size: 4.3 KiB |
@ -1,8 +1,8 @@
|
||||
---
|
||||
title: Something something something...
|
||||
title: Something something something2...
|
||||
draft: false
|
||||
date: 2022-11-01 06:30
|
||||
image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
|
||||
image: static-cms-icon.svg
|
||||
---
|
||||
# Welcome
|
||||
|
||||
|
After Width: | Height: | Size: 67 KiB |
@ -0,0 +1 @@
|
||||
Some text here!
|
After Width: | Height: | Size: 61 KiB |
@ -25,7 +25,10 @@ collections:
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
summary_fields:
|
||||
- title
|
||||
- date
|
||||
- draft
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
@ -219,7 +222,7 @@ collections:
|
||||
time_format: 'h:mm aaa'
|
||||
required: false
|
||||
- name: date_and_time_with_default
|
||||
label: Date and Time With Deafult
|
||||
label: Date and Time With Default
|
||||
widget: datetime
|
||||
format: 'MMM d, yyyy h:mm aaa'
|
||||
date_format: 'MMM d, yyyy'
|
||||
@ -233,22 +236,25 @@ collections:
|
||||
time_format: false
|
||||
required: false
|
||||
- name: date_with_default
|
||||
label: Date With Deafult
|
||||
label: Date With Default
|
||||
widget: datetime
|
||||
format: 'MMM d, yyyy'
|
||||
date_format: 'MMM d, yyyy'
|
||||
time_format: false
|
||||
required: false
|
||||
default: 'Jan 12, 2023'
|
||||
- name: time
|
||||
label: Time
|
||||
widget: datetime
|
||||
format: 'h:mm aaa'
|
||||
date_format: false
|
||||
time_format: 'h:mm aaa'
|
||||
required: false
|
||||
- name: time_with_default
|
||||
label: Time With Deafult
|
||||
label: Time With Default
|
||||
widget: datetime
|
||||
format: 'h:mm aaa'
|
||||
date_format: false
|
||||
time_format: 'h:mm aaa'
|
||||
required: false
|
||||
default: '12:00 am'
|
||||
@ -641,7 +647,7 @@ collections:
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
- date
|
||||
value_field: title
|
||||
- label: Required With Default
|
||||
name: with_default
|
||||
@ -652,7 +658,7 @@ collections:
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
- date
|
||||
value_field: title
|
||||
default: This is a YAML front matter post
|
||||
- label: Optional Validation
|
||||
@ -665,7 +671,7 @@ collections:
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
- date
|
||||
value_field: title
|
||||
- label: Multiple
|
||||
name: multiple
|
||||
@ -678,7 +684,7 @@ collections:
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
- date
|
||||
value_field: title
|
||||
- label: Multiple With Default
|
||||
name: multiple_with_default
|
||||
@ -694,7 +700,7 @@ collections:
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
- date
|
||||
value_field: title
|
||||
- name: select
|
||||
label: Select
|
||||
|
@ -6,7 +6,27 @@
|
||||
<title>Static CMS Development Test</title>
|
||||
<script>
|
||||
window.repoFiles = {
|
||||
assets: {
|
||||
uploads: {
|
||||
'moby-dick.jpg': {
|
||||
content: '',
|
||||
},
|
||||
'lobby.jpg': {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
_posts: {
|
||||
assets: {
|
||||
uploads: {
|
||||
'moby-dick.jpg': {
|
||||
content: '',
|
||||
},
|
||||
'lobby.jpg': {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'2015-02-14-this-is-a-post.md': {
|
||||
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',
|
||||
@ -15,10 +35,6 @@
|
||||
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',
|
||||
},
|
||||
'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': {
|
||||
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',
|
||||
@ -104,15 +120,15 @@
|
||||
_i18n_playground: {
|
||||
'file1.en.md': {
|
||||
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': {
|
||||
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': {
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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(
|
||||
'div',
|
||||
{ style: { width: '100%' } },
|
||||
viewStyle === 'grid' ? widgetFor('image') : null,
|
||||
h(
|
||||
'div',
|
||||
{ style: { padding: '16px', width: '100%' } },
|
||||
@ -27,6 +31,8 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'start',
|
||||
gap: '4px',
|
||||
color: theme === 'dark' ? 'white' : 'inherit',
|
||||
},
|
||||
},
|
||||
h(
|
||||
@ -34,13 +40,31 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: viewStyle === 'grid' ? 'column' : 'row',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'baseline',
|
||||
gap: '8px',
|
||||
gap: '4px',
|
||||
},
|
||||
},
|
||||
h('strong', { style: { fontSize: '24px' } }, entry.data.title),
|
||||
h('span', { style: { fontSize: '16px' } }, entry.data.date),
|
||||
h(
|
||||
'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(
|
||||
'div',
|
||||
@ -49,12 +73,13 @@ const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
|
||||
backgroundColor: entry.data.draft === true ? 'blue' : 'green',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
padding: '2px 6px',
|
||||
textAlign: 'center',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
},
|
||||
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 title = entry.data.site_title;
|
||||
const posts = entry.data.posts;
|
||||
@ -134,6 +193,8 @@ const CustomPage = () => {
|
||||
|
||||
CMS.registerPreviewTemplate('posts', PostPreview);
|
||||
CMS.registerPreviewCard('posts', PostPreviewCard);
|
||||
CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview);
|
||||
CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview);
|
||||
CMS.registerPreviewTemplate('general', GeneralPreview);
|
||||
CMS.registerPreviewTemplate('authors', AuthorsPreview);
|
||||
// Pass the name of a registered control to reuse with a new widget preview.
|
||||
@ -170,7 +231,8 @@ CMS.registerShortcode('youtube', {
|
||||
toArgs: ({ src }) => {
|
||||
return [src];
|
||||
},
|
||||
control: ({ src, onChange }) => {
|
||||
control: ({ src, onChange, theme }) => {
|
||||
console.log('[SHORTCUT] shortcut theme', theme);
|
||||
return h('span', {}, [
|
||||
h('input', {
|
||||
key: 'control-input',
|
||||
@ -178,12 +240,18 @@ CMS.registerShortcode('youtube', {
|
||||
onChange: event => {
|
||||
onChange({ src: event.target.value });
|
||||
},
|
||||
style: {
|
||||
width: '100%',
|
||||
backgroundColor: theme === 'dark' ? 'rgb(30, 41, 59)' : 'white',
|
||||
color: theme === 'dark' ? 'white' : 'black',
|
||||
padding: '4px 8px',
|
||||
},
|
||||
}),
|
||||
h(
|
||||
'iframe',
|
||||
{
|
||||
key: 'control-preview',
|
||||
width: '420',
|
||||
width: '100%',
|
||||
height: '315',
|
||||
src: `https://www.youtube.com/embed/${src}`,
|
||||
},
|
||||
|
@ -13,6 +13,7 @@ module.exports = {
|
||||
'\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts',
|
||||
},
|
||||
setupFiles: ['./test/setupEnv.js'],
|
||||
globalSetup: './test/globalSetup.js',
|
||||
testRegex: '\\.spec\\.tsx?$',
|
||||
snapshotSerializers: ['@emotion/jest/serializer'],
|
||||
};
|
||||
|
@ -58,7 +58,7 @@
|
||||
"@codemirror/search": "6.2.3",
|
||||
"@codemirror/state": "6.2.0",
|
||||
"@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/sortable": "7.0.2",
|
||||
"@dnd-kit/utilities": "3.2.1",
|
||||
@ -66,16 +66,23 @@
|
||||
"@emotion/css": "11.10.6",
|
||||
"@emotion/react": "11.10.6",
|
||||
"@emotion/styled": "11.10.6",
|
||||
"@headlessui/react": "1.7.7",
|
||||
"@lezer/common": "1.0.2",
|
||||
"@mdx-js/mdx": "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/system": "5.11.13",
|
||||
"@mui/x-date-pickers": "5.0.20",
|
||||
"@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/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/simple-icons": "10.46.0",
|
||||
"@udecode/plate": "19.7.0",
|
||||
"@udecode/plate-juice": "20.0.0",
|
||||
"@udecode/plate-serializer-md": "20.0.0",
|
||||
@ -84,7 +91,6 @@
|
||||
"ajv": "8.12.0",
|
||||
"ajv-errors": "3.0.0",
|
||||
"ajv-keywords": "5.1.0",
|
||||
"array-move": "4.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"clean-stack": "5.1.0",
|
||||
"codemirror": "6.0.1",
|
||||
@ -193,7 +199,7 @@
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/minimatch": "5.1.2",
|
||||
"@types/node": "16.18.16",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-color": "3.0.6",
|
||||
@ -205,6 +211,7 @@
|
||||
"@types/uuid": "9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.55.0",
|
||||
"@typescript-eslint/parser": "5.55.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"axios": "1.3.4",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-loader": "9.1.2",
|
||||
@ -237,13 +244,14 @@
|
||||
"jest": "29.5.0",
|
||||
"jest-environment-jsdom": "29.5.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"mini-css-extract-plugin": "2.7.2",
|
||||
"mockserver-client": "5.15.0",
|
||||
"mockserver-node": "5.15.0",
|
||||
"ncp": "2.0.0",
|
||||
"node-fetch": "3.3.1",
|
||||
"npm-run-all": "4.1.5",
|
||||
"postcss": "8.4.21",
|
||||
"postcss-scss": "4.0.6",
|
||||
"postcss-loader": "7.1.0",
|
||||
"prettier": "2.8.4",
|
||||
"process": "0.11.10",
|
||||
"react-refresh": "0.14.0",
|
||||
@ -252,6 +260,7 @@
|
||||
"simple-git": "3.17.0",
|
||||
"source-map-loader": "4.0.1",
|
||||
"style-loader": "3.3.2",
|
||||
"tailwindcss": "3.2.4",
|
||||
"to-string-loader": "1.2.0",
|
||||
"ts-jest": "29.0.5",
|
||||
"tsconfig-paths-webpack-plugin": "4.0.1",
|
||||
|
7
packages/core/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
@ -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();
|
1
packages/core/src/__mocks__/copy-text-to-clipboard.ts
Normal file
@ -0,0 +1 @@
|
||||
export default jest.fn();
|
14
packages/core/src/__mocks__/react-polyglot.ts
Normal 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 });
|
||||
};
|
||||
};
|
@ -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 }));
|
||||
}
|
6
packages/core/src/__mocks__/react-waypoint.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
export function Waypoint() {
|
||||
return React.createElement('div', {}, []);
|
||||
}
|
@ -52,14 +52,14 @@ import { addSnackbar } from '../store/slices/snackbars';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import createEntry from '../valueObjects/createEntry';
|
||||
import { addAssets, getAsset } from './media';
|
||||
import { loadMedia, waitForMediaLibraryToLoad } from './mediaLibrary';
|
||||
import { loadMedia } from './mediaLibrary';
|
||||
import { waitUntil } from './waitUntil';
|
||||
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { Backend } from '../backend';
|
||||
import type { CollectionViewStyle } from '../constants/collectionViews';
|
||||
import type { ViewStyle } from '../constants/views';
|
||||
import type {
|
||||
Collection,
|
||||
Entry,
|
||||
@ -345,7 +345,7 @@ export function groupByField(collection: Collection, group: ViewGroup) {
|
||||
};
|
||||
}
|
||||
|
||||
export function changeViewStyle(viewStyle: CollectionViewStyle) {
|
||||
export function changeViewStyle(viewStyle: ViewStyle) {
|
||||
return {
|
||||
type: CHANGE_VIEW_STYLE,
|
||||
payload: {
|
||||
@ -586,12 +586,12 @@ export function deleteLocalBackup(collection: Collection, slug: string) {
|
||||
|
||||
export function loadEntry(collection: Collection, slug: string, silent = false) {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
if (!silent) {
|
||||
dispatch(entryLoading(collection, slug));
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(loadMedia());
|
||||
const loadedEntry = await tryLoadEntry(getState(), collection, slug);
|
||||
dispatch(entryLoaded(collection, loadedEntry));
|
||||
dispatch(createDraftFromEntry(loadedEntry));
|
||||
@ -836,10 +836,6 @@ export function createEmptyDraft(collection: Collection, search: string) {
|
||||
|
||||
const backend = currentBackend(configState.config);
|
||||
|
||||
if (!('media_folder' in collection)) {
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
}
|
||||
|
||||
const i18nFields = createEmptyDraftI18nData(collection, fields);
|
||||
|
||||
let newEntry = createEntry(collection.name, '', '', {
|
||||
|
8
packages/core/src/actions/globalUI.ts
Normal 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>;
|
@ -8,7 +8,7 @@ import {
|
||||
} from '../constants';
|
||||
import { selectMediaFilePath } from '../lib/util/media.util';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
|
||||
import { getMediaFile } from './mediaLibrary';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
@ -55,7 +55,7 @@ async function loadAsset(
|
||||
try {
|
||||
dispatch(loadAssetRequest(resolvedPath));
|
||||
// load asset url from backend
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
// await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
const { url } = await getMediaFile(getState(), resolvedPath);
|
||||
const asset = createAssetProxy({ path: resolvedPath, url });
|
||||
dispatch(addAsset(asset));
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import confirm from '../components/UI/Confirm';
|
||||
import confirm from '../components/common/confirm/Confirm';
|
||||
import {
|
||||
MEDIA_DELETE_FAILURE,
|
||||
MEDIA_DELETE_REQUEST,
|
||||
@ -39,6 +39,7 @@ import type {
|
||||
Field,
|
||||
ImplementationMediaFile,
|
||||
MediaFile,
|
||||
MediaLibrarInsertOptions,
|
||||
MediaLibraryInstance,
|
||||
UnknownField,
|
||||
} from '../interface';
|
||||
@ -81,11 +82,13 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
value?: string | string[];
|
||||
alt?: string;
|
||||
allowMultiple?: boolean;
|
||||
replaceIndex?: number;
|
||||
config?: Record<string, unknown>;
|
||||
collection?: Collection<F>;
|
||||
field?: F;
|
||||
insertOptions?: MediaLibrarInsertOptions;
|
||||
} = {},
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
@ -94,12 +97,14 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
|
||||
const {
|
||||
controlID,
|
||||
value,
|
||||
alt,
|
||||
config = {},
|
||||
allowMultiple,
|
||||
forImage,
|
||||
replaceIndex,
|
||||
collection,
|
||||
field,
|
||||
insertOptions,
|
||||
} = payload;
|
||||
|
||||
if (mediaLibrary) {
|
||||
@ -111,11 +116,13 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
|
||||
controlID,
|
||||
forImage,
|
||||
value,
|
||||
alt,
|
||||
allowMultiple,
|
||||
replaceIndex,
|
||||
config,
|
||||
collection: collection as Collection,
|
||||
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) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
@ -150,7 +157,7 @@ export function insertMedia(mediaPath: string | string[], field: Field | undefin
|
||||
} else {
|
||||
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 files: MediaFile[] = selectMediaFiles(state, field);
|
||||
const files: MediaFile[] = selectMediaFiles(field)(state);
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
|
||||
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
|
||||
|
||||
@ -417,11 +424,13 @@ function mediaLibraryOpened(payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
value?: string | string[];
|
||||
alt?: string;
|
||||
replaceIndex?: number;
|
||||
allowMultiple?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
collection?: Collection;
|
||||
field?: Field;
|
||||
insertOptions?: MediaLibrarInsertOptions;
|
||||
}) {
|
||||
return { type: MEDIA_LIBRARY_OPEN, payload } as const;
|
||||
}
|
||||
@ -430,8 +439,8 @@ function mediaLibraryClosed() {
|
||||
return { type: MEDIA_LIBRARY_CLOSE } as const;
|
||||
}
|
||||
|
||||
function mediaInserted(mediaPath: string | string[]) {
|
||||
return { type: MEDIA_INSERT, payload: { mediaPath } } as const;
|
||||
export function mediaInserted(mediaPath: string | string[], alt?: string) {
|
||||
return { type: MEDIA_INSERT, payload: { mediaPath, alt } } as const;
|
||||
}
|
||||
|
||||
export function mediaLoading(page: number) {
|
||||
|
@ -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 AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import Login from '@staticcms/core/components/login/Login';
|
||||
import { ImplicitAuthenticator, NetlifyAuthenticator } from '@staticcms/core/lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const BitbucketAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
@ -80,15 +75,12 @@ const BitbucketAuthenticationPage = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="bitbucket" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')}
|
||||
t={t}
|
||||
<Login
|
||||
login={handleLogin}
|
||||
label={t('auth.loginWithBitbucket')}
|
||||
icon={BitbucketIcon}
|
||||
inProgress={inProgress}
|
||||
error={loginError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
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';
|
||||
|
||||
@ -22,11 +22,7 @@ export interface GitGatewayAuthenticationPageProps
|
||||
handleAuth: (email: string, password: string) => Promise<User | string>;
|
||||
}
|
||||
|
||||
const GitGatewayAuthenticationPage = ({
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: GitGatewayAuthenticationPageProps) => {
|
||||
const GitGatewayAuthenticationPage = ({ onLogin, t }: GitGatewayAuthenticationPageProps) => {
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
@ -97,7 +93,7 @@ const GitGatewayAuthenticationPage = ({
|
||||
}
|
||||
}, [onLogin]);
|
||||
|
||||
const pageContent = useMemo(() => {
|
||||
const errorContent = useMemo(() => {
|
||||
if (!window.netlifyIdentity) {
|
||||
return t('auth.errors.netlifyIdentityNotFound');
|
||||
}
|
||||
@ -118,15 +114,11 @@ const GitGatewayAuthenticationPage = ({
|
||||
}, [errors.identity, t]);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
key="git-gateway-auth"
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={handleIdentity}
|
||||
buttonContent={t('auth.loginWithNetlifyIdentity')}
|
||||
pageContent={pageContent}
|
||||
loginDisabled={loggingIn}
|
||||
t={t}
|
||||
<Login
|
||||
login={handleIdentity}
|
||||
label={t('auth.loginWithNetlifyIdentity')}
|
||||
inProgress={loggingIn}
|
||||
error={errorContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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 AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import Login from '@staticcms/core/components/login/Login';
|
||||
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const GiteaAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
@ -48,15 +43,12 @@ const GiteaAuthenticationPage = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="gitea" />}
|
||||
buttonContent={t('auth.loginWithGitea')}
|
||||
t={t}
|
||||
<Login
|
||||
login={handleLogin}
|
||||
label={t('auth.loginWithGitea')}
|
||||
icon={GiteaIcon}
|
||||
inProgress={inProgress}
|
||||
error={loginError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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 AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import Login from '@staticcms/core/components/login/Login';
|
||||
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const GitHubAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
@ -48,15 +43,12 @@ const GitHubAuthenticationPage = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="github" />}
|
||||
buttonContent={t('auth.loginWithGitHub')}
|
||||
t={t}
|
||||
<Login
|
||||
login={handleLogin}
|
||||
label={t('auth.loginWithGitHub')}
|
||||
icon={GithubIcon}
|
||||
inProgress={inProgress}
|
||||
error={loginError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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 AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import Login from '@staticcms/core/components/login/Login';
|
||||
import { NetlifyAuthenticator, PkceAuthenticator } from '@staticcms/core/lib/auth';
|
||||
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type {
|
||||
AuthenticationPageProps,
|
||||
AuthenticatorConfig,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const clientSideAuthenticators = {
|
||||
pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config),
|
||||
@ -83,15 +78,12 @@ const GitLabAuthenticationPage = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="gitlab" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')}
|
||||
t={t}
|
||||
<Login
|
||||
login={handleLogin}
|
||||
label={t('auth.loginWithGitLab')}
|
||||
icon={GitlabIcon}
|
||||
inProgress={inProgress}
|
||||
error={loginError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,30 +1,13 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import GoBackButton from '@staticcms/core/components/UI/GoBackButton';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import Login from '@staticcms/core/components/login/Login';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const AuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
@ -34,15 +17,7 @@ const AuthenticationPage = ({
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <Login login={handleLogin} inProgress={inProgress} />;
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
||||
|
@ -1,30 +1,14 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import GoBackButton from '@staticcms/core/components/UI/GoBackButton';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import Login from '@staticcms/core/components/login/Login';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const AuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
@ -44,20 +28,7 @@ const AuthenticationPage = ({
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <Login login={handleLogin} inProgress={inProgress} />;
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
||||
|
@ -5,16 +5,17 @@ import unset from 'lodash/unset';
|
||||
import { extname } from 'path';
|
||||
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 type {
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
BackendEntry,
|
||||
Config,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
ImplementationMediaFile,
|
||||
User,
|
||||
} from '@staticcms/core/interface';
|
||||
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
||||
@ -215,37 +216,35 @@ export default class TestBackend implements BackendClass {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
async getMedia(mediaFolder = this.mediaFolder): Promise<ImplementationMediaFile[]> {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
|
||||
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) {
|
||||
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 {
|
||||
id: url,
|
||||
displayURL: url,
|
||||
id: path,
|
||||
displayURL: path,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
name: path,
|
||||
size: 1,
|
||||
url: path,
|
||||
};
|
||||
}
|
||||
|
||||
normalizeAsset(assetProxy: AssetProxy) {
|
||||
normalizeAsset(assetProxy: AssetProxy): ImplementationMediaFile {
|
||||
const fileObj = assetProxy.fileObj as File;
|
||||
|
||||
const { name, size } = fileObj;
|
||||
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
|
||||
const url = isError(objectUrl) ? '' : objectUrl;
|
||||
|
@ -9,9 +9,9 @@ import { HashRouter as Router } from 'react-router-dom';
|
||||
import 'what-input';
|
||||
import { authenticateUser } from './actions/auth';
|
||||
import { loadConfig } from './actions/config';
|
||||
import App from './components/App/App';
|
||||
import './components/EditorWidgets';
|
||||
import { ErrorBoundary } from './components/UI';
|
||||
import App from './components/App';
|
||||
import './components/entry-editor/widgets';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import addExtensions from './extensions';
|
||||
import { getPhrases } from './lib/phrases';
|
||||
import './mediaLibrary';
|
||||
@ -23,6 +23,12 @@ import type { ConnectedProps } from 'react-redux';
|
||||
import type { BaseField, Config, UnknownField } from './interface';
|
||||
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 TranslatedApp = ({ locale, config }: AppRootProps) => {
|
||||
|
@ -1,6 +1,4 @@
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
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 { discardDraft } from '@staticcms/core/actions/entries';
|
||||
import { currentBackend } from '@staticcms/core/backend';
|
||||
import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles';
|
||||
import { useAppDispatch } from '@staticcms/core/store/hooks';
|
||||
import { getDefaultPath } from '../../lib/util/collection.util';
|
||||
import CollectionRoute from '../collection/CollectionRoute';
|
||||
import EditorRoute from '../editor/EditorRoute';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Page from '../page/Page';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import ScrollTop from '../UI/ScrollTop';
|
||||
import { changeTheme } from '../actions/globalUI';
|
||||
import { getDefaultPath } from '../lib/util/collection.util';
|
||||
import { selectTheme } from '../reducers/selectors/globalUI';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import CollectionRoute from './collections/CollectionRoute';
|
||||
import { Alert } from './common/alert/Alert';
|
||||
import { Confirm } from './common/confirm/Confirm';
|
||||
import Loader from './common/progress/Loader';
|
||||
import EditorRoute from './entry-editor/EditorRoute';
|
||||
import MediaPage from './media-library/MediaPage';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import Page from './page/Page';
|
||||
import Snackbars from './snackbar/Snackbars';
|
||||
|
||||
import type { Credentials, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
@ -40,35 +38,16 @@ import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
0: '#000',
|
||||
'1.0': '#000',
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const AppRoot = styled('div')`
|
||||
width: 100%;
|
||||
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;
|
||||
`;
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
|
||||
function CollectionSearchRedirect() {
|
||||
const { name } = useParams();
|
||||
@ -87,24 +66,43 @@ const App = ({
|
||||
collections,
|
||||
loginUser,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
t,
|
||||
scrollSyncEnabled,
|
||||
}: TranslatedProps<AppProps>) => {
|
||||
const navigate = useNavigate();
|
||||
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(
|
||||
(error?: string) => {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<div>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{error ?? config.error}</ErrorCodeBlock>
|
||||
<div>{error ?? config.error}</div>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[config.error, t],
|
||||
@ -140,20 +138,18 @@ const App = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div key="auth-page-wrapper">
|
||||
<AuthComponent
|
||||
key="auth-page"
|
||||
onLogin={handleLogin}
|
||||
error={auth.error}
|
||||
inProgress={auth.isFetching}
|
||||
siteId={config.config.backend.site_domain}
|
||||
base_url={config.config.backend.base_url}
|
||||
authEndpoint={config.config.backend.auth_endpoint}
|
||||
config={config.config}
|
||||
clearHash={() => navigate('/', { replace: true })}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<AuthComponent
|
||||
key="auth-page"
|
||||
onLogin={handleLogin}
|
||||
error={auth.error}
|
||||
inProgress={auth.isFetching}
|
||||
siteId={config.config.backend.site_domain}
|
||||
base_url={config.config.backend.base_url}
|
||||
authEndpoint={config.config.backend.auth_endpoint}
|
||||
config={config.config}
|
||||
clearHash={() => navigate('/', { replace: true })}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]);
|
||||
|
||||
@ -173,6 +169,21 @@ const App = ({
|
||||
dispatch(discardDraft());
|
||||
}, [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(() => {
|
||||
if (!user) {
|
||||
return authenticationPage;
|
||||
@ -189,11 +200,8 @@ const App = ({
|
||||
path="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
|
||||
element={<Navigate to={defaultPath} />}
|
||||
/>
|
||||
<Route path="/collections" element={<CollectionRoute collections={collections} />} />
|
||||
<Route
|
||||
path="/collections/:name"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route path="/collections" element={<CollectionRoute />} />
|
||||
<Route path="/collections/:name" element={<CollectionRoute />} />
|
||||
<Route
|
||||
path="/collections/:name/new"
|
||||
element={<EditorRoute collections={collections} newRecord />}
|
||||
@ -204,26 +212,18 @@ const App = ({
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/search/:searchTerm"
|
||||
element={
|
||||
<CollectionRoute collections={collections} isSearchResults isSingleSearchResult />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/filter/:filterTerm"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
element={<CollectionRoute collections={collections} isSearchResults />}
|
||||
element={<CollectionRoute isSearchResults isSingleSearchResult />}
|
||||
/>
|
||||
<Route path="/collections/:name/filter/:filterTerm" element={<CollectionRoute />} />
|
||||
<Route path="/search/:searchTerm" element={<CollectionRoute isSearchResults />} />
|
||||
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
|
||||
<Route path="/page/:id" element={<Page />} />
|
||||
<Route path="/media" element={<MediaPage />} />
|
||||
<Route element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
</>
|
||||
);
|
||||
}, [authenticationPage, collections, defaultPath, isFetching, useMediaLibrary, user]);
|
||||
}, [authenticationPage, collections, defaultPath, isFetching, user]);
|
||||
|
||||
if (!config.config) {
|
||||
return configError(t('app.app.configNotFound'));
|
||||
@ -238,35 +238,28 @@ const App = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles key="global-styles" />
|
||||
<ThemeProvider theme={theme}>
|
||||
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
|
||||
<>
|
||||
<div key="back-to-top-anchor" id="back-to-top-anchor" />
|
||||
<AppRoot key="cms-root" id="cms-root">
|
||||
<AppWrapper key="cms-wrapper" className="cms-wrapper">
|
||||
<div key="cms-root" id="cms-root" className="h-full">
|
||||
<div key="cms-wrapper" className="cms-wrapper">
|
||||
<Snackbars key="snackbars" />
|
||||
{content}
|
||||
<Alert key="alert" />
|
||||
<Confirm key="confirm" />
|
||||
</AppWrapper>
|
||||
</AppRoot>
|
||||
<ScrollTop key="scroll-to-top">
|
||||
<Fab size="small" aria-label="scroll back to top">
|
||||
<KeyboardArrowUpIcon />
|
||||
</Fab>
|
||||
</ScrollTop>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ScrollSync>
|
||||
</>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const { auth, config, collections, globalUI, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const useMediaLibrary = !mediaLibrary.externalLibrary;
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
@ -274,7 +267,6 @@ function mapStateToProps(state: RootState) {
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
@ -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>);
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -1,4 +1,3 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import cleanStack from 'clean-stack';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import truncate from 'lodash/truncate';
|
||||
@ -6,11 +5,10 @@ import React, { Component } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { buttons, colors } from '@staticcms/core/components/UI/styles';
|
||||
import { localForage } from '@staticcms/core/lib/util';
|
||||
|
||||
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?';
|
||||
|
||||
@ -38,14 +36,14 @@ ${config}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildIssueTemplate(config: Config) {
|
||||
function buildIssueTemplate(config?: Config) {
|
||||
let version = '';
|
||||
if (typeof STATIC_CMS_CORE_VERSION === 'string') {
|
||||
version = `static-cms@${STATIC_CMS_CORE_VERSION}`;
|
||||
}
|
||||
const template = getIssueTemplate(
|
||||
version,
|
||||
config?.backend?.name,
|
||||
config?.backend?.name ?? 'Unknown',
|
||||
navigator.userAgent,
|
||||
yaml.stringify(config),
|
||||
);
|
||||
@ -53,7 +51,7 @@ function buildIssueTemplate(config: Config) {
|
||||
return template;
|
||||
}
|
||||
|
||||
function buildIssueUrl(title: string, config: Config) {
|
||||
function buildIssueUrl(title: string, config?: Config) {
|
||||
try {
|
||||
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 {
|
||||
entry: string;
|
||||
}
|
||||
@ -122,9 +78,9 @@ const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
|
||||
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
|
||||
<CopyButton onClick={() => copyToClipboard(entry)}>
|
||||
<button onClick={() => copyToClipboard(entry)}>
|
||||
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
|
||||
</CopyButton>
|
||||
</button>
|
||||
<pre>
|
||||
<code>{entry}</code>
|
||||
</pre>
|
||||
@ -134,7 +90,7 @@ const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
config: Config;
|
||||
config?: Config;
|
||||
showBackup?: boolean;
|
||||
}
|
||||
|
||||
@ -145,10 +101,7 @@ interface ErrorBoundaryState {
|
||||
backup: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
TranslatedProps<ErrorBoundaryProps>,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = {
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
@ -194,7 +147,7 @@ export class ErrorBoundary extends Component<
|
||||
return this.props.children;
|
||||
}
|
||||
return (
|
||||
<ErrorBoundaryContainer key="error-boundary-container">
|
||||
<div key="error-boundary-container">
|
||||
<h1>{t('ui.errorBoundary.title')}</h1>
|
||||
<p>
|
||||
<span>{t('ui.errorBoundary.details')}</span>
|
||||
@ -211,7 +164,7 @@ export class ErrorBoundary extends Component<
|
||||
{t('ui.errorBoundary.privacyWarning')
|
||||
.split('\n')
|
||||
.map((item, index) => [
|
||||
<PrivacyWarning key={`private-warning-${index}`}>{item}</PrivacyWarning>,
|
||||
<span key={`private-warning-${index}`}>{item}</span>,
|
||||
<br key={`break-${index}`} />,
|
||||
])}
|
||||
</p>
|
||||
@ -219,9 +172,9 @@ export class ErrorBoundary extends Component<
|
||||
<h2>{t('ui.errorBoundary.detailsHeading')}</h2>
|
||||
<p>{errorMessage}</p>
|
||||
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
|
||||
</ErrorBoundaryContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(ErrorBoundary);
|
||||
export default translate()(ErrorBoundary) as ComponentClass<ErrorBoundaryProps>;
|
63
packages/core/src/components/MainView.tsx
Normal 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;
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -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>;
|
@ -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;
|
@ -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;
|
@ -1,21 +1,14 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { lengths } from '@staticcms/core/components/UI/styles';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
const NotFoundContainer = styled('div')`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const NotFoundPage = ({ t }: TranslateProps) => {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<div>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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: 15∏0px;
|
||||
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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -1,2 +0,0 @@
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as SettingsDropdown } from './SettingsDropdown';
|
@ -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,
|
||||
};
|
@ -1,4 +1,3 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
@ -9,7 +8,6 @@ import {
|
||||
groupByField as groupByFieldAction,
|
||||
sortByField as sortByFieldAction,
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import { components } from '@staticcms/core/components/UI/styles';
|
||||
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
|
||||
import { getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
|
||||
import {
|
||||
@ -23,11 +21,11 @@ import {
|
||||
selectEntriesSort,
|
||||
selectViewStyle,
|
||||
} from '@staticcms/core/reducers/selectors/entries';
|
||||
import Card from '../common/card/Card';
|
||||
import CollectionControls from './CollectionControls';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import Sidebar from './Sidebar';
|
||||
import CollectionHeader from './CollectionHeader';
|
||||
import EntriesCollection from './entries/EntriesCollection';
|
||||
import EntriesSearch from './entries/EntriesSearch';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
@ -40,24 +38,11 @@ import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType } from 'react';
|
||||
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 = ({
|
||||
collection,
|
||||
collections,
|
||||
collectionName,
|
||||
isSearchEnabled,
|
||||
// TODO isSearchEnabled,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
@ -211,49 +196,45 @@ const CollectionView = ({
|
||||
};
|
||||
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
|
||||
|
||||
const collectionDescription = collection?.description;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={(!isSearchResults || isSingleSearchResult) && collection}
|
||||
isSearchEnabled={isSearchEnabled}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
<CollectionMain>
|
||||
<>
|
||||
{isSearchResults ? (
|
||||
<>
|
||||
<SearchResultContainer>
|
||||
<SearchResultHeading>
|
||||
{t(searchResultKey, { searchTerm, collection: collection?.label })}
|
||||
</SearchResultHeading>
|
||||
</SearchResultContainer>
|
||||
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={changeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters ?? []}
|
||||
viewGroups={viewGroups ?? []}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{entries}
|
||||
</>
|
||||
</CollectionMain>
|
||||
</>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center mb-4">
|
||||
{isSearchResults ? (
|
||||
<>
|
||||
<div className="flex-grow">
|
||||
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
|
||||
</div>
|
||||
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} t={t} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CollectionHeader collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={changeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters ?? []}
|
||||
viewGroups={viewGroups ?? []}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{collectionDescription ? (
|
||||
<div className="flex flex-grow mb-4">
|
||||
<Card className="flex-grow px-3.5 py-2.5 text-sm">{collectionDescription}</Card>
|
||||
</div>
|
||||
) : null}
|
||||
{entries}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -281,7 +262,7 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionV
|
||||
const sortableFields = selectSortableFields(collection, t);
|
||||
const viewFilters = selectViewFilters(collection);
|
||||
const viewGroups = selectViewGroups(collection);
|
||||
const filter = selectEntriesFilter(state, collection?.name);
|
||||
const filter = selectEntriesFilter(collection?.name)(state);
|
||||
const group = selectEntriesGroup(state, collection?.name);
|
||||
const viewStyle = selectViewStyle(state);
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import FilterControl from './FilterControl';
|
||||
import GroupControl from './GroupControl';
|
||||
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 {
|
||||
FilterMap,
|
||||
GroupMap,
|
||||
@ -18,21 +17,9 @@ import type {
|
||||
ViewGroup,
|
||||
} 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 {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
viewStyle: ViewStyle;
|
||||
onChangeViewStyle: (viewStyle: ViewStyle) => void;
|
||||
sortableFields?: SortableField[];
|
||||
onSortClick?: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort?: SortMap | undefined;
|
||||
@ -59,7 +46,7 @@ const CollectionControls = ({
|
||||
group,
|
||||
}: TranslatedProps<CollectionControlsProps>) => {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<div className="flex gap-2 items-center relative z-20">
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{viewGroups && onGroupClick && group
|
||||
? viewGroups.length > 0 && (
|
||||
@ -81,7 +68,7 @@ const CollectionControls = ({
|
||||
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
|
||||
)
|
||||
: null}
|
||||
</CollectionControlsContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
@ -1,30 +1,26 @@
|
||||
import React, { useMemo } from 'react';
|
||||
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 MainView from '../App/MainView';
|
||||
import MainView from '../MainView';
|
||||
import Collection from './Collection';
|
||||
|
||||
import type { Collections } from '@staticcms/core/interface';
|
||||
|
||||
interface CollectionRouteProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const CollectionRoute = ({
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
collections,
|
||||
}: CollectionRouteProps) => {
|
||||
const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => {
|
||||
const { name, searchTerm, filterTerm } = useParams();
|
||||
const collection = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const collectionSelector = useMemo(() => selectCollection(name), [name]);
|
||||
const collection = useAppSelector(collectionSelector);
|
||||
const collections = useAppSelector(selectCollections);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
@ -37,7 +33,7 @@ const CollectionRoute = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<MainView>
|
||||
<MainView breadcrumbs={[{ name: collection?.label }]} showQuickCreate showLeftNav>
|
||||
<Collection
|
||||
name={name}
|
||||
searchTerm={searchTerm}
|
@ -1,64 +1,10 @@
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import PopperUnstyled from '@mui/base/PopperUnstyled';
|
||||
import { Search as SearchIcon } from '@styled-icons/material/Search';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
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;
|
||||
`;
|
||||
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
|
||||
interface CollectionSearchProps {
|
||||
collections: Collections;
|
||||
@ -191,27 +137,116 @@ const CollectionSearch = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchContainer>
|
||||
<TextField
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputRef,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<StyledPopover
|
||||
<div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<SearchIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
className="
|
||||
block
|
||||
w-full
|
||||
p-1.5
|
||||
pl-10
|
||||
text-sm
|
||||
text-gray-900
|
||||
border
|
||||
border-gray-300
|
||||
rounded-lg
|
||||
bg-gray-50
|
||||
focus-visible:outline-none
|
||||
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"
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
@ -224,30 +259,27 @@ const CollectionSearch = ({
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
sx={{
|
||||
width: 300
|
||||
}}
|
||||
>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
$isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
<div>
|
||||
<div>{t('collection.sidebar.searchIn')}</div>
|
||||
<div onClick={e => handleSuggestionClick(e, -1)} onMouseDown={e => e.preventDefault()}>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
</div>
|
||||
{collections.map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
<div
|
||||
key={idx}
|
||||
$isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.label}
|
||||
</SuggestionItem>
|
||||
</div>
|
||||
))}
|
||||
</Suggestions>
|
||||
</StyledPopover>
|
||||
</SearchContainer>
|
||||
</div>
|
||||
</Popover> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
66
packages/core/src/components/collections/FilterControl.tsx
Normal 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);
|
45
packages/core/src/components/collections/GroupControl.tsx
Normal 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);
|
@ -1,78 +1,17 @@
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Article as ArticleIcon } from '@styled-icons/material/Article';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { dirname, sep } from 'path';
|
||||
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 { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
|
||||
import { stringTemplate } from '@staticcms/core/lib/widgets';
|
||||
import NavLink from '../navbar/NavLink';
|
||||
|
||||
import type { Collection, Entry } from '@staticcms/core/interface';
|
||||
|
||||
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 {
|
||||
title: string | undefined;
|
||||
path: string;
|
||||
@ -122,19 +61,19 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
|
||||
|
||||
return (
|
||||
<Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
<NavLink
|
||||
to={to}
|
||||
$activeClassName="sidebar-active"
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
$depth={depth}
|
||||
data-testid={node.path}
|
||||
>
|
||||
<ArticleIcon />
|
||||
<NodeTitleContainer>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
|
||||
</NodeTitleContainer>
|
||||
</TreeNavLink>
|
||||
{/* TODO $activeClassName="sidebar-active" */}
|
||||
{/* TODO $depth={depth} */}
|
||||
<ArticleIcon className="h-5 w-5" />
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
{hasChildren && (node.expanded ? <div /> : <div />)}
|
||||
</div>
|
||||
</NavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
84
packages/core/src/components/collections/SortControl.tsx
Normal 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);
|
@ -1,28 +1,18 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
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 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 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 {
|
||||
entries: Entry[];
|
||||
page?: number;
|
||||
isFetching: boolean;
|
||||
viewStyle: CollectionViewStyle;
|
||||
viewStyle: ViewStyle;
|
||||
cursor: Cursor;
|
||||
handleCursorActions?: (action: string) => void;
|
||||
}
|
||||
@ -83,13 +73,13 @@ const Entries = ({
|
||||
/>
|
||||
)}
|
||||
{isFetching && page !== undefined && entries.length > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
<div>{t('collection.entries.loadingEntries')}</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
return <div>{t('collection.entries.noEntries')}</div>;
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
@ -1,10 +1,8 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
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 useGroups from '@staticcms/core/lib/hooks/useGroups';
|
||||
import { Cursor } from '@staticcms/core/lib/util';
|
||||
@ -13,21 +11,13 @@ import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/
|
||||
import Entries from './Entries';
|
||||
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 { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { t } from 'react-polyglot';
|
||||
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>) {
|
||||
return entries.filter(entry => paths.has(entry.path));
|
||||
}
|
||||
@ -118,8 +108,8 @@ const EntriesCollection = ({
|
||||
{groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<div key={group.id} id={group.id}>
|
||||
<h2>{title}</h2>
|
||||
<Entries
|
||||
collection={collection}
|
||||
entries={getGroupEntries(filteredEntries, group.paths)}
|
||||
@ -130,7 +120,7 @@ const EntriesCollection = ({
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
</GroupContainer>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>;
|
||||
@ -153,7 +143,7 @@ const EntriesCollection = ({
|
||||
|
||||
interface EntriesCollectionOwnProps {
|
||||
collection: Collection;
|
||||
viewStyle: CollectionViewStyle;
|
||||
viewStyle: ViewStyle;
|
||||
readyToLoad: boolean;
|
||||
filterTerm: string;
|
||||
}
|
@ -10,7 +10,7 @@ import { Cursor } from '@staticcms/core/lib/util';
|
||||
import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/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 { RootState } from '@staticcms/core/store';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
@ -25,7 +25,6 @@ const EntriesSearch = ({
|
||||
searchEntries,
|
||||
clearSearch,
|
||||
}: EntriesSearchProps) => {
|
||||
console.log('collections', collections);
|
||||
const collectionNames = useMemo(() => Object.keys(collections), [collections]);
|
||||
|
||||
const getCursor = useCallback(() => {
|
||||
@ -72,7 +71,7 @@ const EntriesSearch = ({
|
||||
interface EntriesSearchOwnProps {
|
||||
searchTerm: string;
|
||||
collections: Collections;
|
||||
viewStyle: CollectionViewStyle;
|
||||
viewStyle: ViewStyle;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
@ -81,7 +80,6 @@ function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
console.log('searched entries', entries);
|
||||
return { isFetching, page, collections, viewStyle, entries, searchTerm };
|
||||
}
|
||||
|
160
packages/core/src/components/collections/entries/EntryCard.tsx
Normal 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;
|
@ -1,45 +1,17 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
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 Table from '../../common/table/Table';
|
||||
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 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 {
|
||||
entries: Entry[];
|
||||
viewStyle: CollectionViewStyle;
|
||||
viewStyle: ViewStyle;
|
||||
cursor?: Cursor;
|
||||
handleCursorActions?: (action: string) => void;
|
||||
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(() => {
|
||||
if ('collection' in otherProps) {
|
||||
const inferredFields = inferFields(otherProps.collection);
|
||||
@ -107,16 +93,17 @@ const EntryListing = ({
|
||||
viewStyle={viewStyle}
|
||||
entry={entry}
|
||||
key={entry.slug}
|
||||
summaryFields={summaryFields}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
|
||||
return entries.map(entry => {
|
||||
const collectionName = entry.collection;
|
||||
const collection = Object.values(otherProps.collections).find(
|
||||
coll => coll.name === collectionName,
|
||||
);
|
||||
|
||||
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
|
||||
const inferredFields = inferFields(collection);
|
||||
return collection ? (
|
||||
@ -124,19 +111,38 @@ const EntryListing = ({
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
imageFieldName={inferredFields.imageField}
|
||||
viewStyle={viewStyle}
|
||||
collectionLabel={collectionLabel}
|
||||
key={entry.slug}
|
||||
summaryFields={summaryFields}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}, [entries, inferFields, otherProps, viewStyle]);
|
||||
}, [entries, inferFields, isSingleCollectionInList, otherProps, summaryFields, viewStyle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid $layout={viewStyle}>
|
||||
if (viewStyle === 'VIEW_STYLE_LIST') {
|
||||
return (
|
||||
<Table columns={!isSingleCollectionInList ? ['Collection', ...summaryFields] : summaryFields}>
|
||||
{renderedCards}
|
||||
{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>
|
||||
);
|
||||
};
|