feat: v2.0.0
@ -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.
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"useWorkspaces": true,
|
||||
"version": "1.2.14"
|
||||
"version": "2.0.0-rc.1"
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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'],
|
||||
|
@ -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": {
|
||||
|
7
packages/app/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
7
packages/app/tailwind.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
const baseConfig = require('../../tailwind.base.config');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['../core/src/**/*.tsx'],
|
||||
...baseConfig,
|
||||
};
|
@ -49,7 +49,7 @@
|
||||
"@staticcms/string/*": ["../core/src/widgets/string/*"],
|
||||
"@staticcms/text": ["../core/src/widgets/text"],
|
||||
"@staticcms/text/*": ["../core/src/widgets/text/*"],
|
||||
"@staticcms/core": ["../core/core/src/*"],
|
||||
"@staticcms/core": ["../core/src"],
|
||||
"@staticcms/core/*": ["../core/src/*"]
|
||||
},
|
||||
"types": ["@emotion/react/types/css-prop", "@types/jest", "@testing-library/jest-dom"]
|
||||
|
@ -2,6 +2,7 @@ const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const devServerPort = parseInt(process.env.STATIC_CMS_DEV_SERVER_PORT || `${8080}`);
|
||||
@ -44,14 +45,11 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: ['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath),
|
||||
include: [...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), path.resolve(__dirname, '..', 'core', 'src')],
|
||||
use: [
|
||||
{
|
||||
loader: 'style-loader',
|
||||
},
|
||||
{
|
||||
loader: 'css-loader',
|
||||
},
|
||||
!isProduction ? 'style-loader' : MiniCssExtractPlugin.loader,
|
||||
'css-loader',
|
||||
'postcss-loader',
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -85,6 +83,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
!isProduction && new ReactRefreshWebpackPlugin(),
|
||||
isProduction && new MiniCssExtractPlugin(),
|
||||
new webpack.IgnorePlugin({ resourceRegExp: /^esprima$/ }),
|
||||
new webpack.IgnorePlugin({ resourceRegExp: /moment\/locale\// }),
|
||||
new webpack.ProvidePlugin({
|
||||
|
@ -55,23 +55,6 @@ module.exports = {
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@mui/*/*/*', '!@mui/material/test-utils/*'],
|
||||
message: 'Do not import material imports as 3rd level imports',
|
||||
allowTypeImports: true,
|
||||
},
|
||||
{
|
||||
group: ['@mui/material', '!@mui/material/'],
|
||||
message: 'Please import material imports as defaults or 2nd level imports',
|
||||
allowTypeImports: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'import/prefer-default-export': 'error',
|
||||
},
|
||||
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
|
||||
@ -81,7 +64,7 @@ module.exports = {
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: 'packages/core/tsconfig.json',
|
||||
project: 'packages/core/tsconfig-dev.json',
|
||||
}, // this loads <rootdir>/tsconfig.json to eslint
|
||||
},
|
||||
'import/core-modules': ['src'],
|
||||
|
@ -2,4 +2,3 @@ dist/
|
||||
bin/
|
||||
public/
|
||||
.cache/
|
||||
j-toml.js
|
||||
|
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 808 KiB |
BIN
packages/core/dev-test/_posts/assets/uploads/moby-dick.jpg
Normal file
After Width: | Height: | Size: 310 KiB |
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 808 KiB |
BIN
packages/core/dev-test/assets/uploads/Other Pics/moby-dick.jpg
Normal file
After Width: | Height: | Size: 310 KiB |
BIN
packages/core/dev-test/assets/uploads/lobby.jpg
Normal file
After Width: | Height: | Size: 808 KiB |
Before Width: | Height: | Size: 4.3 KiB |
@ -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>
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: An Author
|
||||
---
|
||||
|
||||
Author details go here!.
|
@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Authors
|
||||
---
|
@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Pages
|
||||
---
|
@ -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.
|
@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Posts
|
||||
---
|
@ -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!
|
@ -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!
|
After Width: | Height: | Size: 72 KiB |
After Width: | Height: | Size: 67 KiB |
@ -1,13 +0,0 @@
|
||||
---
|
||||
title: Test
|
||||
draft: false
|
||||
date: 2022-11-01 14:28
|
||||
image: /backends/proxy/assets/posts/kittens.jpg
|
||||
---
|
||||
Test2
|
||||
|
||||
<br>
|
||||

|
||||

|
||||
|
||||

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

|
After Width: | Height: | Size: 330 KiB |
After Width: | Height: | Size: 310 KiB |
@ -1,7 +0,0 @@
|
||||
---
|
||||
title: Test3
|
||||
draft: false
|
||||
date: 2022-11-02 08:43
|
||||
image: /backends/proxy/assets/upload/kanefreeman_2.jpg
|
||||
---
|
||||
test25555
|
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Test3
|
||||
draft: false
|
||||
date: 2022-11-02 08:43
|
||||
image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
|
||||
---
|
||||
test2555556
|
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 182 KiB |
@ -0,0 +1 @@
|
||||
Some text here!
|
After Width: | Height: | Size: 61 KiB |
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 ' +
|
||||
|
@ -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}`,
|
||||
},
|
||||
|
@ -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'],
|
||||
};
|
||||
|
@ -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",
|
||||
|
7
packages/core/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
const mockDisplatch = jest.fn();
|
||||
export const useAppDispatch = jest.fn().mockReturnValue(mockDisplatch);
|
||||
export const useAppSelector = jest.fn();
|
1
packages/core/src/__mocks__/copy-text-to-clipboard.ts
Normal file
@ -0,0 +1 @@
|
||||
export default jest.fn();
|
14
packages/core/src/__mocks__/react-polyglot.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/* eslint-disable react/display-name */
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
import type { FC } from 'react';
|
||||
|
||||
export const translate = () => (Component: FC) => {
|
||||
const t = (key: string, _options: unknown) => key;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (props: any) => {
|
||||
return React.createElement(Component, { t, ...props });
|
||||
};
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/display-name */
|
||||
import React from 'react';
|
||||
|
||||
export default function (props: any) {
|
||||
const { children } = props;
|
||||
return React.createElement('div', {}, children({ width: 500, height: 1000 }));
|
||||
}
|
6
packages/core/src/__mocks__/react-waypoint.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
export function Waypoint() {
|
||||
return React.createElement('div', {}, []);
|
||||
}
|
3
packages/core/src/__mocks__/uuid.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const v4 = jest.fn().mockReturnValue('I_AM_A_UUID');
|
||||
|
||||
export const validate = jest.fn();
|
1800
packages/core/src/__tests__/testConfig.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
8
packages/core/src/actions/globalUI.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { THEME_CHANGE } from '../constants';
|
||||
|
||||
export function changeTheme(theme: 'dark' | 'light') {
|
||||
return { type: THEME_CHANGE, payload: theme } as const;
|
||||
}
|
||||
|
||||
export type GlobalUIAction = ReturnType<typeof changeTheme>;
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
};
|
||||
})();
|
||||
|
@ -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(
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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' };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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' };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -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 => {
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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' };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,213 +0,0 @@
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Link from '@mui/material/Link';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '@staticcms/core/actions/auth';
|
||||
import { openMediaLibrary as openMediaLibraryAction } from '@staticcms/core/actions/mediaLibrary';
|
||||
import { checkBackendStatus as checkBackendStatusAction } from '@staticcms/core/actions/status';
|
||||
import { buttons, colors } from '@staticcms/core/components/UI/styles';
|
||||
import { stripProtocol, getNewEntryUrl } from '@staticcms/core/lib/urlHelper';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import SettingsDropdown from '../UI/SettingsDropdown';
|
||||
|
||||
import type { TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { ComponentType, MouseEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
${buttons.button};
|
||||
background: none;
|
||||
color: #7b8290;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
gap: 2px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSpacer = styled('div')`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledAppHeaderActions = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Header = ({
|
||||
user,
|
||||
collections,
|
||||
logoutUser,
|
||||
openMediaLibrary,
|
||||
displayUrl,
|
||||
isTestRepo,
|
||||
t,
|
||||
showMediaButton,
|
||||
checkBackendStatus,
|
||||
}: TranslatedProps<HeaderProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const creatableCollections = useMemo(
|
||||
() =>
|
||||
Object.values(collections).filter(collection =>
|
||||
'folder' in collection ? collection.create ?? false : false,
|
||||
),
|
||||
[collections],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [checkBackendStatus]);
|
||||
|
||||
const handleMediaClick = useCallback(() => {
|
||||
openMediaLibrary();
|
||||
}, [openMediaLibrary]);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="sticky">
|
||||
<StyledToolbar>
|
||||
<Link to="/collections" component={NavLink} activeClassName={'header-link-active'}>
|
||||
<DescriptionIcon />
|
||||
{t('app.header.content')}
|
||||
</Link>
|
||||
{showMediaButton ? (
|
||||
<StyledButton onClick={handleMediaClick}>
|
||||
<ImageIcon />
|
||||
{t('app.header.media')}
|
||||
</StyledButton>
|
||||
) : null}
|
||||
<StyledSpacer />
|
||||
<StyledAppHeaderActions>
|
||||
{creatableCollections.length > 0 && (
|
||||
<div key="quick-create">
|
||||
<Button
|
||||
id="quick-create-button"
|
||||
aria-controls={open ? 'quick-create-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('app.header.quickAdd')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="quick-create-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'quick-create-button',
|
||||
}}
|
||||
>
|
||||
{creatableCollections.map(collection => (
|
||||
<MenuItem
|
||||
key={collection.name}
|
||||
onClick={() => navigate(getNewEntryUrl(collection.name))}
|
||||
>
|
||||
{collection.label_singular || collection.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{isTestRepo && (
|
||||
<Button
|
||||
href="https://staticcms.org/docs/test-backend"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
Test Backend
|
||||
</Button>
|
||||
)}
|
||||
{displayUrl ? (
|
||||
<Button
|
||||
href={displayUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
{stripProtocol(displayUrl)}
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
isTestRepo={isTestRepo}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={logoutUser}
|
||||
/>
|
||||
</StyledAppHeaderActions>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, mediaLibrary } = state;
|
||||
const user = auth.user;
|
||||
const showMediaButton = mediaLibrary.showMediaButton;
|
||||
return {
|
||||
user,
|
||||
collections,
|
||||
displayUrl: config.config?.display_url,
|
||||
isTestRepo: config.config?.backend.name === 'test-repo',
|
||||
showMediaButton,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus: checkBackendStatusAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
logoutUser: logoutUserAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type HeaderProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Header) as ComponentType<HeaderProps>);
|
@ -1,49 +0,0 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import Header from './Header';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const StyledMainContainerWrapper = styled('div')`
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const StyledMainContainer = styled('div')`
|
||||
min-width: 1152px;
|
||||
max-width: 1392px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface MainViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MainView = ({ children }: MainViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<StyledMainContainerWrapper>
|
||||
<StyledMainContainer>{children}</StyledMainContainer>
|
||||
</StyledMainContainerWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainView;
|
@ -1,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;
|
@ -1,78 +0,0 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import React, { useCallback } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { components } from '@staticcms/core/components/UI/styles';
|
||||
|
||||
import type { Collection, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const CollectionTopRow = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled('p')`
|
||||
${components.cardTopDescription};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
function getCollectionProps(collection: Collection) {
|
||||
const collectionLabel = collection.label;
|
||||
const collectionLabelSingular = collection.label_singular;
|
||||
const collectionDescription = collection.description;
|
||||
|
||||
return {
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionTopProps {
|
||||
collection: Collection;
|
||||
newEntryUrl?: string;
|
||||
}
|
||||
|
||||
const CollectionTop = ({ collection, newEntryUrl, t }: TranslatedProps<CollectionTopProps>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { collectionLabel, collectionLabelSingular, collectionDescription } =
|
||||
getCollectionProps(collection);
|
||||
|
||||
const onNewClick = useCallback(() => {
|
||||
if (newEntryUrl) {
|
||||
navigate(newEntryUrl);
|
||||
}
|
||||
}, [navigate, newEntryUrl]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{newEntryUrl ? (
|
||||
<Button onClick={onNewClick} variant="contained">
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionTop);
|
@ -1,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);
|
@ -1,86 +0,0 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
interface FilterControlProps {
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (viewFilter: ViewFilter) => void;
|
||||
}
|
||||
|
||||
const FilterControl = ({
|
||||
viewFilters,
|
||||
t,
|
||||
onFilterClick,
|
||||
filter,
|
||||
}: TranslatedProps<FilterControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={anyActive ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.filterBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewFilters.map(viewFilter => {
|
||||
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
|
||||
const labelId = `filter-list-label-${viewFilter.label}`;
|
||||
return (
|
||||
<MenuItem
|
||||
key={viewFilter.id}
|
||||
onClick={() => onFilterClick(viewFilter)}
|
||||
sx={{ height: '36px' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={checked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={viewFilter.label} />
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(FilterControl);
|
@ -1,80 +0,0 @@
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface GroupControlProps {
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (viewGroup: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const GroupControl = ({
|
||||
viewGroups,
|
||||
group,
|
||||
t,
|
||||
onGroupClick,
|
||||
}: TranslatedProps<GroupControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={activeGroup ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.groupBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewGroups.map(viewGroup => (
|
||||
<MenuItem key={viewGroup.id} onClick={() => onGroupClick(viewGroup)}>
|
||||
<ListItemText>{viewGroup.label}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{viewGroup.id === activeGroup?.id ? <CheckIcon fontSize="small" /> : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(GroupControl);
|
@ -1,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);
|
@ -1,188 +0,0 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { colors } from '@staticcms/core/components/UI/styles';
|
||||
import { getAdditionalLinks, getIcon } from '@staticcms/core/lib/registry';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import CollectionSearch from './CollectionSearch';
|
||||
import NestedCollection from './NestedCollection';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const StyledSidebar = styled('div')`
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
const StyledListItemIcon = styled(ListItemIcon)`
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collections;
|
||||
collection: Collection;
|
||||
isSearchEnabled: boolean;
|
||||
searchTerm: string;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
const Sidebar = ({
|
||||
collections,
|
||||
collection,
|
||||
isSearchEnabled,
|
||||
searchTerm,
|
||||
t,
|
||||
filterTerm,
|
||||
}: TranslatedProps<SidebarProps>) => {
|
||||
const navigate = useNavigate();
|
||||
function searchCollections(query: string, collection?: string) {
|
||||
if (collection) {
|
||||
navigate(`/collections/${collection}/search/${query}`);
|
||||
} else {
|
||||
navigate(`/search/${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
const collectionLinks = useMemo(
|
||||
() =>
|
||||
Object.values(collections)
|
||||
.filter(collection => collection.hide !== true)
|
||||
.map(collection => {
|
||||
const collectionName = collection.name;
|
||||
const iconName = collection.icon;
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const StoredIcon = getIcon(iconName);
|
||||
if (StoredIcon) {
|
||||
icon = <StoredIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
if ('nested' in collection) {
|
||||
return (
|
||||
<li key={`nested-${collectionName}`}>
|
||||
<NestedCollection
|
||||
collection={collection}
|
||||
filterTerm={filterTerm}
|
||||
data-testid={collectionName}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={collectionName}
|
||||
to={`/collections/${collectionName}`}
|
||||
component={NavLink}
|
||||
disablePadding
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<ListItemButton>
|
||||
<StyledListItemIcon>{icon}</StyledListItemIcon>
|
||||
<ListItemText primary={collection.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}),
|
||||
[collections, filterTerm],
|
||||
);
|
||||
|
||||
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
|
||||
const links = useMemo(
|
||||
() =>
|
||||
Object.values(additionalLinks).map(
|
||||
({ id, title, data, options: { icon: iconName } = {} }) => {
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const StoredIcon = getIcon(iconName);
|
||||
if (StoredIcon) {
|
||||
icon = <StoredIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StyledListItemIcon>{icon}</StyledListItemIcon>
|
||||
<ListItemText primary={title} />
|
||||
</>
|
||||
);
|
||||
|
||||
return typeof data === 'string' ? (
|
||||
<ListItem
|
||||
key={title}
|
||||
href={data}
|
||||
component="a"
|
||||
disablePadding
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
color: colors.inactive,
|
||||
'&:hover': {
|
||||
color: colors.active,
|
||||
'.MuiListItemIcon-root': {
|
||||
color: colors.active,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemButton>{content}</ListItemButton>
|
||||
</ListItem>
|
||||
) : (
|
||||
<ListItem
|
||||
key={title}
|
||||
to={`/page/${id}`}
|
||||
component={NavLink}
|
||||
disablePadding
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<ListItemButton>{content}</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
},
|
||||
),
|
||||
[additionalLinks],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSidebar>
|
||||
<Card sx={{ minWidth: 275 }}>
|
||||
<CardContent sx={{ paddingBottom: 0 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{t('collection.sidebar.collections')}
|
||||
</Typography>
|
||||
{isSearchEnabled && (
|
||||
<CollectionSearch
|
||||
searchTerm={searchTerm}
|
||||
collections={collections}
|
||||
collection={collection}
|
||||
onSubmit={(query: string, collection?: string) =>
|
||||
searchCollections(query, collection)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<List>
|
||||
{collectionLinks}
|
||||
{links}
|
||||
</List>
|
||||
</Card>
|
||||
</StyledSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Sidebar);
|
@ -1,122 +0,0 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Button from '@mui/material/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import {
|
||||
SORT_DIRECTION_ASCENDING,
|
||||
SORT_DIRECTION_DESCENDING,
|
||||
SORT_DIRECTION_NONE,
|
||||
} from '@staticcms/core/constants';
|
||||
|
||||
import type {
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
function nextSortDirection(direction: SortDirection) {
|
||||
switch (direction) {
|
||||
case SORT_DIRECTION_ASCENDING:
|
||||
return SORT_DIRECTION_DESCENDING;
|
||||
case SORT_DIRECTION_DESCENDING:
|
||||
return SORT_DIRECTION_NONE;
|
||||
default:
|
||||
return SORT_DIRECTION_ASCENDING;
|
||||
}
|
||||
}
|
||||
|
||||
interface SortControlProps {
|
||||
fields: SortableField[];
|
||||
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort: SortMap | undefined;
|
||||
}
|
||||
|
||||
const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const selectedSort = useMemo(() => {
|
||||
if (!sort) {
|
||||
return { key: undefined, direction: undefined };
|
||||
}
|
||||
|
||||
const sortValues = Object.values(sort);
|
||||
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SORT_DIRECTION_NONE) {
|
||||
return { key: undefined, direction: undefined };
|
||||
}
|
||||
|
||||
return sortValues[0];
|
||||
}, [sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="sort-button"
|
||||
aria-controls={open ? 'sort-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={selectedSort.key ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.sortBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="sort-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'sort-button',
|
||||
}}
|
||||
>
|
||||
{fields.map(field => {
|
||||
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
|
||||
const nextSortDir = nextSortDirection(sortDir);
|
||||
return (
|
||||
<MenuItem
|
||||
key={field.name}
|
||||
onClick={() => onSortClick(field.name, nextSortDir)}
|
||||
selected={field.name === selectedSort.key}
|
||||
>
|
||||
<ListItemText>{field.label ?? field.name}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{field.name === selectedSort.key ? (
|
||||
selectedSort.direction === SORT_DIRECTION_ASCENDING ? (
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
)
|
||||
) : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(SortControl);
|
@ -1,44 +0,0 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import GridViewSharpIcon from '@mui/icons-material/GridViewSharp';
|
||||
import ReorderSharpIcon from '@mui/icons-material/ReorderSharp';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import React from 'react';
|
||||
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
|
||||
|
||||
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
|
||||
|
||||
const ViewControlsSection = styled('div')`
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface ViewStyleControlPros {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
}
|
||||
|
||||
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
|
||||
return (
|
||||
<ViewControlsSection>
|
||||
<IconButton
|
||||
color={viewStyle === VIEW_STYLE_LIST ? 'primary' : 'default'}
|
||||
aria-label="list view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<ReorderSharpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color={viewStyle === VIEW_STYLE_GRID ? 'primary' : 'default'}
|
||||
aria-label="grid view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<GridViewSharpIcon />
|
||||
</IconButton>
|
||||
</ViewControlsSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewStyleControl;
|
@ -1,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>);
|
@ -1,243 +0,0 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import get from 'lodash/get';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeDraftField as changeDraftFieldAction } from '@staticcms/core/actions/entries';
|
||||
import {
|
||||
getI18nInfo,
|
||||
getLocaleDataPath,
|
||||
hasI18n,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isFieldTranslatable,
|
||||
} from '@staticcms/core/lib/i18n';
|
||||
import EditorControl from './EditorControl';
|
||||
|
||||
import type { ButtonProps } from '@mui/material/Button';
|
||||
import type {
|
||||
Collection,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
I18nSettings,
|
||||
TranslatedProps,
|
||||
ValueOrNestedValue,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { RootState } from '@staticcms/core/store';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
|
||||
const ControlPaneContainer = styled('div')`
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const LocaleRowWrapper = styled('div')`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const DefaultLocaleWritingIn = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36.5px;
|
||||
`;
|
||||
|
||||
interface LocaleDropdownProps {
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
dropdownText: string;
|
||||
color: ButtonProps['color'];
|
||||
canChangeLocale: boolean;
|
||||
onLocaleChange?: (locale: string) => void;
|
||||
}
|
||||
|
||||
const LocaleDropdown = ({
|
||||
locales,
|
||||
defaultLocale,
|
||||
dropdownText,
|
||||
color,
|
||||
canChangeLocale,
|
||||
onLocaleChange,
|
||||
}: LocaleDropdownProps) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleLocaleChange = useCallback(
|
||||
(locale: string) => {
|
||||
onLocaleChange?.(locale);
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, onLocaleChange],
|
||||
);
|
||||
|
||||
if (!canChangeLocale) {
|
||||
return <DefaultLocaleWritingIn>{dropdownText}</DefaultLocaleWritingIn>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
color={color}
|
||||
>
|
||||
{dropdownText}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{locales
|
||||
.filter(locale => locale !== defaultLocale)
|
||||
.map(locale => (
|
||||
<MenuItem
|
||||
key={locale}
|
||||
onClick={() => handleLocaleChange(locale)}
|
||||
sx={{ minWidth: '80px' }}
|
||||
>
|
||||
{locale}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getFieldValue(
|
||||
field: Field,
|
||||
entry: Entry,
|
||||
isTranslatable: boolean,
|
||||
locale: string | undefined,
|
||||
): ValueOrNestedValue {
|
||||
if (isTranslatable && locale) {
|
||||
const dataPath = getLocaleDataPath(locale);
|
||||
return get(entry, [...dataPath, field.name]);
|
||||
}
|
||||
|
||||
return entry.data?.[field.name];
|
||||
}
|
||||
|
||||
const EditorControlPane = ({
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
locale,
|
||||
canChangeLocale = false,
|
||||
onLocaleChange,
|
||||
t,
|
||||
}: TranslatedProps<EditorControlPaneProps>) => {
|
||||
const i18n = useMemo(() => {
|
||||
if (hasI18n(collection)) {
|
||||
const { locales, defaultLocale } = getI18nInfo(collection);
|
||||
return {
|
||||
currentLocale: locale ?? locales[0],
|
||||
locales,
|
||||
defaultLocale,
|
||||
} as I18nSettings;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [collection, locale]);
|
||||
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry || entry.partial === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlPaneContainer>
|
||||
{i18n?.locales && locale ? (
|
||||
<LocaleRowWrapper>
|
||||
<LocaleDropdown
|
||||
locales={i18n.locales}
|
||||
defaultLocale={i18n.defaultLocale}
|
||||
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
|
||||
locale: locale?.toUpperCase(),
|
||||
})}
|
||||
color="primary"
|
||||
canChangeLocale={canChangeLocale}
|
||||
onLocaleChange={onLocaleChange}
|
||||
/>
|
||||
</LocaleRowWrapper>
|
||||
) : null}
|
||||
{fields.map(field => {
|
||||
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
|
||||
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;
|
||||
|
||||
return (
|
||||
<EditorControl
|
||||
key={key}
|
||||
field={field}
|
||||
value={getFieldValue(field, entry, isTranslatable, locale)}
|
||||
fieldsErrors={fieldsErrors}
|
||||
submitted={submitted}
|
||||
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
|
||||
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
|
||||
locale={locale}
|
||||
parentPath=""
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditorControlPaneOwnProps {
|
||||
collection: Collection;
|
||||
entry: Entry;
|
||||
fields: Field[];
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
locale?: string;
|
||||
canChangeLocale?: boolean;
|
||||
onLocaleChange?: (locale: string) => void;
|
||||
}
|
||||
|
||||
function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) {
|
||||
return {
|
||||
...ownProps,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorControlPaneProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EditorControlPane);
|
@ -1,382 +0,0 @@
|
||||
import HeightIcon from '@mui/icons-material/Height';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import { colorsRaw, components, zIndex } from '@staticcms/core/components/UI/styles';
|
||||
import { transientOptions } from '@staticcms/core/lib';
|
||||
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n';
|
||||
import { getFileFromSlug } from '@staticcms/core/lib/util/collection.util';
|
||||
import EditorControlPane from './EditorControlPane/EditorControlPane';
|
||||
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
TranslatedProps,
|
||||
User,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
const I18N_VISIBLE = 'cms.i18n-visible';
|
||||
|
||||
const StyledSplitPane = styled('div')`
|
||||
display: grid;
|
||||
grid-template-columns: min(864px, 50%) auto;
|
||||
height: calc(100vh - 64px);
|
||||
`;
|
||||
|
||||
const NoPreviewContainer = styled('div')`
|
||||
${components.card};
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Editor = styled('div')`
|
||||
height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
background-color: ${colorsRaw.white};
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
interface PreviewPaneContainerProps {
|
||||
$blockEntry?: boolean;
|
||||
}
|
||||
|
||||
const PreviewPaneContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<PreviewPaneContainerProps>(
|
||||
({ $blockEntry }) => `
|
||||
height: 100%;
|
||||
pointer-events: ${$blockEntry ? 'none' : 'auto'};
|
||||
overflow-y: auto;
|
||||
`,
|
||||
);
|
||||
|
||||
interface ControlPaneContainerProps {
|
||||
$hidden?: boolean;
|
||||
}
|
||||
|
||||
const ControlPaneContainer = styled(
|
||||
PreviewPaneContainer,
|
||||
transientOptions,
|
||||
)<ControlPaneContainerProps>(
|
||||
({ $hidden = false }) => `
|
||||
padding: 24px 16px 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
display: ${$hidden ? 'none' : 'flex'};
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
`,
|
||||
);
|
||||
|
||||
const StyledViewControls = styled('div')`
|
||||
position: fixed;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
z-index: ${zIndex.zIndex299};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
interface EditorContentProps {
|
||||
i18nVisible: boolean;
|
||||
previewVisible: boolean;
|
||||
editor: JSX.Element;
|
||||
editorSideBySideLocale: JSX.Element;
|
||||
editorWithPreview: JSX.Element;
|
||||
}
|
||||
|
||||
const EditorContent = ({
|
||||
i18nVisible,
|
||||
previewVisible,
|
||||
editor,
|
||||
editorSideBySideLocale,
|
||||
editorWithPreview,
|
||||
}: EditorContentProps) => {
|
||||
if (i18nVisible) {
|
||||
return editorSideBySideLocale;
|
||||
} else if (previewVisible) {
|
||||
return editorWithPreview;
|
||||
} else {
|
||||
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
|
||||
}
|
||||
};
|
||||
|
||||
interface EditorInterfaceProps {
|
||||
draftKey: string;
|
||||
entry: Entry;
|
||||
collection: Collection;
|
||||
fields: Field[] | undefined;
|
||||
fieldsErrors: FieldsErrors;
|
||||
onPersist: (opts?: EditorPersistOptions) => void;
|
||||
onDelete: () => Promise<void>;
|
||||
onDuplicate: () => void;
|
||||
showDelete: boolean;
|
||||
user: User | undefined;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
isNewEntry: boolean;
|
||||
isModification: boolean;
|
||||
onLogoutClick: () => void;
|
||||
editorBackLink: string;
|
||||
toggleScroll: () => Promise<void>;
|
||||
scrollSyncEnabled: boolean;
|
||||
loadScroll: () => void;
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
const EditorInterface = ({
|
||||
collection,
|
||||
entry,
|
||||
fields = [],
|
||||
fieldsErrors,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onPersist,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
onLogoutClick,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
t,
|
||||
loadScroll,
|
||||
toggleScroll,
|
||||
submitted,
|
||||
}: TranslatedProps<EditorInterfaceProps>) => {
|
||||
const [previewVisible, setPreviewVisible] = useState(
|
||||
localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
|
||||
);
|
||||
const [i18nVisible, setI18nVisible] = useState(localStorage.getItem(I18N_VISIBLE) !== 'false');
|
||||
|
||||
useEffect(() => {
|
||||
loadScroll();
|
||||
}, [loadScroll]);
|
||||
|
||||
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
|
||||
const [selectedLocale, setSelectedLocale] = useState<string>(locales?.[1] ?? 'en');
|
||||
|
||||
const handleOnPersist = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
// await switchToDefaultLocale();
|
||||
onPersist({ createNew, duplicate });
|
||||
},
|
||||
[onPersist],
|
||||
);
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
const newPreviewVisible = !previewVisible;
|
||||
setPreviewVisible(newPreviewVisible);
|
||||
localStorage.setItem(PREVIEW_VISIBLE, `${newPreviewVisible}`);
|
||||
}, [previewVisible]);
|
||||
|
||||
const handleToggleScrollSync = useCallback(() => {
|
||||
toggleScroll();
|
||||
}, [toggleScroll]);
|
||||
|
||||
const handleToggleI18n = useCallback(() => {
|
||||
const newI18nVisible = !i18nVisible;
|
||||
setI18nVisible(newI18nVisible);
|
||||
localStorage.setItem(I18N_VISIBLE, `${newI18nVisible}`);
|
||||
}, [i18nVisible]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: string) => {
|
||||
setSelectedLocale(locale);
|
||||
}, []);
|
||||
|
||||
const [previewEnabled, previewInFrame] = useMemo(() => {
|
||||
let preview = collection.editor?.preview ?? true;
|
||||
let frame = collection.editor?.frame ?? true;
|
||||
|
||||
if ('files' in collection) {
|
||||
const file = getFileFromSlug(collection, entry.slug);
|
||||
if (file?.editor?.preview !== undefined) {
|
||||
preview = file.editor.preview;
|
||||
}
|
||||
|
||||
if (file?.editor?.frame !== undefined) {
|
||||
frame = file.editor.frame;
|
||||
}
|
||||
}
|
||||
|
||||
return [preview, frame];
|
||||
}, [collection, entry.slug]);
|
||||
|
||||
const collectionI18nEnabled = hasI18n(collection);
|
||||
|
||||
const editor = (
|
||||
<ControlPaneContainer key={defaultLocale} id="control-pane">
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={defaultLocale}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const editorLocale = useMemo(
|
||||
() =>
|
||||
(locales ?? [])
|
||||
.filter(locale => locale !== defaultLocale)
|
||||
.map(locale => (
|
||||
<ControlPaneContainer key={locale} $hidden={locale !== selectedLocale}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={locale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
canChangeLocale
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
)),
|
||||
[
|
||||
collection,
|
||||
defaultLocale,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
handleLocaleChange,
|
||||
locales,
|
||||
selectedLocale,
|
||||
submitted,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const previewEntry = collectionI18nEnabled
|
||||
? getPreviewEntry(entry, selectedLocale[0], defaultLocale)
|
||||
: entry;
|
||||
|
||||
const editorWithPreview = (
|
||||
<>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<PreviewPaneContainer>
|
||||
<EditorPreviewPane
|
||||
collection={collection}
|
||||
previewInFrame={previewInFrame}
|
||||
entry={previewEntry}
|
||||
fields={fields}
|
||||
/>
|
||||
</PreviewPaneContainer>
|
||||
</StyledSplitPane>
|
||||
</>
|
||||
);
|
||||
|
||||
const editorSideBySideLocale = (
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<div>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>
|
||||
<>{editorLocale}</>
|
||||
</ScrollSyncPane>
|
||||
</StyledSplitPane>
|
||||
</div>
|
||||
</ScrollSync>
|
||||
);
|
||||
|
||||
const finalI18nVisible = collectionI18nEnabled && i18nVisible;
|
||||
const finalPreviewVisible = previewEnabled && previewVisible;
|
||||
const scrollSyncVisible = finalI18nVisible || finalPreviewVisible;
|
||||
|
||||
return (
|
||||
<EditorContainer>
|
||||
<EditorToolbar
|
||||
isPersisting={entry.isPersisting}
|
||||
isDeleting={entry.isDeleting}
|
||||
onPersist={handleOnPersist}
|
||||
onPersistAndNew={() => handleOnPersist({ createNew: true })}
|
||||
onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })}
|
||||
onDelete={onDelete}
|
||||
showDelete={showDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
onLogoutClick={onLogoutClick}
|
||||
editorBackLink={editorBackLink}
|
||||
/>
|
||||
<Editor key={draftKey}>
|
||||
<StyledViewControls>
|
||||
{collectionI18nEnabled && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={finalI18nVisible ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleToggleI18n}
|
||||
title={t('editor.editorInterface.toggleI18n')}
|
||||
>
|
||||
<LanguageIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{previewEnabled && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={finalPreviewVisible ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleTogglePreview}
|
||||
title={t('editor.editorInterface.togglePreview')}
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{scrollSyncVisible && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={scrollSyncEnabled ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleToggleScrollSync}
|
||||
title={t('editor.editorInterface.toggleScrollSync')}
|
||||
>
|
||||
<HeightIcon />
|
||||
</Fab>
|
||||
)}
|
||||
</StyledViewControls>
|
||||
<EditorContent
|
||||
i18nVisible={finalI18nVisible}
|
||||
previewVisible={finalPreviewVisible}
|
||||
editor={editor}
|
||||
editorSideBySideLocale={editorSideBySideLocale}
|
||||
editorWithPreview={editorWithPreview}
|
||||
/>
|
||||
</Editor>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
@ -1,44 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { FrameContextConsumer } from 'react-frame-component';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import EditorPreviewContent from './EditorPreviewContent';
|
||||
|
||||
import type {
|
||||
EntryData,
|
||||
TemplatePreviewComponent,
|
||||
TemplatePreviewProps,
|
||||
UnknownField,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { FC } from 'react';
|
||||
|
||||
interface PreviewFrameContentProps {
|
||||
previewComponent: TemplatePreviewComponent<EntryData, UnknownField>;
|
||||
previewProps: Omit<TemplatePreviewProps<EntryData, UnknownField>, 'document' | 'window'>;
|
||||
}
|
||||
|
||||
const PreviewFrameContent: FC<PreviewFrameContentProps> = ({ previewComponent, previewProps }) => {
|
||||
const ref = useRef<HTMLElement>();
|
||||
|
||||
return (
|
||||
<FrameContextConsumer>
|
||||
{context => {
|
||||
if (!ref.current) {
|
||||
ref.current = context.document?.scrollingElement as HTMLElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollSyncPane key="preview-frame-scroll-sync" attachTo={ref}>
|
||||
<EditorPreviewContent
|
||||
key="preview-frame-content"
|
||||
previewComponent={previewComponent}
|
||||
previewProps={{ ...previewProps, document: context.document, window: context.window }}
|
||||
/>
|
||||
</ScrollSyncPane>
|
||||
);
|
||||
}}
|
||||
</FrameContextConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewFrameContent;
|
@ -1,305 +0,0 @@
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { green } from '@mui/material/colors';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { colors, components, zIndex } from '@staticcms/core/components/UI/styles';
|
||||
import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util';
|
||||
import { SettingsDropdown } from '../UI';
|
||||
import NavLink from '../UI/NavLink';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
TranslatedProps,
|
||||
User,
|
||||
} from '@staticcms/core/interface';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
z-index: ${zIndex.zIndex100};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledToolbarSectionBackLink = styled('div')`
|
||||
display: flex;
|
||||
margin: -32px -24px;
|
||||
height: 64px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledToolbarSectionMain = styled('div')`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
margin-left: 24px;
|
||||
`;
|
||||
|
||||
const StyledBackCollection = styled('div')`
|
||||
color: ${colors.textLead};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StyledBackStatus = styled('div')`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const StyledBackStatusUnchanged = styled(StyledBackStatus)`
|
||||
${components.textBadgeSuccess};
|
||||
`;
|
||||
|
||||
const StyledBackStatusChanged = styled(StyledBackStatus)`
|
||||
${components.textBadgeDanger};
|
||||
`;
|
||||
|
||||
const StyledButtonWrapper = styled('div')`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export interface EditorToolbarProps {
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
onPersist: (opts?: EditorPersistOptions) => Promise<void>;
|
||||
onPersistAndNew: () => Promise<void>;
|
||||
onPersistAndDuplicate: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
showDelete: boolean;
|
||||
onDuplicate: () => void;
|
||||
user: User;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
collection: Collection;
|
||||
isNewEntry: boolean;
|
||||
isModification?: boolean;
|
||||
onLogoutClick: () => void;
|
||||
editorBackLink: string;
|
||||
}
|
||||
|
||||
const EditorToolbar = ({
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
collection,
|
||||
onLogoutClick,
|
||||
onDuplicate,
|
||||
isPersisting,
|
||||
onPersist,
|
||||
onPersistAndDuplicate,
|
||||
onPersistAndNew,
|
||||
isNewEntry,
|
||||
showDelete,
|
||||
onDelete,
|
||||
t,
|
||||
editorBackLink,
|
||||
}: TranslatedProps<EditorToolbarProps>) => {
|
||||
const canCreate = useMemo(
|
||||
() => ('folder' in collection && collection.create) ?? false,
|
||||
[collection],
|
||||
);
|
||||
const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]);
|
||||
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleMenuOptionClick = useCallback(
|
||||
(callback: () => Promise<void> | void) => () => {
|
||||
handleClose();
|
||||
callback();
|
||||
},
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
const handlePersistAndNew = useMemo(
|
||||
() => handleMenuOptionClick(onPersistAndNew),
|
||||
[handleMenuOptionClick, onPersistAndNew],
|
||||
);
|
||||
const handlePersistAndDuplicate = useMemo(
|
||||
() => handleMenuOptionClick(onPersistAndDuplicate),
|
||||
[handleMenuOptionClick, onPersistAndDuplicate],
|
||||
);
|
||||
const handleDuplicate = useMemo(
|
||||
() => handleMenuOptionClick(onDuplicate),
|
||||
[handleMenuOptionClick, onDuplicate],
|
||||
);
|
||||
const handlePersist = useMemo(
|
||||
() => handleMenuOptionClick(() => onPersist()),
|
||||
[handleMenuOptionClick, onPersist],
|
||||
);
|
||||
const handleDelete = useMemo(
|
||||
() => handleMenuOptionClick(onDelete),
|
||||
[handleMenuOptionClick, onDelete],
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
|
||||
if (!isPublished) {
|
||||
items.push(
|
||||
<MenuItem key="publishNow" onClick={handlePersist}>
|
||||
{t('editor.editorToolbar.publishNow')}
|
||||
</MenuItem>,
|
||||
);
|
||||
|
||||
if (canCreate) {
|
||||
items.push(
|
||||
<MenuItem key="publishAndCreateNew" onClick={handlePersistAndNew}>
|
||||
{t('editor.editorToolbar.publishAndCreateNew')}
|
||||
</MenuItem>,
|
||||
<MenuItem key="publishAndDuplicate" onClick={handlePersistAndDuplicate}>
|
||||
{t('editor.editorToolbar.publishAndDuplicate')}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (canCreate) {
|
||||
items.push(
|
||||
<MenuItem key="duplicate" onClick={handleDuplicate}>
|
||||
{t('editor.editorToolbar.duplicate')}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
canCreate,
|
||||
handleDuplicate,
|
||||
handlePersist,
|
||||
handlePersistAndDuplicate,
|
||||
handlePersistAndNew,
|
||||
isPublished,
|
||||
t,
|
||||
]);
|
||||
|
||||
const controls = useMemo(
|
||||
() => (
|
||||
<StyledToolbarSectionMain>
|
||||
<div>
|
||||
<StyledButtonWrapper>
|
||||
<Button
|
||||
id="existing-published-button"
|
||||
aria-controls={open ? 'existing-published-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
color={isPublished ? 'success' : 'primary'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
disabled={menuItems.length === 0 || isPersisting}
|
||||
>
|
||||
{isPublished
|
||||
? t('editor.editorToolbar.published')
|
||||
: isPersisting
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</Button>
|
||||
{isPersisting ? (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
sx={{
|
||||
color: green[500],
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: '-12px',
|
||||
marginLeft: '-12px',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</StyledButtonWrapper>
|
||||
<Menu
|
||||
id="existing-published-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'existing-published-button',
|
||||
}}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</div>
|
||||
{showDelete && canDelete ? (
|
||||
<Button variant="outlined" color="error" key="delete-button" onClick={handleDelete}>
|
||||
{t('editor.editorToolbar.deleteEntry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</StyledToolbarSectionMain>
|
||||
),
|
||||
[
|
||||
anchorEl,
|
||||
canDelete,
|
||||
handleClick,
|
||||
handleClose,
|
||||
handleDelete,
|
||||
isPersisting,
|
||||
isPublished,
|
||||
menuItems,
|
||||
open,
|
||||
showDelete,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="relative">
|
||||
<StyledToolbar>
|
||||
<StyledToolbarSectionBackLink>
|
||||
<Button component={NavLink} to={editorBackLink}>
|
||||
<ArrowBackIcon />
|
||||
<div>
|
||||
<StyledBackCollection>
|
||||
{t('editor.editorToolbar.backCollection', {
|
||||
collectionLabel: collection.label,
|
||||
})}
|
||||
</StyledBackCollection>
|
||||
{hasChanged ? (
|
||||
<StyledBackStatusChanged key="back-changed">
|
||||
{t('editor.editorToolbar.unsavedChanges')}
|
||||
</StyledBackStatusChanged>
|
||||
) : (
|
||||
<StyledBackStatusUnchanged key="back-unchanged">
|
||||
{t('editor.editorToolbar.changesSaved')}
|
||||
</StyledBackStatusUnchanged>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</StyledToolbarSectionBackLink>
|
||||
{controls}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(EditorToolbar);
|