feat: v2.0.0

This commit is contained in:
Daniel Lautzenheiser
2023-04-20 15:13:42 -04:00
committed by GitHub
572 changed files with 31916 additions and 30091 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
18

View File

@ -126,6 +126,6 @@ Every release is documented on the Github [Releases](https://github.com/StaticJs
Static CMS is released under the [MIT License](LICENSE).
Please make sure you understand its [implications and guarantees](https://writing.kemitchell.com/2016/09/21/MIT-License-Line-by-Line.html).
# Netlify CMS
# Decap
Static CMS is a fork of Netlify CMS focusing on the core product over adding massive, scope expanding, new features.
Static CMS is a fork of [Decap](https://github.com/decaporg/decap-cms) (previously Netlify CMS) focusing on the core product over adding massive, scope expanding, new features.

View File

@ -1,5 +1,5 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useWorkspaces": true,
"version": "1.2.14"
"version": "2.0.0-rc.1"
}

View File

@ -14,6 +14,8 @@
"lint": "lerna run lint",
"prepare": "husky install",
"release": "lerna publish --no-private",
"release-dev": "lerna publish --no-private --no-push --no-git-tag-version --dist-tag dev",
"release-next": "lerna publish --no-private --dist-tag next",
"test:ci": "lerna run test:ci",
"test:integration:ci": "lerna run test:integration:ci",
"test:integration": "lerna run test:integration",
@ -23,7 +25,7 @@
"devDependencies": {
"all-contributors-cli": "6.24.0",
"husky": "8.0.3",
"lerna": "6.5.1",
"lerna": "6.6.1",
"lint-staged": "13.2.0",
"react": "18.2.0",
"react-dom": "18.2.0"

View File

@ -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'],

View File

@ -1,6 +1,6 @@
{
"name": "@staticcms/app",
"version": "1.2.14",
"version": "2.0.0-rc.1",
"license": "MIT",
"description": "Static CMS application.",
"repository": "https://github.com/StaticJsCMS/static-cms",
@ -16,7 +16,12 @@
"clean": "rimraf dist dev-test/dist",
"prepublishOnly": "yarn build ",
"prepack": "cp ../../README.md ./",
"postpack": "rm ./README.md"
"postpack": "rm ./README.md",
"format:prettier": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --write",
"format": "run-s \"lint:js --fix --quiet\" \"format:prettier\"",
"lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different",
"lint:js": "eslint --color \"src/**/*.{ts,tsx}\"",
"lint": "run-p -c --aggregate-output \"lint:*\""
},
"main": "dist/static-cms-app.js",
"files": [
@ -35,7 +40,7 @@
"@babel/eslint-parser": "7.21.3",
"@babel/runtime": "7.21.0",
"@emotion/babel-preset-css-prop": "11.10.0",
"@staticcms/core": "^1.2.14",
"@staticcms/core": "^2.0.0-rc.1",
"buffer": "6.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -44,7 +49,7 @@
},
"devDependencies": {
"@babel/cli": "7.21.0",
"@babel/core": "7.21.3",
"@babel/core": "7.21.4",
"@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-export-default-from": "7.18.10",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
@ -52,16 +57,17 @@
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.20.2",
"@babel/preset-env": "7.21.4",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.21.0",
"@babel/preset-typescript": "7.21.4",
"@emotion/eslint-plugin": "11.10.0",
"@emotion/jest": "11.10.5",
"@types/node": "16.18.16",
"@types/react": "18.0.28",
"@types/node": "18.15.11",
"@types/react": "18.0.33",
"@types/react-dom": "18.0.11",
"@typescript-eslint/eslint-plugin": "5.55.0",
"@typescript-eslint/parser": "5.55.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"autoprefixer": "10.4.14",
"babel-core": "7.0.0-bridge.0",
"babel-loader": "9.1.2",
"babel-plugin-emotion": "11.0.0",
@ -73,25 +79,27 @@
"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",
"eslint-plugin-cypress": "2.12.1",
"eslint": "8.37.0",
"eslint-import-resolver-typescript": "3.5.4",
"eslint-plugin-cypress": "2.13.2",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"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.5",
"npm-run-all": "4.1.5",
"postcss": "8.4.21",
"postcss-scss": "4.0.6",
"prettier": "2.8.4",
"prettier": "2.8.7",
"source-map-loader": "4.0.1",
"style-loader": "3.3.2",
"to-string-loader": "1.2.0",
"tsconfig-paths-webpack-plugin": "4.0.1",
"typescript": "4.9.5",
"webpack": "5.76.2",
"typescript": "5.0.3",
"webpack": "5.77.0",
"webpack-cli": "5.0.1"
},
"publishConfig": {

View File

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

View File

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

View File

@ -49,7 +49,7 @@
"@staticcms/string/*": ["../core/src/widgets/string/*"],
"@staticcms/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"]

View File

@ -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({

View File

@ -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'],

View File

@ -2,4 +2,3 @@ dist/
bin/
public/
.cache/
j-toml.js

View File

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<script src="https://identity.netlify.com/v1/netlify-identity-widget.js" async></script>
<title>Static CMS - Git Gateway Development Test</title>
</head>

View File

@ -1,9 +1,9 @@
{
"front_limit": 5,
"front_limit": 6,
"site_title": "Test",
"posts": {
"front_limit": 4,
"author": "Bob",
"thumb": "/backends/proxy/assets/upload/kanefreeman_2.jpg"
}
}
}

View File

@ -0,0 +1,5 @@
---
title: An Author
---
Author details go here!.

View File

@ -0,0 +1,3 @@
---
title: Authors
---

View File

@ -0,0 +1,3 @@
---
title: Pages
---

View File

@ -0,0 +1,5 @@
---
title: Hello World
---
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.

View File

@ -0,0 +1,3 @@
---
title: Posts
---

View File

@ -1,11 +0,0 @@
---
title: Something something something...
draft: false
date: 2022-11-01 06:30
image: /backends/proxy/assets/posts/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
---
# Welcome
Here is your body!
And some more information!

View File

@ -0,0 +1,11 @@
---
title: Something something something2...
draft: false
date: 2022-11-01 06:30
image: static-cms-icon.svg
---
# Welcome
Here is your body!
And some more information!

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -1,13 +0,0 @@
---
title: Test
draft: false
date: 2022-11-01 14:28
image: /backends/proxy/assets/posts/kittens.jpg
---
Test2
<br>
![moby-dick.jpg](/assets/upload/moby-dick.jpg)
![moby-dick.jpg](/assets/upload/moby-dick.jpg)
![kanefreeman_2.jpg](/assets/upload/kanefreeman_2.jpg)

View File

@ -0,0 +1,11 @@
---
title: Test
draft: false
date: 2022-11-01 14:28
image: kittens.jpg
---
Test234t6
[Test](https://example.com)
![Kittens!](kittens.jpg)

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@ -1,7 +0,0 @@
---
title: Test3
draft: false
date: 2022-11-02 08:43
image: /backends/proxy/assets/upload/kanefreeman_2.jpg
---
test25555

View File

@ -0,0 +1,7 @@
---
title: Test3
draft: false
date: 2022-11-02 08:43
image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
---
test2555556

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -24,14 +24,15 @@ collections:
- name: posts
label: Posts
label_singular: Post
media_folder: /packages/core/dev-test/backends/proxy/assets/posts
public_folder: /backends/proxy/assets/posts
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: packages/core/dev-test/backends/proxy/_posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
media_folder: ""
public_folder: ""
path: "{{year}}-{{month}}-{{day}}-{{slug}}/index"
sortable_fields:
fields:
- title
@ -503,3 +504,22 @@ collections:
label: Date
widget: datetime
i18n: duplicate
- name: pages
label: Nested Pages
label_singular: 'Page'
folder: packages/core/dev-test/backends/proxy/_nested_pages
create: true
# adding a nested object will show the collection folder structure
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
# adding a path object allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
path: { label: 'Path', index_file: 'index' }
fields:
- label: Title
name: title
widget: string
- label: Body
name: body
widget: markdown

View File

@ -2,6 +2,8 @@ backend:
name: test-repo
site_url: 'https://example.com'
media_folder: assets/uploads
media_library:
folder_support: true
locale: en
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file
@ -25,7 +27,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
@ -138,6 +143,7 @@ collections:
label: Pattern Validation
widget: code
pattern: ['.{12,}', 'Must have at least 12 characters']
allow_input: true
required: false
- name: language
label: Language Selection
@ -173,6 +179,10 @@ collections:
- name: required
label: Required Validation
widget: color
- name: allow_input
label: Allow Input
widget: color
allow_input: true
- name: with_default
label: Required With Default
widget: color
@ -180,7 +190,8 @@ collections:
- name: pattern
label: Pattern Validation
widget: color
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code']
pattern:
['^#[a-fA-F0-9]{3}$|^[a-fA-F0-9]{4}$|^[a-fA-F0-9]{6}$', 'Must be a valid hex code']
allow_input: true
required: false
- name: alpha
@ -218,7 +229,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'
@ -232,22 +243,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'
@ -272,12 +286,13 @@ collections:
label: Choose URL
widget: file
required: false
media_library:
choose_url: true
choose_url: true
- name: image
label: Image
file: _widgets/image.json
description: Image widget
media_library:
folder_support: false
fields:
- name: required
label: Required Validation
@ -295,8 +310,12 @@ collections:
label: Choose URL
widget: image
required: false
choose_url: true
- name: folder_support
label: Folder Support
widget: image
media_library:
choose_url: true
folder_support: true
- name: list
label: List
file: _widgets/list.yml
@ -501,6 +520,11 @@ collections:
widget: markdown
pattern: ['# [a-zA-Z0-9]+', 'Must have a header']
required: false
- name: folder_support
label: Folder Support
widget: markdown
media_library:
folder_support: true
- name: number
label: Number
file: _widgets/number.json
@ -640,7 +664,7 @@ collections:
- date
search_fields:
- title
- body
- date
value_field: title
- label: Required With Default
name: with_default
@ -651,7 +675,7 @@ collections:
- date
search_fields:
- title
- body
- date
value_field: title
default: This is a YAML front matter post
- label: Optional Validation
@ -664,7 +688,7 @@ collections:
- date
search_fields:
- title
- body
- date
value_field: title
- label: Multiple
name: multiple
@ -677,7 +701,7 @@ collections:
- date
search_fields:
- title
- body
- date
value_field: title
- label: Multiple With Default
name: multiple_with_default
@ -693,7 +717,7 @@ collections:
- date
search_fields:
- title
- body
- date
value_field: title
- name: select
label: Select
@ -828,6 +852,18 @@ collections:
widget: text
pattern: ['.{12,}', 'Must have at least 12 characters']
required: false
- name: uuid
label: UUID
file: _widgets/uuid.json
description: UUID widget
fields:
- name: uuid
label: UUID
widget: uuid
- name: no_regenerate
label: Does not allow regeneration
widget: uuid
allow_regenerate: false
- name: settings
label: Settings
delete: false
@ -838,8 +874,6 @@ collections:
label: Site Settings
file: _data/settings.json
description: General Site Settings
editor:
preview: true
fields:
- label: Number of posts on frontpage
name: front_limit
@ -1242,3 +1276,22 @@ collections:
label: Date
widget: datetime
i18n: duplicate
- name: pages
label: Nested Pages
label_singular: 'Page'
folder: _nested_pages
create: true
# adding a nested object will show the collection folder structure
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
# adding a path object allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
path: { label: 'Path', index_file: 'index' }
fields:
- label: Title
name: title
widget: string
- label: Body
name: body
widget: markdown

View File

@ -6,6 +6,24 @@
<title>Static CMS Development Test</title>
<script>
window.repoFiles = {
assets: {
uploads: {
'moby-dick.jpg': {
content: '',
},
'lobby.jpg': {
content: '',
},
'Other Pics': {
'moby-dick.jpg': {
content: '',
},
'lobby.jpg': {
content: '',
},
},
},
},
_posts: {
'2015-02-14-this-is-a-post.md': {
content:
@ -15,9 +33,9 @@
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': {
'2015-02-15-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',
'+++\ntitle = "This is a TOML 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',
},
'2015-02-14-this-is-a-post-with-a-different-extension.other': {
content:
@ -104,15 +122,52 @@
_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",
},
},
_nested_pages: {
authors: {
'author-1': {
'index.md': {
content: '---\ntitle: An Author\n---\nAuthor details go here!.\n',
},
},
'index.md': {
content: '---\ntitle: Authors\n---\n',
},
},
posts: {
'hello-world': {
'index.md': {
content:
'---\ntitle: Hello World\n---\nCoffee 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.\n',
},
},
'index.md': {
content: '---\ntitle: Posts\n---\n',
},
news: {
'hello-world-news': {
'index.md': {
content:
'---\ntitle: Hello World News\n---\nCoffee 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.\n',
},
},
'index.md': {
content: '---\ntitle: News Articles\n---\n',
},
},
},
'index.md': {
content: '---\ntitle: Pages\n---\n',
},
},
};
@ -133,6 +188,7 @@
'---\ntitle: "This is post # ' +
i +
`\"\ndraft: ${i % 2 === 0}` +
'\nimage: /assets/uploads/lobby.jpg' +
'\ndate: ' +
dateString +
'T00:00:00.000Z\n---\n# The post is number ' +

View File

@ -11,11 +11,15 @@ const PostPreview = ({ entry, widgetFor }) => {
);
};
const PostPreviewCard = ({ entry, widgetFor, viewStyle }) => {
const PostPreviewCard = ({ entry, theme, hasLocalBackup }) => {
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,36 +40,123 @@ 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',
{
style: {
backgroundColor: entry.data.draft === true ? 'blue' : 'green',
color: 'white',
border: 'none',
padding: '4px 8px',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
cursor: 'pointer',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
whiteSpace: 'no-wrap',
gap: '8px',
},
},
entry.data.draft === true ? 'Draft' : 'Published',
hasLocalBackup
? h(
'div',
{
style: {
border: '2px solid rgb(147, 197, 253)',
borderRadius: '50%',
color: 'rgb(147, 197, 253)',
height: '18px',
width: '18px',
fontWeight: 'bold',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
},
title: 'Has local backup'
},
'i',
)
: null,
h(
'div',
{
style: {
backgroundColor:
entry.data.draft === 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',
},
},
entry.data.draft === true ? 'Draft' : 'Published',
),
),
),
),
);
};
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 +227,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 +265,7 @@ CMS.registerShortcode('youtube', {
toArgs: ({ src }) => {
return [src];
},
control: ({ src, onChange }) => {
control: ({ src, onChange, theme }) => {
return h('span', {}, [
h('input', {
key: 'control-input',
@ -178,12 +273,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}`,
},

View File

@ -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'],
};

View File

@ -1,6 +1,6 @@
{
"name": "@staticcms/core",
"version": "1.2.14",
"version": "2.0.0-rc.1",
"license": "MIT",
"description": "Static CMS core application.",
"repository": "https://github.com/StaticJsCMS/static-cms",
@ -33,7 +33,7 @@
"type-check": "tsc --watch"
},
"main": "dist/static-cms-core.js",
"types": "dist/src/index.d.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
@ -50,15 +50,15 @@
"@babel/eslint-parser": "7.21.3",
"@babel/runtime": "7.21.0",
"@codemirror/autocomplete": "6.4.2",
"@codemirror/commands": "6.2.1",
"@codemirror/commands": "6.2.2",
"@codemirror/language": "6.6.0",
"@codemirror/language-data": "6.1.0",
"@codemirror/legacy-modes": "6.3.1",
"@codemirror/language-data": "6.2.0",
"@codemirror/legacy-modes": "6.3.2",
"@codemirror/lint": "6.2.0",
"@codemirror/search": "6.2.3",
"@codemirror/search": "6.3.0",
"@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,36 +66,43 @@
"@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/material": "5.11.13",
"@mui/system": "5.11.13",
"@mui/base": "5.0.0-alpha.124",
"@mui/material": "5.11.16",
"@mui/system": "5.11.16",
"@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-outlined": "10.47.0",
"@styled-icons/material-rounded": "10.47.0",
"@styled-icons/remix-editor": "10.46.0",
"@udecode/plate": "19.7.0",
"@udecode/plate-juice": "20.0.0",
"@udecode/plate-serializer-md": "20.0.0",
"@uiw/codemirror-extensions-langs": "4.19.9",
"@uiw/react-codemirror": "4.19.9",
"@styled-icons/simple-icons": "10.46.0",
"@udecode/plate": "20.6.0",
"@udecode/plate-juice": "20.4.0",
"@udecode/plate-serializer-md": "20.4.1",
"@uiw/codemirror-extensions-langs": "4.19.11",
"@uiw/react-codemirror": "4.19.11",
"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",
"clean-stack": "5.2.0",
"codemirror": "6.0.1",
"common-tags": "1.8.2",
"copy-text-to-clipboard": "3.1.0",
"create-react-class": "15.7.0",
"date-fns": "2.29.3",
"deepmerge": "4.3.0",
"deepmerge": "4.3.1",
"diacritics": "1.3.0",
"escape-html": "1.0.3",
"eslint-config-prettier": "8.7.0",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-babel": "5.3.1",
"fuzzy": "0.1.3",
"globby": "13.1.3",
@ -104,7 +111,7 @@
"graphql-tag": "2.12.6",
"gray-matter": "4.0.3",
"history": "5.3.0",
"immer": "9.0.19",
"immer": "9.0.21",
"ini": "3.0.1",
"is-hotkey": "0.2.0",
"js-base64": "3.7.5",
@ -112,7 +119,16 @@
"jwt-decode": "3.1.2",
"localforage": "1.10.0",
"lodash": "4.17.21",
"minimatch": "7.4.2",
"mdast-util-gfm-footnote": "1.0.2",
"mdast-util-gfm-strikethrough": "1.0.3",
"mdast-util-gfm-table": "1.0.7",
"mdast-util-gfm-task-list-item": "1.0.2",
"micromark-extension-gfm-footnote": "1.1.0",
"micromark-extension-gfm-strikethrough": "1.0.5",
"micromark-extension-gfm-table": "1.0.5",
"micromark-extension-gfm-task-list-item": "1.0.4",
"micromark-util-combine-extensions": "1.0.0",
"minimatch": "8.0.3",
"moment": "2.29.4",
"node-polyglot": "2.5.0",
"ol": "7.3.0",
@ -127,31 +143,27 @@
"react-is": "18.2.0",
"react-polyglot": "0.7.2",
"react-redux": "8.0.5",
"react-router-dom": "6.9.0",
"react-router-dom": "6.10.0",
"react-scroll-sync": "0.11.0",
"react-textarea-autosize": "8.4.0",
"react-topbar-progress-indicator": "4.1.1",
"react-virtualized-auto-sizer": "1.0.7",
"react-virtualized-auto-sizer": "1.0.11",
"react-waypoint": "10.3.0",
"react-window": "1.8.8",
"remark-gfm": "3.0.1",
"remark-html": "15.0.2",
"remark-mdx": "2.3.0",
"remark-parse": "10.0.1",
"sanitize-filename": "1.6.3",
"scheduler": "0.23.0",
"semaphore": "1.1.0",
"slate": "0.91.4",
"slate-history": "0.86.0",
"slate": "0.93.0",
"slate-history": "0.93.0",
"slate-hyperscript": "0.77.0",
"slate-react": "0.91.10",
"slate-react": "0.93.0",
"stream-browserify": "3.0.0",
"styled-components": "5.3.9",
"symbol-observable": "4.0.0",
"unified": "10.1.2",
"unist-util-visit": "4.1.2",
"uploadcare-widget": "3.21.0",
"uploadcare-widget-tab-effects": "1.6.0",
"url": "0.11.0",
"url-join": "5.0.0",
"uuid": "9.0.0",
@ -165,7 +177,7 @@
},
"devDependencies": {
"@babel/cli": "7.21.0",
"@babel/core": "7.21.3",
"@babel/core": "7.21.4",
"@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-export-default-from": "7.18.10",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
@ -173,14 +185,15 @@
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.20.2",
"@babel/preset-env": "7.21.4",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.21.0",
"@babel/preset-typescript": "7.21.4",
"@emotion/eslint-plugin": "11.10.0",
"@emotion/jest": "11.10.5",
"@iarna/toml": "2.2.5",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.10",
"@simbathesailor/use-what-changed": "2.0.0",
"@testing-library/dom": "9.0.1",
"@testing-library/dom": "9.2.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
@ -188,14 +201,14 @@
"@types/create-react-class": "15.6.3",
"@types/fs-extra": "11.0.1",
"@types/is-hotkey": "0.1.7",
"@types/jest": "29.4.2",
"@types/jest": "29.5.0",
"@types/js-yaml": "4.0.5",
"@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.191",
"@types/minimatch": "5.1.2",
"@types/node": "16.18.16",
"@types/node-fetch": "2.6.2",
"@types/react": "18.0.28",
"@types/node": "18.15.11",
"@types/node-fetch": "2.6.3",
"@types/react": "18.0.33",
"@types/react-color": "3.0.6",
"@types/react-dom": "18.0.11",
"@types/react-virtualized-auto-sizer": "1.0.1",
@ -203,8 +216,9 @@
"@types/styled-components": "5.1.26",
"@types/url-join": "4.0.1",
"@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.55.0",
"@typescript-eslint/parser": "5.55.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"autoprefixer": "10.4.14",
"axios": "1.3.4",
"babel-core": "7.0.0-bridge.0",
"babel-loader": "9.1.2",
@ -222,43 +236,45 @@
"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",
"eslint-plugin-cypress": "2.12.1",
"eslint": "8.37.0",
"eslint-import-resolver-typescript": "3.5.4",
"eslint-plugin-cypress": "2.13.2",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-unicorn": "46.0.0",
"execa": "7.1.1",
"fs-extra": "11.1.0",
"fs-extra": "11.1.1",
"gitlab": "14.2.2",
"http-server": "14.1.1",
"jest": "29.5.0",
"jest-environment-jsdom": "29.5.0",
"js-yaml": "4.1.0",
"mini-css-extract-plugin": "2.7.5",
"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",
"prettier": "2.8.4",
"postcss-loader": "7.2.4",
"prettier": "2.8.7",
"process": "0.11.10",
"react-refresh": "0.14.0",
"react-svg-loader": "3.0.3",
"rimraf": "4.4.0",
"rimraf": "4.4.1",
"simple-git": "3.17.0",
"source-map-loader": "4.0.1",
"style-loader": "3.3.2",
"tailwindcss": "3.3.1",
"to-string-loader": "1.2.0",
"ts-jest": "29.0.5",
"ts-jest": "29.1.0",
"tsconfig-paths-webpack-plugin": "4.0.1",
"typescript": "4.9.5",
"webpack": "5.76.2",
"typescript": "5.0.3",
"webpack": "5.77.0",
"webpack-cli": "5.0.1",
"webpack-dev-server": "4.12.0"
"webpack-dev-server": "4.13.2"
},
"peerDependencies": {
"react": "^18.2.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export const v4 = jest.fn().mockReturnValue('I_AM_A_UUID');
export const validate = jest.fn();

File diff suppressed because it is too large Load Diff

View File

@ -19,39 +19,26 @@ import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
Collection,
CollectionFile,
Config,
Field,
I18nInfo,
ListField,
LocalBackend,
ObjectField,
UnknownField,
} from '../interface';
import type { RootState } from '../store';
function isObjectField<F extends BaseField = UnknownField>(
field: Field<F>,
): field is ObjectField<F> {
return 'fields' in (field as ObjectField);
}
function isFieldList<F extends BaseField = UnknownField>(field: Field<F>): field is ListField<F> {
return 'types' in (field as ListField) || 'field' in (field as ListField);
}
function traverseFieldsJS<F extends Field>(
fields: F[],
updater: <T extends Field>(field: T) => T,
): F[] {
function traverseFields(fields: Field[], updater: (field: Field) => Field): Field[] {
return fields.map(field => {
const newField = updater(field);
if (isObjectField(newField)) {
return { ...newField, fields: traverseFieldsJS(newField.fields, updater) };
} else if (isFieldList(newField) && newField.types) {
return { ...newField, types: traverseFieldsJS(newField.types, updater) };
if ('fields' in newField && newField.fields) {
return { ...newField, fields: traverseFields(newField.fields, updater) } as Field;
} else if (newField.widget === 'list' && newField.types) {
return { ...newField, types: traverseFields(newField.types, updater) } as Field;
}
return newField;
return newField as Field;
});
}
@ -62,20 +49,29 @@ function getConfigUrl() {
};
const configLinkEl = document.querySelector<HTMLLinkElement>('link[rel="cms-config-url"]');
if (configLinkEl && validTypes[configLinkEl.type] && configLinkEl.href) {
console.info(`Using config file path: "${configLinkEl.href}"`);
console.info(`[StaticCMS] Using config file path: "${configLinkEl.href}"`);
return configLinkEl.href;
}
return 'config.yml';
}
function setDefaultPublicFolderForField<T extends Field>(field: T) {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
return field;
}
const setFieldDefaults =
(collection: Collection, collectionFile?: CollectionFile) => (field: Field) => {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
function setI18nField<T extends Field>(field: T) {
if (field.widget === 'image' || field.widget === 'file' || field.widget === 'markdown') {
field.media_library = {
...((collectionFile ?? collection).media_library ?? {}),
...(field.media_library ?? {}),
};
}
return field;
};
function setI18nField<T extends BaseField = UnknownField>(field: T) {
if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD_TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) {
@ -100,9 +96,9 @@ function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n:
function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: boolean) {
if (hasI18n) {
return traverseFieldsJS(collectionOrFileFields, setI18nField);
return traverseFields(collectionOrFileFields, setI18nField);
} else {
return traverseFieldsJS(collectionOrFileFields, field => {
return traverseFields(collectionOrFileFields, field => {
const newField = { ...field };
delete newField[I18N];
return newField;
@ -174,6 +170,11 @@ export function applyDefaults(originalConfig: Config) {
collection.editor = { preview: config.editor.preview, frame: config.editor.frame };
}
collection.media_library = {
...(config.media_library ?? {}),
...(collection.media_library ?? {}),
};
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
@ -199,7 +200,7 @@ export function applyDefaults(originalConfig: Config) {
}
if ('fields' in collection && collection.fields) {
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
collection.fields = traverseFields(collection.fields, setFieldDefaults(collection));
}
collection.folder = trim(collection.folder, '/');
@ -215,8 +216,13 @@ export function applyDefaults(originalConfig: Config) {
file.public_folder = file.media_folder;
}
file.media_library = {
...(collection.media_library ?? {}),
...(file.media_library ?? {}),
};
if (file.fields) {
file.fields = traverseFieldsJS(file.fields, setDefaultPublicFolderForField);
file.fields = traverseFields(file.fields, setFieldDefaults(collection, file));
}
let fileI18n = file[I18N];
@ -288,7 +294,7 @@ async function getConfigYaml(file: string): Promise<Config> {
const contentType = response.headers.get('Content-Type') ?? 'Not-Found';
const isYaml = contentType.indexOf('yaml') !== -1;
if (!isYaml) {
console.info(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
console.info(`[StaticCMS] Response for ${file} was not yaml. (Content-Type: ${contentType})`);
}
return parseConfig(await response.text());
}
@ -332,7 +338,7 @@ export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
: localBackend.url || defaultUrl.replace('localhost', location.hostname);
try {
console.info(`Looking for Static CMS Proxy Server at '${proxyUrl}'`);
console.info(`[StaticCMS] Looking for Static CMS Proxy Server at '${proxyUrl}'`);
const res = await fetch(`${proxyUrl}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -343,14 +349,16 @@ export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
type?: string;
};
if (typeof repo === 'string' && typeof type === 'string') {
console.info(`Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
console.info(
`[StaticCMS] Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`,
);
return { proxyUrl, type };
} else {
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
console.info(`[StaticCMS] Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
} catch {
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
console.info(`[StaticCMS] Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
}

View File

@ -39,10 +39,10 @@ import {
} from '../constants';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import {
duplicateDefaultI18nFields,
hasI18n,
I18N_FIELD_DUPLICATE,
I18N_FIELD_TRANSLATE,
duplicateDefaultI18nFields,
hasI18n,
serializeI18n,
} from '../lib/i18n';
import { serializeValues } from '../lib/serializeEntryValues';
@ -50,7 +50,7 @@ import { Cursor } from '../lib/util';
import { selectFields, updateFieldByKey } from '../lib/util/collection.util';
import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors';
import {
selectEntriesSortFields,
selectEntriesSortField,
selectIsFetching,
selectPublishedSlugs,
} from '../reducers/selectors/entries';
@ -58,14 +58,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,
@ -351,7 +351,7 @@ export function groupByField(collection: Collection, group: ViewGroup) {
};
}
export function changeViewStyle(viewStyle: CollectionViewStyle) {
export function changeViewStyle(viewStyle: ViewStyle) {
return {
type: CHANGE_VIEW_STYLE,
payload: {
@ -463,15 +463,17 @@ export function changeDraftField({
field,
value,
i18n,
isMeta,
}: {
path: string;
field: Field;
value: ValueOrNestedValue;
i18n?: I18nSettings;
isMeta: boolean;
}) {
return {
type: DRAFT_CHANGE_FIELD,
payload: { path, field, value, i18n },
payload: { path, field, value, i18n, isMeta },
} as const;
}
@ -479,10 +481,11 @@ export function changeDraftFieldValidation(
path: string,
errors: FieldError[],
i18n?: I18nSettings,
isMeta?: boolean,
) {
return {
type: DRAFT_VALIDATION_ERRORS,
payload: { path, errors, i18n },
payload: { path, errors, i18n, isMeta },
} as const;
}
@ -592,12 +595,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));
@ -659,10 +662,9 @@ export function loadEntries(collection: Collection, page = 0) {
return;
}
const state = getState();
const sortFields = selectEntriesSortFields(state, collection.name);
if (sortFields && sortFields.length > 0) {
const field = sortFields[0];
return dispatch(sortByField(collection, field.key, field.direction));
const sortField = selectEntriesSortField(collection.name)(state);
if (sortField) {
return dispatch(sortByField(collection, sortField.key, sortField.direction));
}
const configState = state.config;
@ -843,10 +845,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, '', '', {
@ -957,12 +955,16 @@ export function getSerializedEntry(collection: Collection, entry: Entry): Entry
return serializedEntry;
}
export function persistEntry(collection: Collection, navigate: NavigateFunction) {
export function persistEntry(
collection: Collection,
rootSlug: string | undefined,
navigate: NavigateFunction,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.fieldsErrors;
const usedSlugs = selectPublishedSlugs(state, collection.name);
const usedSlugs = selectPublishedSlugs(collection.name)(state);
// Early return if draft contains validation errors
if (Object.keys(fieldsErrors).length > 0) {
@ -1021,6 +1023,7 @@ export function persistEntry(collection: Collection, navigate: NavigateFunction)
return backend
.persistEntry({
config: configState.config,
rootSlug,
collection,
entryDraft: newEntryDraft,
assetProxies,

View File

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

View File

@ -6,15 +6,13 @@ import {
LOAD_ASSET_SUCCESS,
REMOVE_ASSET,
} from '../constants';
import { isAbsolutePath } from '../lib/util';
import { selectMediaFilePath } from '../lib/util/media.util';
import { selectMediaFileByPath } from '../reducers/selectors/mediaLibrary';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
import { getMediaFile } from './mediaLibrary';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { BaseField, Collection, Entry, Field, UnknownField } from '../interface';
import type { BaseField, Collection, Entry, Field, MediaField, UnknownField } from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
@ -56,20 +54,9 @@ async function loadAsset(
): Promise<AssetProxy> {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
let asset: AssetProxy;
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
dispatch(loadAssetSuccess(resolvedPath));
return asset;
} catch (error: unknown) {
@ -83,11 +70,12 @@ async function loadAsset(
const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset<F extends BaseField = UnknownField>(
collection: Collection<F> | null | undefined,
export function getAsset<T extends MediaField, EF extends BaseField = UnknownField>(
collection: Collection<EF> | null | undefined,
entry: Entry | null | undefined,
path: string,
field?: F,
field?: T,
currentFolder?: string,
) {
return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
@ -104,8 +92,10 @@ export function getAsset<F extends BaseField = UnknownField>(
entry,
path,
field as Field,
currentFolder,
);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
const { asset, isLoading } = state.medias[resolvedPath] || {};
if (isLoading) {
return promiseCache[resolvedPath];
}
@ -116,23 +106,9 @@ export function getAsset<F extends BaseField = UnknownField>(
}
const p = new Promise<AssetProxy>(resolve => {
if (isAbsolutePath(resolvedPath)) {
// asset path is a public url so we can just use it as is
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
loadAsset(resolvedPath, dispatch, getState).then(asset => {
resolve(asset);
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
resolve(asset);
} else {
loadAsset(resolvedPath, dispatch, getState).then(asset => {
resolve(asset);
});
}
}
});
});
promiseCache[resolvedPath] = p;

View File

@ -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,
@ -9,7 +9,6 @@ import {
MEDIA_DISPLAY_URL_SUCCESS,
MEDIA_INSERT,
MEDIA_LIBRARY_CLOSE,
MEDIA_LIBRARY_CREATE,
MEDIA_LIBRARY_OPEN,
MEDIA_LOAD_FAILURE,
MEDIA_LOAD_REQUEST,
@ -34,91 +33,80 @@ import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
Collection,
CollectionFile,
DisplayURLState,
Field,
ImplementationMediaFile,
MediaField,
MediaFile,
MediaLibraryInstance,
MediaLibrarInsertOptions,
MediaLibraryConfig,
UnknownField,
} from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
export function createMediaLibrary(instance: MediaLibraryInstance) {
const api = {
show: instance.show || (() => undefined),
hide: instance.hide || (() => undefined),
onClearControl: instance.onClearControl || (() => undefined),
onRemoveControl: instance.onRemoveControl || (() => undefined),
enableStandalone: instance.enableStandalone || (() => undefined),
};
return { type: MEDIA_LIBRARY_CREATE, payload: api } as const;
}
export function clearMediaControl(id: string) {
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.onClearControl?.({ id });
}
};
}
export function removeMediaControl(id: string) {
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.onRemoveControl?.({ id });
}
};
}
export function openMediaLibrary<F extends BaseField = UnknownField>(
export function openMediaLibrary<EF extends BaseField = UnknownField>(
payload: {
controlID?: string;
forImage?: boolean;
forFolder?: boolean;
value?: string | string[];
alt?: string;
allowMultiple?: boolean;
replaceIndex?: number;
config?: Record<string, unknown>;
field?: F;
config?: MediaLibraryConfig;
collection?: Collection<EF>;
collectionFile?: CollectionFile<EF>;
field?: EF;
insertOptions?: MediaLibrarInsertOptions;
} = {},
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
const { controlID, value, config = {}, allowMultiple, forImage, replaceIndex, field } = payload;
if (mediaLibrary) {
mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage });
}
dispatch(
mediaLibraryOpened({
controlID,
forImage,
value,
allowMultiple,
replaceIndex,
config,
field: field as Field,
}),
);
};
const {
controlID,
value,
alt,
config = {},
allowMultiple,
forImage,
forFolder,
replaceIndex,
collection,
collectionFile,
field,
insertOptions,
} = payload;
return {
type: MEDIA_LIBRARY_OPEN,
payload: {
controlID,
forImage,
forFolder,
value,
alt,
allowMultiple,
replaceIndex,
config,
collection: collection as Collection,
collectionFile: collectionFile as CollectionFile,
field: field as Field,
insertOptions,
},
} as const;
}
export function closeMediaLibrary() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.hide?.();
}
dispatch(mediaLibraryClosed());
};
return { type: MEDIA_LIBRARY_CLOSE } as const;
}
export function insertMedia(mediaPath: string | string[], field: Field | undefined) {
export function insertMedia(
mediaPath: string | string[],
field: MediaField | undefined,
alt?: string,
currentFolder?: string,
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
@ -131,12 +119,19 @@ export function insertMedia(mediaPath: string | string[], field: Field | undefin
const collection = state.collections[collectionName];
if (Array.isArray(mediaPath)) {
mediaPath = mediaPath.map(path =>
selectMediaFilePublicPath(config, collection, path, entry, field),
selectMediaFilePublicPath(config, collection, path, entry, field, currentFolder),
);
} else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
mediaPath = selectMediaFilePublicPath(
config,
collection,
mediaPath as string,
entry,
field,
currentFolder,
);
}
dispatch(mediaInserted(mediaPath));
dispatch(mediaInserted(mediaPath, alt));
};
}
@ -144,8 +139,15 @@ export function removeInsertedMedia(controlID: string) {
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
}
export function loadMedia(opts: { delay?: number; query?: string; page?: number } = {}) {
const { delay = 0, page = 1 } = opts;
export function loadMedia(
opts: {
delay?: number;
query?: string;
page?: number;
currentFolder?: string;
} = {},
) {
const { delay = 0, page = 1, currentFolder } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
@ -158,12 +160,12 @@ export function loadMedia(opts: { delay?: number; query?: string; page?: number
function loadFunction() {
return backend
.getMedia()
.getMedia(currentFolder, config?.media_library?.folder_support ?? false)
.then(files => dispatch(mediaLoaded(files)))
.catch((error: { status?: number }) => {
console.error(error);
if (error.status === 404) {
console.info('This 404 was expected and handled appropriately.');
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
dispatch(mediaLoaded([]));
} else {
dispatch(mediaLoadFailed());
@ -206,7 +208,12 @@ function createMediaFileFromAsset({
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}) {
export function persistMedia(
file: File,
opts: MediaOptions = {},
targetFolder?: string,
currentFolder?: string,
) {
const { field } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
@ -216,7 +223,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);
@ -252,7 +259,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
try {
const entry = state.entryDraft.entry;
const collection = entry?.collection ? state.collections[entry.collection] : null;
const path = selectMediaFilePath(config, collection, entry, fileName, field);
const path = selectMediaFilePath(config, collection, entry, fileName, field, targetFolder);
const assetProxy = createAssetProxy({
file,
path,
@ -275,7 +282,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
mediaFile = await backend.persistMedia(config, assetProxy);
}
return dispatch(mediaPersisted(mediaFile));
return dispatch(mediaPersisted(mediaFile, currentFolder));
} catch (error) {
console.error(error);
dispatch(
@ -399,24 +406,8 @@ export function loadMediaDisplayURL(file: MediaFile) {
};
}
function mediaLibraryOpened(payload: {
controlID?: string;
forImage?: boolean;
value?: string | string[];
replaceIndex?: number;
allowMultiple?: boolean;
config?: Record<string, unknown>;
field?: Field;
}) {
return { type: MEDIA_LIBRARY_OPEN, payload } as const;
}
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) {
@ -427,7 +418,7 @@ export function mediaLoading(page: number) {
}
export interface MediaOptions {
field?: Field;
field?: MediaField;
page?: number;
canPaginate?: boolean;
dynamicSearch?: boolean;
@ -449,10 +440,10 @@ export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST } as const;
}
export function mediaPersisted(file: ImplementationMediaFile) {
export function mediaPersisted(file: ImplementationMediaFile, currentFolder: string | undefined) {
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file },
payload: { file, currentFolder },
} as const;
}
@ -497,7 +488,7 @@ export async function waitForMediaLibraryToLoad(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
state: RootState,
) {
if (state.mediaLibrary.isLoading !== false && !state.mediaLibrary.externalLibrary) {
if (state.mediaLibrary.isLoading !== false) {
await waitUntilWithTimeout(dispatch, resolve => ({
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
run: () => resolve(),
@ -540,9 +531,8 @@ export async function getMediaDisplayURL(
}
export type MediaLibraryAction = ReturnType<
| typeof createMediaLibrary
| typeof mediaLibraryOpened
| typeof mediaLibraryClosed
| typeof openMediaLibrary
| typeof closeMediaLibrary
| typeof mediaInserted
| typeof removeInsertedMedia
| typeof mediaLoading

View File

@ -29,7 +29,7 @@ export async function waitUntilWithTimeout<T>(
if (waitDone) {
resolve(null);
} else {
console.warn('Wait Action timed out');
console.warn('[StaticCMS] Wait Action timed out');
resolve(null);
}
}, timeout);

View File

@ -20,13 +20,14 @@ import {
import { getBackend, invokeEvent } from './lib/registry';
import { sanitizeChar } from './lib/urlHelper';
import {
CURSOR_COMPATIBILITY_SYMBOL,
Cursor,
asyncLock,
blobToFileObj,
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
getPathDepth,
localForage,
} from './lib/util';
import { getEntryBackupKey } from './lib/util/backup.util';
import {
selectAllowDeletion,
selectAllowNewEntries,
@ -39,6 +40,7 @@ import {
selectMediaFolders,
} from './lib/util/collection.util';
import { selectMediaFilePath, selectMediaFilePublicPath } from './lib/util/media.util';
import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util';
import { set } from './lib/util/object.util';
import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
import createEntry from './valueObjects/createEntry';
@ -46,6 +48,7 @@ import createEntry from './valueObjects/createEntry';
import type {
BackendClass,
BackendInitializer,
BackupEntry,
BaseField,
Collection,
CollectionFile,
@ -56,9 +59,10 @@ import type {
Entry,
EntryData,
EntryDraft,
Field,
FilterRule,
ImplementationEntry,
MediaField,
PersistArgs,
SearchQueryResponse,
SearchResponse,
UnknownField,
@ -102,15 +106,6 @@ export class LocalStorageAuthStore {
}
}
function getEntryBackupKey(collectionName?: string, slug?: string) {
const baseKey = 'backup';
if (!collectionName) {
return baseKey;
}
const suffix = slug ? `.${slug}` : '';
return `${baseKey}.${collectionName}${suffix}`;
}
export function getEntryField(field: string, entry: Entry): string {
const value = get(entry.data, field);
if (value) {
@ -230,9 +225,9 @@ interface AuthStore {
logout: () => void;
}
interface BackendOptions {
interface BackendOptions<EF extends BaseField> {
backendName: string;
config: Config;
config: Config<EF>;
authStore?: AuthStore;
}
@ -245,29 +240,14 @@ export interface MediaFile {
draft?: boolean;
url?: string;
file?: File;
field?: Field;
field?: MediaField;
queryOrder?: unknown;
isViewableImage?: boolean;
type?: string;
isDirectory?: boolean;
}
interface BackupEntry {
raw: string;
path: string;
mediaFiles: MediaFile[];
i18n?: Record<string, { raw: string }>;
}
interface PersistArgs {
config: Config;
collection: Collection;
entryDraft: EntryDraft;
assetProxies: AssetProxy[];
usedSlugs: string[];
status?: string;
}
function collectionDepth(collection: Collection) {
function collectionDepth<EF extends BaseField>(collection: Collection<EF>) {
let depth;
depth =
('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? '');
@ -279,17 +259,17 @@ function collectionDepth(collection: Collection) {
return depth;
}
export class Backend<BC extends BackendClass = BackendClass> {
export class Backend<EF extends BaseField = UnknownField, BC extends BackendClass = BackendClass> {
implementation: BC;
backendName: string;
config: Config;
config: Config<EF>;
authStore?: AuthStore;
user?: User | null;
backupSync: AsyncLock;
constructor(
implementation: BackendInitializer,
{ backendName, authStore, config }: BackendOptions,
implementation: BackendInitializer<EF>,
{ backendName, authStore, config }: BackendOptions<EF>,
) {
// We can't reliably run this on exit, so we do cleanup on load.
this.deleteAnonymousBackup();
@ -401,9 +381,15 @@ export class Backend<BC extends BackendClass = BackendClass> {
entryData: EntryData,
config: Config,
usedSlugs: string[],
customPath: string | undefined,
) {
const slugConfig = config.slug;
const slug = slugFormatter(collection, entryData, slugConfig);
let slug: string;
if (customPath) {
slug = slugFromCustomPath(collection, customPath);
} else {
slug = slugFormatter(collection, entryData, slugConfig);
}
let i = 1;
let uniqueSlug = slug;
@ -417,7 +403,10 @@ export class Backend<BC extends BackendClass = BackendClass> {
return uniqueSlug;
}
processEntries(loadedEntries: ImplementationEntry[], collection: Collection): Entry[] {
processEntries<EF extends BaseField>(
loadedEntries: ImplementationEntry[],
collection: Collection<EF>,
): Entry[] {
const entries = loadedEntries.map(loadedEntry =>
createEntry(
collection.name,
@ -486,13 +475,13 @@ export class Backend<BC extends BackendClass = BackendClass> {
// repeats the process. Once there is no available "next" action, it
// returns all the collected entries. Used to retrieve all entries
// for local searches and queries.
async listAllEntries<T extends BaseField = UnknownField>(collection: Collection<T>) {
async listAllEntries<EF extends BaseField>(collection: Collection<EF>) {
if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection as Collection);
const extension = selectFolderEntryExtension(collection as Collection);
const depth = collectionDepth(collection);
const extension = selectFolderEntryExtension(collection);
return this.implementation
.allEntriesByFolder(collection.folder as string, extension, depth)
.then(entries => this.processEntries(entries, collection as Collection));
.then(entries => this.processEntries(entries, collection));
}
const response = await this.listEntries(collection as Collection);
@ -565,8 +554,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
return { entries: hits, pagination: 1 };
}
async query<T extends BaseField = UnknownField>(
collection: Collection<T>,
async query<EF extends BaseField>(
collection: Collection<EF>,
searchFields: string[],
searchTerm: string,
file?: string,
@ -687,7 +676,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
const result = await localForage.setItem(getEntryBackupKey(), raw);
return result;
} catch (e) {
console.warn('persistLocalDraftBackup', e);
console.warn('[StaticCMS] persistLocalDraftBackup', e);
} finally {
this.backupSync.release();
}
@ -702,7 +691,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
const result = await this.deleteAnonymousBackup();
return result;
} catch (e) {
console.warn('deleteLocalDraftBackup', e);
console.warn('[StaticCMS] deleteLocalDraftBackup', e);
} finally {
this.backupSync.release();
}
@ -714,7 +703,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
return localForage.removeItem(getEntryBackupKey());
}
async getEntry(state: RootState, collection: Collection, slug: string) {
async getEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
slug: string,
) {
const path = selectEntryPath(collection, slug) as string;
const label = selectFileEntryLabel(collection, slug);
const extension = selectFolderEntryExtension(collection);
@ -743,8 +736,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
return entryValue;
}
getMedia() {
return this.implementation.getMedia();
getMedia(folder?: string | undefined, folderSupport?: boolean, mediaPath?: string | undefined) {
return this.implementation.getMedia(folder, folderSupport, mediaPath);
}
getMediaFile(path: string) {
@ -762,7 +755,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
return Promise.reject(err);
}
entryWithFormat(collection: Collection) {
entryWithFormat<EF extends BaseField>(collection: Collection<EF>) {
return (entry: Entry): Entry => {
const format = resolveFormat(collection, entry);
if (entry && entry.raw !== undefined) {
@ -777,7 +770,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
};
}
async processEntry(state: RootState, collection: Collection, entry: Entry) {
async processEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
entry: Entry,
) {
const configState = state.config;
if (!configState.config) {
throw new Error('Config not loaded');
@ -794,7 +791,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
entry,
undefined,
);
return this.implementation.getMedia(folder, mediaPath);
return this.implementation.getMedia(
folder,
collection.media_library?.folder_support ?? false,
mediaPath,
);
}),
);
entry.mediaFiles = entry.mediaFiles.concat(...files);
@ -807,6 +808,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
async persistEntry({
config,
rootSlug,
collection,
entryDraft: draft,
assetProxies,
@ -826,6 +828,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
const newEntry = entryDraft.entry.newRecord ?? false;
const customPath = selectCustomPath(draft.entry, collection, rootSlug, config);
let dataFile: DataFile;
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
@ -836,8 +840,9 @@ export class Backend<BC extends BackendClass = BackendClass> {
entryDraft.entry.data,
config,
usedSlugs,
customPath,
);
const path = selectEntryPath(collection, slug) ?? '';
const path = customPath || (selectEntryPath(collection, slug) ?? '');
dataFile = {
path,
slug,
@ -849,8 +854,9 @@ export class Backend<BC extends BackendClass = BackendClass> {
const slug = entryDraft.entry.slug;
dataFile = {
path: entryDraft.entry.path,
slug,
slug: customPath ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.entry),
newPath: customPath,
};
}
@ -889,8 +895,6 @@ export class Backend<BC extends BackendClass = BackendClass> {
...updatedOptions,
};
await this.invokePrePublishEvent(entryDraft.entry);
await this.implementation.persistEntry(
{
dataFiles,
@ -900,7 +904,6 @@ export class Backend<BC extends BackendClass = BackendClass> {
);
await this.invokePostSaveEvent(entryDraft.entry);
await this.invokePostPublishEvent(entryDraft.entry);
return slug;
}
@ -910,14 +913,6 @@ export class Backend<BC extends BackendClass = BackendClass> {
return await invokeEvent({ name: event, data: { entry, author: { login, name } } });
}
async invokePrePublishEvent(entry: Entry) {
await this.invokeEventWithEntry('prePublish', entry);
}
async invokePostPublishEvent(entry: Entry) {
await this.invokeEventWithEntry('postPublish', entry);
}
async invokePreSaveEvent(entry: Entry) {
return await this.invokeEventWithEntry('preSave', entry);
}
@ -938,7 +933,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
return this.implementation.persistMedia(file, options);
}
async deleteEntry(state: RootState, collection: Collection, slug: string) {
async deleteEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
slug: string,
) {
const configState = state.config;
if (!configState.config) {
throw new Error('Config not loaded');
@ -987,17 +986,15 @@ export class Backend<BC extends BackendClass = BackendClass> {
fieldsOrder(collection: Collection, entry: Entry) {
if ('fields' in collection) {
return collection.fields?.map(f => f!.name) ?? [];
} else {
const files = collection.files ?? [];
const file: CollectionFile | null = files.filter(f => f!.name === entry.slug)?.[0] ?? null;
if (file == null) {
throw new Error(`No file found for ${entry.slug} in ${collection.name}`);
}
return file.fields.map(f => f.name);
}
return [];
const files = collection.files ?? [];
const file: CollectionFile | null = files.filter(f => f!.name === entry.slug)?.[0] ?? null;
if (file == null) {
throw new Error(`No file found for ${entry.slug} in ${collection.name}`);
}
return file.fields.map(f => f.name);
}
filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule) {
@ -1012,7 +1009,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
}
}
export function resolveBackend(config?: Config) {
export function resolveBackend<EF extends BaseField>(config?: Config<EF>) {
if (!config?.backend.name) {
throw new Error('No backend defined in configuration');
}
@ -1020,22 +1017,22 @@ export function resolveBackend(config?: Config) {
const { name } = config.backend;
const authStore = new LocalStorageAuthStore();
const backend = getBackend(name);
const backend = getBackend<EF>(name);
if (!backend) {
throw new Error(`Backend not found: ${name}`);
} else {
return new Backend(backend, { backendName: name, authStore, config });
return new Backend<EF, BackendClass>(backend, { backendName: name, authStore, config });
}
}
export const currentBackend = (function () {
let backend: Backend;
return <T extends BaseField = UnknownField>(config: Config<T>) => {
return <EF extends BaseField = UnknownField>(config: Config<EF>) => {
if (backend) {
return backend;
}
return (backend = resolveBackend(config as Config));
return (backend = resolveBackend(config) as unknown as Backend);
};
})();

View File

@ -83,7 +83,7 @@ export const API_NAME = 'Bitbucket';
function replace404WithEmptyResponse(err: FetchError) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
return { size: 0, values: [] as BitBucketFile[] } as BitBucketSrcResult;
} else {
return Promise.reject(err);
@ -188,7 +188,8 @@ export default class API {
// doesn't.)
...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}),
});
processFiles = (files: BitBucketFile[]) => files.filter(this.isFile).map(this.processFile);
processFiles = (files: BitBucketFile[], folderSupport?: boolean) =>
files.filter(file => (!folderSupport ? this.isFile(file) : true)).map(this.processFile);
readFile = async (
path: string,
@ -234,7 +235,7 @@ export default class API {
url: `${this.repoURL}/commits`,
params: { include: branch, pagelen: '100' },
}).catch(e => {
console.info(`Failed getting commits for branch '${branch}'`, e);
console.info(`[StaticCMS] Failed getting commits for branch '${branch}'`, e);
return [];
});
@ -294,7 +295,7 @@ export default class API {
})),
])((cursor.data?.links as Record<string, unknown>)[action]);
listAllFiles = async (path: string, depth: number, branch: string) => {
listAllFiles = async (path: string, depth: number, branch: string, folderSupport?: boolean) => {
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
path,
depth,
@ -311,7 +312,7 @@ export default class API {
entries.push(...newEntries);
currentCursor = newCursor;
}
return this.processFiles(entries);
return this.processFiles(entries, folderSupport);
};
async uploadFiles(

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material/styles';
import { Bitbucket as BitbucketIcon } from '@styled-icons/fa-brands/Bitbucket';
import React, { useCallback, useMemo, useState } from 'react';
import 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}
/>
);
};

View File

@ -126,7 +126,7 @@ export default class BitbucketBackend implements BackendClass {
);
})
.catch(e => {
console.warn('Failed getting BitBucket status', e);
console.warn('[StaticCMS] Failed getting BitBucket status', e);
return true;
});
@ -138,7 +138,7 @@ export default class BitbucketBackend implements BackendClass {
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Bitbucket user', e);
console.warn('[StaticCMS] Failed getting Bitbucket user', e);
return false;
})) || false;
}
@ -351,12 +351,18 @@ export default class BitbucketBackend implements BackendClass {
}));
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
return this.api!.listAllFiles(mediaFolder, 1, this.branch, folderSupport).then(files =>
files.map(({ id, name, path, type }) => ({
id,
name,
path,
displayURL: { id, path },
isDirectory: type === 'commit_directory',
})),
);
}
@ -367,7 +373,7 @@ export default class BitbucketBackend implements BackendClass {
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
.catch((err: FetchError) => {
if (err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
} else {
console.error(err);
}

View File

@ -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<{
@ -36,6 +32,42 @@ const GitGatewayAuthenticationPage = ({
password?: string;
}>({});
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (window.netlifyIdentity) {
let initialized = false;
Promise.race([
new Promise<void>(resolve => {
window.netlifyIdentity?.on('init', () => {
if (!initialized) {
initialized = true;
resolve();
}
});
}),
new Promise<void>(resolve => {
const interval = setInterval(() => {
if (initialized) {
clearInterval(interval);
return;
}
if (window.netlifyIdentity) {
console.info('[StaticCMS] Manually initializing identity widget');
initialized = true;
window.netlifyIdentity.init();
clearInterval(interval);
resolve();
}
}, 250);
}),
]).then(() => {
setInitialized(true);
});
}
}, []);
useEffect(() => {
if (!loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
setLoggingIn(true);
@ -97,7 +129,7 @@ const GitGatewayAuthenticationPage = ({
}
}, [onLogin]);
const pageContent = useMemo(() => {
const errorContent = useMemo(() => {
if (!window.netlifyIdentity) {
return t('auth.errors.netlifyIdentityNotFound');
}
@ -118,15 +150,12 @@ 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}
disabled={!initialized}
/>
);
};

View File

@ -106,27 +106,6 @@ function getEndpoint(endpoint: string, netlifySiteURL: string | null) {
return endpoint;
}
// wait for identity widget to initialize
// force init on timeout
let initPromise = Promise.resolve() as Promise<unknown>;
if (window.netlifyIdentity) {
let initialized = false;
initPromise = Promise.race([
new Promise<void>(resolve => {
window.netlifyIdentity?.on('init', () => {
initialized = true;
resolve();
});
}),
new Promise(resolve => setTimeout(resolve, 2500)).then(() => {
if (!initialized) {
console.info('Manually initializing identity widget');
window.netlifyIdentity?.init();
}
}),
]);
}
interface NetlifyUser extends Credentials {
jwt: () => Promise<string>;
email: string;
@ -199,7 +178,7 @@ export default class GitGateway implements BackendClass {
.every((statusComponent: GitGatewayStatus) => statusComponent.status === 'operational');
})
.catch(e => {
console.warn('Failed getting Git Gateway status', e);
console.warn('[StaticCMS] Failed getting Git Gateway status', e);
return true;
});
@ -210,7 +189,7 @@ export default class GitGateway implements BackendClass {
(await this.tokenPromise?.()
.then(token => !!token)
.catch(e => {
console.warn('Failed getting Identity token', e);
console.warn('[StaticCMS] Failed getting Identity token', e);
return false;
})) || false;
}
@ -222,7 +201,6 @@ export default class GitGateway implements BackendClass {
if (this.authClient) {
return this.authClient;
}
await initPromise;
this.authClient = {
logout: () => window.netlifyIdentity?.logout(),
currentUser: () => window.netlifyIdentity?.currentUser(),
@ -413,8 +391,8 @@ export default class GitGateway implements BackendClass {
return client.enabled && client.matchPath(path);
}
getMedia(mediaFolder = this.mediaFolder) {
return this.backend!.getMedia(mediaFolder);
getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
return this.backend!.getMedia(mediaFolder, folderSupport);
}
// this method memoizes this._getLargeMediaClient so that there can
@ -440,7 +418,7 @@ export default class GitGateway implements BackendClass {
.then((patterns: string[]) => ({ err: null, patterns }))
.catch((err: Error) => {
if (err.message.includes('404')) {
console.info('This 404 was expected and handled appropriately.');
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
return { err: null, patterns: [] as string[] };
} else {
return { err, patterns: [] as string[] };
@ -486,7 +464,7 @@ export default class GitGateway implements BackendClass {
const entry = items[0];
const pointerFile = parsePointerFile(entry.data);
if (!pointerFile.sha) {
console.warn(`Failed parsing pointer file ${path}`);
console.warn(`[StaticCMS] Failed parsing pointer file ${path}`);
return { url: path, blob: new Blob() };
}
@ -495,7 +473,7 @@ export default class GitGateway implements BackendClass {
return { url, blob };
}
async getMediaDisplayURL(displayURL: DisplayURL) {
async getMediaDisplayURL(displayURL: DisplayURL): Promise<string> {
const { path, id } = displayURL as DisplayURLObject;
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
@ -506,8 +484,7 @@ export default class GitGateway implements BackendClass {
return displayURL;
}
const url = await this.backend!.getMediaDisplayURL(displayURL);
return url;
return this.backend!.getMediaDisplayURL(displayURL);
}
async getMediaFile(path: string) {

View File

@ -323,6 +323,7 @@ export default class API {
async listFiles(
path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
folderSupport?: boolean,
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/');
try {
@ -336,10 +337,11 @@ export default class API {
);
return (
result.tree
// filter only files and up to the required depth
// filter only files and/or folders up to the required depth
.filter(
file =>
file.type === 'blob' && decodeURIComponent(file.path).split('/').length <= depth,
(!folderSupport ? file.type === 'blob' : true) &&
decodeURIComponent(file.path).split('/').length <= depth,
)
.map(file => ({
type: file.type,
@ -352,7 +354,7 @@ export default class API {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
return [];
} else {
throw err;

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material/styles';
import { Gitea as GiteaIcon } from '@styled-icons/simple-icons/Gitea';
import React, { useCallback, useState } from 'react';
import 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}
/>
);
};

View File

@ -281,5 +281,49 @@ describe('gitea API', () => {
params: { recursive: 1 },
});
});
it('should get files and folders', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const tree = [
{
path: 'image.png',
type: 'blob',
},
{
path: 'dir1',
type: 'tree',
},
{
path: 'dir1/nested-image.png',
type: 'blob',
},
{
path: 'dir1/dir2',
type: 'tree',
},
{
path: 'dir1/dir2/nested-image.png',
type: 'blob',
},
];
api.request = jest.fn().mockResolvedValue({ tree });
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
{
path: 'media/image.png',
type: 'blob',
name: 'image.png',
},
{
path: 'media/dir1',
type: 'tree',
name: 'dir1',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
params: {},
});
});
});
});

View File

@ -93,7 +93,7 @@ export default class Gitea implements BackendClass {
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Gitea user', e);
console.warn('[StaticCMS] Failed getting Gitea user', e);
return false;
})) || false;
@ -285,15 +285,13 @@ export default class Gitea implements BackendClass {
.catch(() => ({ file: { path, id: null }, data: '' }));
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listFiles(mediaFolder).then(files =>
files.map(({ id, name, size, path }) => {
// load media using getMediaDisplayURL to avoid token expiration with Gitlab raw content urls
// for private repositories
return { id, name, size, displayURL: { id, path }, path };
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path, type }) => {
return { id, name, size, displayURL: { id, path }, path, isDirectory: type === 'tree' };
}),
);
}

View File

@ -338,6 +338,7 @@ export default class API {
async listFiles(
path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
folderSupport?: boolean,
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/');
try {
@ -351,8 +352,12 @@ export default class API {
);
return (
result.tree
// filter only files and up to the required depth
.filter(file => file.type === 'blob' && file.path.split('/').length <= depth)
// filter only files and/or folders up to the required depth
.filter(
file =>
(!folderSupport ? file.type === 'blob' : true) &&
file.path.split('/').length <= depth,
)
.map(file => ({
type: file.type,
id: file.sha,
@ -364,7 +369,7 @@ export default class API {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
return [];
} else {
throw err;

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material/styles';
import { Github as GithubIcon } from '@styled-icons/simple-icons/Github';
import React, { useCallback, useState } from 'react';
import 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}
/>
);
};

View File

@ -314,5 +314,49 @@ describe('github API', () => {
params: { recursive: 1 },
});
});
it('should get files and folders', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const tree = [
{
path: 'image.png',
type: 'blob',
},
{
path: 'dir1',
type: 'tree',
},
{
path: 'dir1/nested-image.png',
type: 'blob',
},
{
path: 'dir1/dir2',
type: 'tree',
},
{
path: 'dir1/dir2/nested-image.png',
type: 'blob',
},
];
api.request = jest.fn().mockResolvedValue({ tree });
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
{
path: 'media/image.png',
type: 'blob',
name: 'image.png',
},
{
path: 'media/dir1',
type: 'tree',
name: 'dir1',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
params: {},
});
});
});
});

View File

@ -109,7 +109,7 @@ export default class GitHub implements BackendClass {
);
})
.catch(e => {
console.warn('Failed getting GitHub status', e);
console.warn('[StaticCMS] Failed getting GitHub status', e);
return true;
});
@ -121,7 +121,7 @@ export default class GitHub implements BackendClass {
?.getUser()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitHub user', e);
console.warn('[StaticCMS] Failed getting GitHub user', e);
return false;
})) || false;
}
@ -314,15 +314,15 @@ export default class GitHub implements BackendClass {
.catch(() => ({ file: { path, id: null }, data: '' }));
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listFiles(mediaFolder).then(files =>
files.map(({ id, name, size, path }) => {
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path, type }) => {
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
// for private repositories
return { id, name, size, displayURL: { id, path }, path };
return { id, name, size, displayURL: { id, path }, path, isDirectory: type == 'tree' };
}),
);
}

View File

@ -318,7 +318,12 @@ export default class API {
};
};
listAllFiles = async (path: string, recursive = false, branch = this.branch) => {
listAllFiles = async (
path: string,
folderSupport?: boolean,
recursive = false,
branch = this.branch,
) => {
const entries = [];
// eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
@ -333,7 +338,7 @@ export default class API {
entries.push(...newEntries);
cursor = newCursor;
}
return entries.filter(({ type }) => type === 'blob');
return entries.filter(({ type }) => (!folderSupport ? type === 'blob' : true));
};
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
@ -421,7 +426,7 @@ export default class API {
for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
const sourceDir = dirname(item.oldPath as string);
const destDir = dirname(item.path);
const children = await this.listAllFiles(sourceDir, true, branch);
const children = await this.listAllFiles(sourceDir, undefined, true, branch);
children
.filter(f => f.path !== item.oldPath)
.forEach(file => {

View File

@ -1,21 +1,16 @@
import { styled } from '@mui/material/styles';
import { Gitlab as GitlabIcon } from '@styled-icons/simple-icons/Gitlab';
import React, { useCallback, useMemo, useState } from 'react';
import 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}
/>
);
};

View File

@ -86,7 +86,7 @@ export default class GitLab implements BackendClass {
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitLab user', e);
console.warn('[StaticCMS] Failed getting GitLab user', e);
return false;
})) || false;
@ -172,7 +172,7 @@ export default class GitLab implements BackendClass {
}
async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth > 1);
const files = await this.api!.listAllFiles(folder, undefined, depth > 1);
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
return filtered;
}
@ -217,13 +217,13 @@ export default class GitLab implements BackendClass {
}));
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listAllFiles(mediaFolder).then(files =>
files.map(({ id, name, path }) => {
return { id, name, path, displayURL: { id, name, path } };
return this.api!.listAllFiles(mediaFolder, folderSupport).then(files =>
files.map(({ id, name, path, type }) => {
return { id, name, path, displayURL: { id, name, path }, isDirectory: type === 'tree' };
}),
);
}

View File

@ -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;

View File

@ -146,24 +146,33 @@ export default class ProxyBackend implements BackendClass {
});
}
async getMedia(mediaFolder = this.mediaFolder, publicFolder = this.publicFolder) {
const files: { path: string; url: string }[] = await this.request({
async getMedia(
mediaFolder = this.mediaFolder,
folderSupport?: boolean,
publicFolder = this.publicFolder,
) {
const files: { path: string; url: string; isDirectory: boolean }[] = await this.request({
action: 'getMedia',
params: { branch: this.branch, mediaFolder, publicFolder },
});
return files.map(({ url, path }) => {
const filteredFiles = folderSupport ? files : files.filter(f => !f.isDirectory);
return filteredFiles.map(({ url, path, isDirectory }) => {
const id = url;
const name = basename(path);
return { id, name, displayURL: { id, path: url }, path };
return { id, name, displayURL: { id, path: url }, path, isDirectory };
});
}
async getMediaFile(path: string): Promise<ImplementationMediaFile> {
const file = await this.request<MediaFile>({
action: 'getMediaFile',
params: { branch: this.branch, path },
params: {
branch: this.branch,
path,
},
});
return deserializeMediaFile(file);
}

View File

@ -1,30 +1,14 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect } from 'react';
import 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;

View File

@ -2,24 +2,26 @@ import attempt from 'lodash/attempt';
import isError from 'lodash/isError';
import take from 'lodash/take';
import unset from 'lodash/unset';
import { extname } from 'path';
import { basename, dirname } 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 { isNotEmpty } from '@staticcms/core/lib/util/string.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';
type RepoFile = { path: string; content: string | AssetProxy };
type RepoFile = { path: string; content: string | AssetProxy; isDirectory?: boolean };
type RepoTree = { [key: string]: RepoFile | RepoTree };
declare global {
@ -82,20 +84,36 @@ export function getFolderFiles(
depth: number,
files = [] as RepoFile[],
path = folder,
) {
includeFolders?: boolean,
): RepoFile[] {
if (depth <= 0) {
return files;
}
if (includeFolders) {
files.unshift({ isDirectory: true, content: '', path });
}
Object.keys(tree[folder] || {}).forEach(key => {
if (extname(key)) {
const parts = key.split('.');
const keyExtension = parts.length > 1 ? parts[parts.length - 1] : '';
if (isNotEmpty(keyExtension)) {
const file = (tree[folder] as RepoTree)[key] as RepoFile;
if (!extension || key.endsWith(`.${extension}`)) {
files.unshift({ content: file.content, path: `${path}/${key}` });
}
} else {
const subTree = tree[folder] as RepoTree;
return getFolderFiles(subTree, key, extension, depth - 1, files, `${path}/${key}`);
return getFolderFiles(
subTree,
key,
extension,
depth - 1,
files,
`${path}/${key}`,
includeFolders,
);
}
});
@ -206,8 +224,14 @@ export default class TestBackend implements BackendClass {
async persistEntry(entry: BackendEntry) {
entry.dataFiles.forEach(dataFile => {
const { path, raw } = dataFile;
writeFile(path, raw, window.repoFiles);
const { path, newPath, raw } = dataFile;
if (newPath) {
deleteFile(path, window.repoFiles);
writeFile(newPath, raw, window.repoFiles);
} else {
writeFile(path, raw, window.repoFiles);
}
});
entry.assets.forEach(a => {
writeFile(a.path, a, window.repoFiles);
@ -215,37 +239,48 @@ export default class TestBackend implements BackendClass {
return Promise.resolve();
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(
mediaFolder = this.mediaFolder,
folderSupport?: boolean,
): 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));
const files = getFolderFiles(
window.repoFiles,
mediaFolder.split('/')[0],
'',
100,
undefined,
undefined,
folderSupport,
).filter(f => {
return dirname(f.path) === mediaFolder;
});
return files.map(f => ({
name: basename(f.path),
id: f.path,
path: f.path,
displayURL: f.path,
isDirectory: f.isDirectory ?? false,
}));
}
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: basename(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;

View File

@ -9,12 +9,11 @@ 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';
import { selectLocale } from './reducers/selectors/config';
import { store } from './store';
@ -23,8 +22,29 @@ 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';
/**
* Very hacky. This suppresses the "You are importing createRoot from "react-dom" which
* is not supported. You should instead import it from "react-dom/client"." warning.
*
* Not sure why this is necessary as we import from "react-dom/client" as we should.
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars, import/order
import ReactDOM from 'react-dom';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint = true;
const TranslatedApp = ({ locale, config }: AppRootProps) => {
if (!config) {
return null;
@ -60,7 +80,7 @@ function bootstrap<F extends BaseField = UnknownField>(opts?: {
* Log the version number.
*/
if (typeof STATIC_CMS_CORE_VERSION === 'string') {
console.info(`static-cms-core ${STATIC_CMS_CORE_VERSION}`);
console.info(`[StaticCMS] Using @staticcms/core ${STATIC_CMS_CORE_VERSION}`);
}
/**

View File

@ -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,20 @@ 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 { invokeEvent } from '../lib/registry';
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 +39,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();
@ -76,8 +56,8 @@ function CollectionSearchRedirect() {
}
function EditEntityRedirect() {
const params = useParams();
return <Navigate to={`/collections/${params.name}/entries/${params['*']}`} />;
const { name, ...params } = useParams();
return <Navigate to={`/collections/${name}/entries/${params['*']}`} />;
}
const App = ({
@ -87,24 +67,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 +139,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 +170,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,41 +201,40 @@ 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 />}
/>
<Route
path="/collections/:name/new/*"
element={<EditorRoute collections={collections} newRecord />}
/>
<Route
path="/collections/:name/entries/*"
element={<EditorRoute collections={collections} />}
/>
<Route
path="/collections/:name/search/:searchTerm"
element={
<CollectionRoute collections={collections} isSearchResults isSingleSearchResult />
}
/>
<Route
path="/collections/:name/filter/*"
element={<CollectionRoute collections={collections} />}
/>
<Route
path="/search/:searchTerm"
element={<CollectionRoute collections={collections} isSearchResults />}
element={<CollectionRoute isSearchResults isSingleSearchResult />}
/>
<Route path="/collections/:name/filter/*" element={<CollectionRoute />} />
<Route path="/search/:searchTerm" element={<CollectionRoute isSearchResults />} />
<Route path="/edit/:name/*" 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]);
useEffect(() => {
setTimeout(() => {
invokeEvent({ name: 'mounted' });
});
}, []);
if (!config.config) {
return configError(t('app.app.configNotFound'));
@ -238,35 +249,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 +278,6 @@ function mapStateToProps(state: RootState) {
collections,
user,
isFetching,
useMediaLibrary,
scrollSyncEnabled,
};
}

View File

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

View File

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

View File

@ -1,54 +0,0 @@
import React, { useMemo } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import { getDefaultPath } from '../../lib/util/collection.util';
import MainView from '../App/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 params = useParams();
const { name, searchTerm } = params;
const filterTerm = params['*'];
const collection = useMemo(() => {
if (!name) {
return false;
}
return collections[name];
}, [collections, name]);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
if (!searchTerm && (!name || !collection)) {
return <Navigate to={defaultPath} />;
}
if (collection && 'files' in collection && collection.files?.length === 1) {
return <Navigate to={`/collections/${collection.name}/entries/${collection.files[0].name}`} />;
}
return (
<MainView>
<Collection
name={name}
searchTerm={searchTerm}
filterTerm={filterTerm}
isSearchResults={isSearchResults}
isSingleSearchResult={isSingleSearchResult}
/>
</MainView>
);
};
export default CollectionRoute;

View File

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

View File

@ -1,147 +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 { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
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 { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
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, Field } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux';
const EntryCard = ({
collection,
entry,
path,
image,
imageField,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
}: NestedCollectionProps) => {
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection, 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>
);
};
interface EntryCardOwnProps {
entry: Entry;
inferredFields: {
titleField?: string | null | undefined;
descriptionField?: string | null | undefined;
imageField?: string | null | undefined;
remainingFields?: Field[] | undefined;
};
collection: Collection;
imageField?: Field;
collectionLabel?: string;
viewStyle?: CollectionViewStyle;
}
function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
const { entry, inferredFields, collection } = ownProps;
const entryData = entry.data;
let image = inferredFields.imageField
? (entryData?.[inferredFields.imageField] as string | undefined)
: undefined;
if (image) {
image = encodeURI(image.trim());
}
const isLoadingAsset = selectIsLoadingAsset(state);
return {
...ownProps,
path: `/collections/${collection.name}/entries/${entry.slug}`,
image,
imageField:
'fields' in collection
? collection.fields?.find(f => f.name === inferredFields.imageField && f.widget === 'image')
: undefined,
isLoadingAsset,
};
}
const mapDispatchToProps = {
getAsset: getAssetAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type NestedCollectionProps = ConnectedProps<typeof connector>;
export default connector(EntryCard);

View File

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

View File

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

View File

@ -1,354 +0,0 @@
import ArticleIcon from '@mui/icons-material/Article';
import { styled } from '@mui/material/styles';
import sortBy from 'lodash/sortBy';
import { dirname, sep } from 'path';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { colors, components } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { stringTemplate } from '@staticcms/core/lib/widgets';
import { selectEntries } from '@staticcms/core/reducers/selectors/entries';
import type { Collection, Entry } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ConnectedProps } from 'react-redux';
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;
isDir: boolean;
isRoot: boolean;
expanded?: boolean;
}
type SingleTreeNodeData = BaseTreeNodeData | (Entry & BaseTreeNodeData);
type TreeNodeData = SingleTreeNodeData & {
children: TreeNodeData[];
};
function getNodeTitle(node: TreeNodeData) {
const title = node.isRoot
? node.title
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
return title;
}
interface TreeNodeProps {
collection: Collection;
treeData: TreeNodeData[];
depth?: number;
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void;
}
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
const collectionName = collection.name;
const sortedData = sortBy(treeData, getNodeTitle);
return (
<>
{sortedData.map(node => {
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
if (leaf) {
return null;
}
let to = `/collections/${collectionName}`;
if (depth > 0) {
to = `${to}/filter${node.path}`;
}
const title = getNodeTitle(node);
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
return (
<Fragment key={node.path}>
<TreeNavLink
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>
{node.expanded && (
<TreeNode
collection={collection}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
/>
)}
</Fragment>
);
})}
</>
);
};
export function walk(treeData: TreeNodeData[], callback: (node: TreeNodeData) => void) {
function traverse(children: TreeNodeData[]) {
for (const child of children) {
callback(child);
traverse(child.children);
}
}
return traverse(treeData);
}
export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeData[] {
const collectionFolder = 'folder' in collection ? collection.folder : '';
const rootFolder = '/';
const entriesObj = entries.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
const dirs = entriesObj.reduce((acc, entry) => {
let dir: string | undefined = dirname(entry.path);
while (dir && !acc[dir] && dir !== rootFolder) {
const parts: string[] = dir.split(sep);
acc[dir] = parts.pop();
dir = parts.length ? parts.join(sep) : undefined;
}
return acc;
}, {} as Record<string, string | undefined>);
if ('nested' in collection && collection.nested?.summary) {
collection = {
...collection,
summary: collection.nested.summary,
};
} else {
collection = {
...collection,
};
delete collection.summary;
}
const flatData = [
{
title: collection.label,
path: rootFolder,
isDir: true,
isRoot: true,
},
...Object.entries(dirs).map(([key, value]) => ({
title: value,
path: key,
isDir: true,
isRoot: false,
})),
...entriesObj.map((e, index) => {
let entryMap = entries[index];
entryMap = {
...entryMap,
data: addFileTemplateFields(entryMap.path, entryMap.data as Record<string, string>),
};
const title = selectEntryCollectionTitle(collection, entryMap);
return {
...e,
title,
isDir: false,
isRoot: false,
};
}),
];
const parentsToChildren = flatData.reduce((acc, node) => {
const parent = node.path === rootFolder ? '' : dirname(node.path);
if (acc[parent]) {
acc[parent].push(node);
} else {
acc[parent] = [node];
}
return acc;
}, {} as Record<string, BaseTreeNodeData[]>);
function reducer(acc: TreeNodeData[], value: BaseTreeNodeData) {
const node = value;
let children: TreeNodeData[] = [];
if (parentsToChildren[node.path]) {
children = parentsToChildren[node.path].reduce(reducer, []);
}
acc.push({ ...node, children });
return acc;
}
const treeData = parentsToChildren[''].reduce(reducer, []);
return treeData;
}
export function updateNode(
treeData: TreeNodeData[],
node: TreeNodeData,
callback: (node: TreeNodeData) => TreeNodeData,
) {
let stop = false;
function updater(nodes: TreeNodeData[]) {
if (stop) {
return nodes;
}
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].path === node.path) {
nodes[i] = callback(node);
stop = true;
return nodes;
}
}
nodes.forEach(node => updater(node.children));
return nodes;
}
return updater([...treeData]);
}
const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => {
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
const [selected, setSelected] = useState<TreeNodeData | null>(null);
const [useFilter, setUseFilter] = useState(true);
const [prevCollection, setPrevCollection] = useState(collection);
const [prevEntries, setPrevEntries] = useState(entries);
const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm);
useEffect(() => {
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
const expanded: Record<string, boolean> = {};
walk(treeData, node => {
if (node.expanded) {
expanded[node.path] = true;
}
});
const newTreeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(newTreeData, node => {
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
node.expanded = true;
}
});
setTreeData(newTreeData);
}
setPrevCollection(collection);
setPrevEntries(entries);
setPrevFilterTerm(filterTerm);
}, [
collection,
entries,
filterTerm,
prevCollection,
prevEntries,
prevFilterTerm,
treeData,
useFilter,
]);
const onToggle = useCallback(
({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => {
if (!selected || selected.path === node.path || expanded) {
setTreeData(
updateNode(treeData, node, node => ({
...node,
expanded,
})),
);
setSelected(node);
setUseFilter(false);
} else {
// don't collapse non selected nodes when clicked
setSelected(node);
setUseFilter(false);
}
},
[selected, treeData],
);
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
};
interface NestedCollectionOwnProps {
collection: Collection;
filterTerm: string;
}
function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) {
const { collection } = ownProps;
const entries = selectEntries(state, collection) ?? [];
return { ...ownProps, entries };
}
const connector = connect(mapStateToProps, {});
export type NestedCollectionProps = ConnectedProps<typeof connector>;
export default connector(NestedCollection);

View File

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

View File

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

View File

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

View File

@ -1,393 +0,0 @@
import { styled } from '@mui/material/styles';
import { isEqual } from 'lodash';
import isEmpty from 'lodash/isEmpty';
import React, { createElement, useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
changeDraftField as changeDraftFieldAction,
changeDraftFieldValidation,
} from '@staticcms/core/actions/entries';
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
import {
clearMediaControl as clearMediaControlAction,
openMediaLibrary as openMediaLibraryAction,
removeInsertedMedia as removeInsertedMediaAction,
removeMediaControl as removeMediaControlAction,
} from '@staticcms/core/actions/mediaLibrary';
import { query as queryAction } from '@staticcms/core/actions/search';
import { borders, colors, lengths, transitions } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n';
import { resolveWidget } from '@staticcms/core/lib/registry';
import { getFieldLabel } from '@staticcms/core/lib/util/field.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { validate } from '@staticcms/core/lib/util/validation.util';
import { selectFieldErrors } from '@staticcms/core/reducers/selectors/entryDraft';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import type {
Field,
FieldsErrors,
GetAssetFunction,
I18nSettings,
TranslatedProps,
UnknownField,
ValueOrNestedValue,
Widget,
} from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
/**
* This is a necessary bridge as we are still passing classnames to widgets
* for styling. Once that changes we can stop storing raw style strings like
* this.
*/
const styleStrings = {
widget: `
display: block;
width: 100%;
padding: ${lengths.inputPadding};
margin: 0;
border: ${borders.textField};
border-radius: ${lengths.borderRadius};
border-top-left-radius: 0;
outline: 0;
box-shadow: none;
background-color: ${colors.inputBackground};
color: #444a57;
transition: border-color ${transitions.main};
position: relative;
font-size: 15px;
line-height: 1.5;
select& {
text-indent: 14px;
height: 58px;
}
`,
widgetActive: `
border-color: ${colors.active};
`,
widgetError: `
border-color: ${colors.errorText};
`,
disabled: `
pointer-events: none;
opacity: 0.5;
background: #ccc;
`,
hidden: `
visibility: hidden;
`,
};
interface ControlContainerProps {
$isHidden: boolean;
}
const ControlContainer = styled(
'div',
transientOptions,
)<ControlContainerProps>(
({ $isHidden }) => `
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
width: 100%;
${$isHidden ? styleStrings.hidden : ''};
`,
);
const ControlErrorsList = styled('ul')`
list-style-type: none;
font-size: 12px;
color: ${colors.errorText};
position: relative;
font-weight: 600;
display: flex;
flex-direction: column;
margin: 0;
padding: 4px 8px;
`;
interface ControlHintProps {
$error: boolean;
}
const ControlHint = styled(
'p',
transientOptions,
)<ControlHintProps>(
({ $error }) => `
margin: 0;
margin-left: 8px;
padding: 0;
font-size: 12px;
color: ${$error ? colors.errorText : colors.controlLabel};
transition: color ${transitions.main};
`,
);
const EditorControl = ({
clearMediaControl,
collection,
config: configState,
entry,
field,
fieldsErrors,
submitted,
getAsset,
isDisabled = false,
isParentDuplicate = false,
isFieldDuplicate: deprecatedIsFieldDuplicate,
isParentHidden = false,
isFieldHidden: deprecatedIsFieldHidden,
locale,
mediaPaths,
openMediaLibrary,
parentPath,
query,
removeInsertedMedia,
removeMediaControl,
t,
value,
forList = false,
changeDraftField,
i18n,
fieldName,
}: TranslatedProps<EditorControlProps>) => {
const dispatch = useAppDispatch();
const id = useUUID();
const widgetName = field.widget;
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue>;
const fieldHint = field.hint;
const path = useMemo(
() =>
parentPath.length > 0 ? `${parentPath}.${fieldName ?? field.name}` : fieldName ?? field.name,
[field.name, fieldName, parentPath],
);
const [dirty, setDirty] = useState(!isEmpty(value));
const fieldErrorsSelector = useMemo(() => selectFieldErrors(path, i18n), [i18n, path]);
const errors = useAppSelector(fieldErrorsSelector);
const hasErrors = (submitted || dirty) && Boolean(errors.length);
const handleGetAsset: GetAssetFunction = useMemo(
() => (path: string, field?: Field) => {
return getAsset(collection, entry, path, field);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection],
);
const isDuplicate = useMemo(
() => isParentDuplicate || isFieldDuplicate(field, locale, i18n?.defaultLocale),
[field, i18n?.defaultLocale, isParentDuplicate, locale],
);
const isHidden = useMemo(
() => isParentHidden || isFieldHidden(field, locale, i18n?.defaultLocale),
[field, i18n?.defaultLocale, isParentHidden, locale],
);
useEffect(() => {
if ((!dirty && !submitted) || isHidden) {
return;
}
const validateValue = async () => {
const errors = await validate(field, value, widget, t);
dispatch(changeDraftFieldValidation(path, errors, i18n));
};
validateValue();
}, [dirty, dispatch, field, i18n, isHidden, path, submitted, t, value, widget]);
const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => {
setDirty(true);
changeDraftField({ path, field, value, i18n });
},
[changeDraftField, field, i18n, path],
);
const config = useMemo(() => configState.config, [configState.config]);
const finalValue = useMemoCompare(value, isEqual);
const [version, setVersion] = useState(0);
useEffect(() => {
if (isNotNullish(finalValue)) {
return;
}
if ('default' in field && isNotNullish(!field.default)) {
if (widget.getDefaultValue) {
handleChangeDraftField(
widget.getDefaultValue(field.default, field as unknown as UnknownField),
);
} else {
handleChangeDraftField(field.default);
}
setVersion(version => version + 1);
return;
}
if (widget.getDefaultValue) {
handleChangeDraftField(widget.getDefaultValue(null, field as unknown as UnknownField));
setVersion(version => version + 1);
}
}, [field, finalValue, handleChangeDraftField, widget]);
return useMemo(() => {
if (!collection || !entry || !config || field.widget === 'hidden') {
return null;
}
return (
<ControlContainer $isHidden={isHidden}>
<>
{createElement(widget.control, {
key: `${id}-${version}`,
collection,
config,
entry,
field: field as UnknownField,
fieldsErrors,
submitted,
getAsset: handleGetAsset,
isDisabled: isDisabled || isDuplicate,
isDuplicate,
isFieldDuplicate: deprecatedIsFieldDuplicate,
isHidden,
isFieldHidden: deprecatedIsFieldHidden,
label: getFieldLabel(field, t),
locale,
mediaPaths,
onChange: handleChangeDraftField,
clearMediaControl,
openMediaLibrary,
removeInsertedMedia,
removeMediaControl,
path,
query,
t,
value: finalValue,
forList,
i18n,
hasErrors,
})}
{fieldHint ? (
<ControlHint key="hint" $error={hasErrors}>
{fieldHint}
</ControlHint>
) : null}
{hasErrors ? (
<ControlErrorsList key="errors">
{errors.map(error => {
return (
error.message &&
typeof error.message === 'string' && (
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
)
);
})}
</ControlErrorsList>
) : null}
</>
</ControlContainer>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
collection,
config,
path,
errors,
isHidden,
widget.control,
field,
submitted,
handleGetAsset,
isDisabled,
t,
locale,
mediaPaths,
handleChangeDraftField,
clearMediaControl,
openMediaLibrary,
removeInsertedMedia,
removeMediaControl,
query,
finalValue,
forList,
i18n,
hasErrors,
fieldHint,
]);
};
interface EditorControlOwnProps {
field: Field;
fieldsErrors: FieldsErrors;
submitted: boolean;
isDisabled?: boolean;
isParentDuplicate?: boolean;
/**
* @deprecated use isDuplicate instead
*/
isFieldDuplicate?: (field: Field) => boolean;
isParentHidden?: boolean;
/**
* @deprecated use isHidden instead
*/
isFieldHidden?: (field: Field) => boolean;
locale?: string;
parentPath: string;
value: ValueOrNestedValue;
forList?: boolean;
i18n: I18nSettings | undefined;
fieldName?: string;
}
function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {
const { collections, entryDraft } = state;
const entry = entryDraft.entry;
const collection = entryDraft.entry ? collections[entryDraft.entry.collection] : null;
const isLoadingAsset = selectIsLoadingAsset(state);
return {
...ownProps,
mediaPaths: state.mediaLibrary.controlMedia,
config: state.config,
entry,
collection,
isLoadingAsset,
};
}
const mapDispatchToProps = {
changeDraftField: changeDraftFieldAction,
openMediaLibrary: openMediaLibraryAction,
clearMediaControl: clearMediaControlAction,
removeMediaControl: removeMediaControlAction,
removeInsertedMedia: removeInsertedMediaAction,
query: queryAction,
getAsset: getAssetAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EditorControlProps = ConnectedProps<typeof connector>;
export default connector(translate()(EditorControl) as ComponentType<EditorControlProps>);

View File

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

View File

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

View File

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

View File

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

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