Feature/website overhaul (#49)

* Reorganize repo
* Overhaul website design and rewrite in NextJS and Typescript
* Delete website-publish.yml
This commit is contained in:
Daniel Lautzenheiser
2022-10-25 09:18:18 -04:00
committed by GitHub
parent 3674ee5bd8
commit 421ecf17e6
629 changed files with 6917 additions and 17824 deletions

19
core/.editorconfig Normal file
View File

@ -0,0 +1,19 @@
# https://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.js]
quote_type = single
spaces_around_operators = true
[*.css]
quote_type = single
[*.md]
trim_trailing_whitespace = false

3
core/.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
dev-test

115
core/.eslintrc.js Normal file
View File

@ -0,0 +1,115 @@
module.exports = {
parser: 'babel-eslint',
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:cypress/recommended',
'prettier',
'plugin:import/recommended',
],
env: {
es6: true,
browser: true,
node: true,
jest: true,
'cypress/globals': true,
},
globals: {
STATIC_CMS_CORE_VERSION: false,
CMS_ENV: false,
},
rules: {
'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks
'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies
'no-console': [0],
'react/prop-types': [0],
'react/require-default-props': 0,
'import/no-named-as-default': 0,
'react/react-in-jsx-scope': 'off',
'import/order': [
'error',
{
'newlines-between': 'always',
groups: [['builtin', 'external'], ['internal', 'parent', 'sibling', 'index'], ['type']],
},
],
'no-duplicate-imports': 'error',
'@emotion/no-vanilla': 'off',
'@emotion/import-from-emotion': 'error',
'@emotion/styled-import': 'error',
'require-atomic-updates': [0],
'object-shorthand': ['error', 'always'],
'prefer-const': [
'error',
{
destructuring: 'all',
},
],
'unicorn/prefer-string-slice': 'error',
'react/no-unknown-property': ['error', { ignore: ['css'] }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
'import/core-modules': ['src'],
},
overrides: [
{
files: ['*.ts', '*.tsx'],
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:cypress/recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:import/recommended',
'plugin:import/typescript',
],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': [0],
'react/require-default-props': 0,
'no-duplicate-imports': [0], // handled by @typescript-eslint
'@typescript-eslint/ban-types': [0], // TODO enable in future
'@typescript-eslint/no-non-null-assertion': [0],
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': [0],
'@typescript-eslint/explicit-module-boundary-types': [0],
'@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/no-use-before-define': [
'error',
{ functions: false, classes: true, variables: true },
],
},
},
{
files: ['website/**/*'],
rules: {
'import/no-unresolved': [0],
},
},
],
};

4
core/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
dist/
bin/
public/
.cache/

6
core/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"arrowParens": "avoid",
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100
}

96
core/babel.config.js Normal file
View File

@ -0,0 +1,96 @@
const path = require('path');
const coreVersion = require('./package.json').version;
const isProduction = process.env.NODE_ENV === 'production';
const isTest = process.env.NODE_ENV === 'test';
const isESM = process.env.NODE_ENV === 'esm';
console.info('Build Package:', path.basename(process.cwd()));
const defaultPlugins = [
'lodash',
[
'babel-plugin-transform-builtin-extend',
{
globals: ['Error'],
},
],
'transform-export-extensions',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-nullish-coalescing-operator',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-syntax-dynamic-import',
'babel-plugin-inline-json-import',
];
const svgo = {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
};
function presets() {
return [
'@babel/preset-react',
'@babel/preset-env',
[
'@emotion/babel-preset-css-prop',
{
autoLabel: 'always',
},
],
'@babel/typescript',
];
}
function plugins() {
if (isESM) {
return [
...defaultPlugins,
[
'transform-define',
{
STATIC_CMS_CORE_VERSION: `${coreVersion}`,
},
],
[
'inline-react-svg',
{
svgo,
},
],
];
}
if (isTest) {
return [
...defaultPlugins,
[
'inline-react-svg',
{
svgo,
},
],
];
}
if (!isProduction) {
return [...defaultPlugins];
}
return defaultPlugins;
}
module.exports = {
presets: presets(),
plugins: plugins(),
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

View File

@ -0,0 +1,471 @@
backend:
name: azure
branch: master
repo: organization/project/repo # replace with actual path
tenant_id: tenantId # replace with your tenantId
app_id: appId # replace with your appId
media_folder: static/media
public_folder: /media
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: '{{fields.post | split(''|'', ''$1'')}}'
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS - Azure Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,469 @@
backend:
name: bitbucket
branch: master
repo: owner/repo
media_folder: static/media
public_folder: /media
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: '{{fields.post | split(''|'', ''$1'')}}'
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS - Bitbucket Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,468 @@
backend:
name: git-gateway
branch: master
media_folder: static/media
public_folder: /media
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: '{{fields.post | split(''|'', ''$1'')}}'
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS - Git Gateway Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,469 @@
backend:
name: github
branch: master
repo: owner/repo
media_folder: static/media
public_folder: /media
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: '{{fields.post | split(''|'', ''$1'')}}'
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS - GitHub Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,469 @@
backend:
name: gitlab
branch: master
repo: owner/repo
media_folder: static/media
public_folder: /media
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: '{{fields.post | split(''|'', ''$1'')}}'
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS - GitLab Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,472 @@
backend:
name: github
branch: main
repo: owner/repo
media_folder: static/media
public_folder: /media
local_backend: true
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: '{{fields.post | split(''|'', ''$1'')}}'
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS - Proxy Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

868
core/dev-test/config.yml Normal file
View File

@ -0,0 +1,868 @@
backend:
name: test-repo
site_url: 'https://example.com'
media_folder: assets/uploads
collections:
- name: posts
label: Posts
label_singular: Post
description: >
The description is a great place for tone setting, high level information,
and editing guidelines that are specific to a collection.
folder: _posts
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
sortable_fields:
fields:
- title
- date
default:
field: title
create: true
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
default: false
- label: Publish Date
name: date
widget: datetime
date_format: yyyy-MM-dd
time_format: 'HH:mm'
format: 'yyyy-MM-dd HH:mm'
- label: Cover Image
name: image
widget: image
required: false
- label: Body
name: body
widget: markdown
hint: Main content goes here.
- name: faq
label: FAQ
folder: _faqs
create: true
fields:
- label: Question
name: title
widget: string
- label: Answer
name: body
widget: markdown
- name: posts
label: Posts
label_singular: Post
widget: list
summary: "{{fields.post | split('|', '$1')}}"
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: '{{title}}|{{date}}'
- name: widgets
label: Widgets
delete: false
editor:
preview: false
files:
- name: boolean
label: Boolean
file: _widgets/boolean.json
description: Boolean widget
fields:
- name: required
label: 'Required Validation'
widget: boolean
- name: pattern
label: 'Pattern Validation'
widget: boolean
pattern: ['true', 'Must be true']
required: false
- name: code
label: Code
file: _widgets/code.json
description: Code widget
fields:
- name: required
label: 'Required Validation'
widget: code
- name: pattern
label: 'Pattern Validation'
widget: code
pattern: ['.{12,}', 'Must have at least 12 characters']
allow_input: true
required: false
- name: language
label: 'Language Selection'
widget: code
allow_language_selection: true
required: false
- name: color
label: Color
file: _widgets/color.json
description: Color widget
fields:
- name: required
label: 'Required Validation'
widget: color
- name: pattern
label: 'Pattern Validation'
widget: color
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code']
allow_input: true
required: false
- name: alpha
label: Alpha
widget: color
enable_alpha: true
required: false
- name: datetime
label: DateTime
file: _widgets/datetime.json
description: DateTime widget
fields:
- name: required
label: 'Required Validation'
widget: datetime
- name: pattern
label: 'Pattern Validation'
widget: datetime
format: 'MMM d, yyyy h:mm aaa'
date_format: 'MMM d, yyyy'
time_format: 'h:mm aaa'
pattern: ['pm', 'Must be in the afternoon']
required: false
- name: date_and_time
label: Date and Time
widget: datetime
format: 'MMM d, yyyy h:mm aaa'
date_format: 'MMM d, yyyy'
time_format: 'h:mm aaa'
required: false
- name: date
label: Date
widget: datetime
format: 'MMM d, yyyy'
date_format: 'MMM d, yyyy'
required: false
- name: time
label: Time
widget: datetime
format: 'h:mm aaa'
time_format: 'h:mm aaa'
required: false
- name: file
label: File
file: _widgets/file.json
description: File widget
fields:
- name: required
label: 'Required Validation'
widget: file
- name: pattern
label: 'Pattern Validation'
widget: file
pattern: ['\.pdf', 'Must be a pdf']
required: false
- name: choose_url
label: 'Choose URL'
widget: file
required: false
media_library:
choose_url: true
- name: image
label: Image
file: _widgets/image.json
description: Image widget
fields:
- name: required
label: 'Required Validation'
widget: image
- name: pattern
label: 'Pattern Validation'
widget: image
pattern: ['\.png', 'Must be a png']
required: false
- name: choose_url
label: 'Choose URL'
widget: image
required: false
media_library:
choose_url: true
- name: list
label: List
file: _widgets/list.yml
description: List widget
fields:
- name: list
label: List
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: typed_list
label: Typed List
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- name: map
label: Map
file: _widgets/map.json
description: Map widget
fields:
- name: required
label: 'Required Validation'
widget: map
- name: pattern
label: 'Pattern Validation'
widget: map
pattern: ['\[-([7-9][0-9]|1[0-2][0-9])\.', 'Must be between latitude -70 and -129']
required: false
- name: markdown
label: Markdown
file: _widgets/markdown.json
description: Markdown widget
fields:
- name: required
label: 'Required Validation'
widget: markdown
- name: pattern
label: 'Pattern Validation'
widget: markdown
pattern: ['# [a-zA-Z0-9]+', 'Must have a header']
required: false
- name: number
label: Number
file: _widgets/number.json
description: Number widget
fields:
- name: required
label: 'Required Validation'
widget: number
- name: min
label: 'Min Validation'
widget: number
min: 5
required: false
- name: max
label: 'Max Validation'
widget: number
max: 10
required: false
- name: min_and_max
label: 'Min and Max Validation'
widget: number
min: 5
max: 10
required: false
- name: pattern
label: 'Pattern Validation'
widget: number
pattern: ['[0-9]{3,}', 'Must be at least 3 digits']
required: false
- name: object
label: Object
file: _widgets/object.json
description: Object widget
fields:
- label: Required Validation
name: required
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
- label: Optional Validation
name: optional
widget: object
required: false
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
required: false
- label: Default Author
name: author
widget: string
required: false
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: relation
label: Relation
file: _widgets/relation.json
description: Relation widget
fields:
- label: Required Validation
name: required
widget: relation
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Optional Validation
name: optional
widget: relation
required: false
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Multiple
name: multiple
widget: relation
multiple: true
required: false
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- name: select
label: Select
file: _widgets/select.json
description: Select widget
fields:
- label: Required Validation
name: required
widget: select
options:
- a
- b
- c
- label: Pattern Validation
name: pattern
widget: select
options:
- a
- b
- c
pattern: ['[a-b]', 'Must be a or b']
required: false
- label: Value and Label
name: value_and_label
widget: select
options:
- value: a
label: A fancy label
- value: b
label: Another fancy label
- value: c
label: And one more fancy label
- label: Multiple
name: multiple
widget: select
options:
- a
- b
- c
pattern: ['[a-b]', 'Must be a or b']
multiple: true
required: false
- label: Value and Label Multiple
name: value_and_label_multiple
widget: select
multiple: true
options:
- value: a
label: A fancy label
- value: b
label: Another fancy label
- value: c
label: And one more fancy label
- name: string
label: String
file: _widgets/string.json
description: String widget
fields:
- name: required
label: 'Required Validation'
widget: string
- name: pattern
label: 'Pattern Validation'
widget: string
pattern: ['.{12,}', 'Must have at least 12 characters']
required: false
- name: text
label: Text
file: _widgets/text.json
description: Text widget
fields:
- name: required
label: 'Required Validation'
widget: text
- name: pattern
label: 'Pattern Validation'
widget: text
pattern: ['.{12,}', 'Must have at least 12 characters']
required: false
- name: settings
label: Settings
delete: false
editor:
preview: false
files:
- name: general
label: Site Settings
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
- label: Post Settings
name: posts
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Default Author
name: author
widget: string
- label: Default Thumbnail
name: thumb
widget: image
required: false
- name: authors
label: Authors
file: _data/authors.yml
description: Author descriptions
fields:
- name: authors
label: Authors
label_singular: Author
widget: list
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: kitchenSink
label: Kitchen Sink
folder: _sink
create: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- label: Title
name: title
widget: string
- label: Boolean
name: boolean
widget: boolean
default: true
- label: Map
name: map
widget: map
- label: Text
name: text
widget: text
hint: 'Plain text, not markdown'
- label: Number
name: number
widget: number
hint: To infinity and beyond!
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Color
name: color
widget: color
- label: Color string editable and alpha enabled
name: colorEditable
widget: color
enable_alpha: true
allow_input: true
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Select multiple
name: select_multiple
widget: select
options:
- a
- b
- c
multiple: true
- label: Select numeric
name: select_numeric
widget: select
options:
- label: One
value: 1
- label: Two
value: 2
- label: Three
value: 3
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
collapsed: true
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
default: false
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: markdown
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: List
name: list
widget: list
fields:
- label: Related Post
name: post
widget: relationKitchenSinkPost
collection: posts
search_fields:
- title
- body
value_field: title
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Hidden
name: hidden
widget: hidden
default: hidden
- label: Object
name: object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Number
name: number
widget: number
- label: Markdown
name: markdown
widget: text
- label: Datetime
name: datetime
widget: datetime
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Typed List
name: typed_list
widget: list
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: text
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file

21
core/dev-test/example.css Normal file
View File

@ -0,0 +1,21 @@
html,
body {
color: #444;
font-size: 14px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
padding: 20px;
}
h1 {
margin-top: 20px;
color: #666;
font-weight: bold;
font-size: 32px;
}
img {
max-width: 100%;
}

184
core/dev-test/index.html Normal file

File diff suppressed because one or more lines are too long

107
core/dev-test/index.js Normal file
View File

@ -0,0 +1,107 @@
// Register all the things
window.CMS.init();
const PostPreview = window.createClass({
render: function () {
var entry = this.props.entry;
return window.h(
'div',
{},
window.h(
'div',
{ className: 'cover' },
window.h('h1', {}, entry.data.title),
this.props.widgetFor('image'),
),
window.h('p', {}, window.h('small', {}, 'Written ' + entry.data.date)),
window.h('div', { className: 'text' }, this.props.widgetFor('body')),
);
},
});
const GeneralPreview = window.createClass({
render: function () {
const entry = this.props.entry;
const title = entry.data.site_title;
const posts = entry.data.posts;
const thumb = posts && posts.thumb;
return window.h(
'div',
{},
window.h('h1', {}, title),
window.h(
'dl',
{},
window.h('dt', {}, 'Posts on Frontpage'),
window.h('dd', {}, this.props.widgetsFor('posts').widgets.front_limit || 0),
window.h('dt', {}, 'Default Author'),
window.h('dd', {}, this.props.widgetsFor('posts').data.author || 'None'),
window.h('dt', {}, 'Default Thumbnail'),
window.h(
'dd',
{},
thumb && window.h('img', { src: this.props.getAsset(thumb).toString() }),
),
),
);
},
});
const AuthorsPreview = window.createClass({
render: function () {
return window.h(
'div',
{},
window.h('h1', {}, 'Authors'),
this.props.widgetsFor('authors').map(function (author, index) {
return window.h(
'div',
{ key: index },
window.h('hr', {}),
window.h('strong', {}, author.data.name),
author.widgets.description,
);
}),
);
},
});
const RelationKitchenSinkPostPreview = window.createClass({
render: function () {
// When a post is selected from the relation field, all of it's data
// will be available in the field's metadata nested under the collection
// name, and then further nested under the value specified in `value_field`.
// In this case, the post would be nested under "posts" and then under
// the title of the selected post, since our `value_field` in the config
// is "title".
const { value, fieldsMetaData } = this.props;
const post = fieldsMetaData && fieldsMetaData.posts.value;
const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' };
return post
? window.h(
'div',
{ style: style },
window.h('h2', {}, 'Related Post'),
window.h('h3', {}, post.title),
window.h('img', { src: post.image }),
window.h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
)
: null;
},
});
window.CMS.registerPreviewTemplate('posts', PostPreview);
window.CMS.registerPreviewTemplate('general', GeneralPreview);
window.CMS.registerPreviewTemplate('authors', AuthorsPreview);
// Pass the name of a registered control to reuse with a new widget preview.
window.CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);
window.CMS.registerAdditionalLink({
id: 'example',
title: 'Example.com',
data: 'https://example.com',
options: {
icon: 'page',
},
});

BIN
core/dev-test/moby-dick.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

BIN
core/dev-test/nf-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

241
core/package.json Normal file
View File

@ -0,0 +1,241 @@
{
"name": "@staticcms/core",
"version": "1.0.0-alpha1",
"license": "MIT",
"description": "Static CMS core application.",
"repository": "https://github.com/StaticJsCMS/static-cms",
"bugs": "https://github.com/StaticJsCMS/static-cms/issues",
"keywords": [
"simple",
"cms",
"core"
],
"scripts": {
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore **/__tests__ --extensions \".js,.jsx,.ts,.tsx\"",
"build:webpack": "webpack",
"build:types": "tsc",
"build": "cross-env NODE_ENV=production run-s build:esm build:webpack build:types",
"clean": "rimraf dist dev-test/dist",
"develop": "webpack serve",
"format:prettier": "prettier \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx,css}\"",
"format": "run-s \"lint:js --fix --quiet\" \"format:prettier --write\"",
"lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"",
"lint:css": "stylelint --ignore-path .gitignore \"{src/**/*.{css,js,jsx,ts,tsx},website/**/*.css}\"",
"lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different",
"lint:js": "eslint --color --ignore-path .gitignore \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "run-p -c --aggregate-output \"lint:*\"",
"prepublishOnly": "yarn build",
"start": "run-s clean develop",
"type-check": "tsc --watch"
},
"module": "dist/esm/index.js",
"main": "dist/static-cms-core.js",
"files": [
"src/",
"dist/",
"index.d.ts"
],
"types": "index.d.ts",
"browserslist": [
"last 2 Chrome versions",
"last 2 ChromeAndroid versions",
"last 2 Edge versions",
"last 2 Firefox versions",
"last 2 iOS versions",
"last 2 Opera versions",
"last 2 Safari versions"
],
"dependencies": {
"@emotion/babel-preset-css-prop": "11.10.0",
"@emotion/css": "11.10.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@iarna/toml": "2.2.5",
"@mui/icons-material": "5.10.6",
"@mui/material": "5.10.10",
"@mui/x-date-pickers": "5.0.4",
"@reduxjs/toolkit": "1.8.5",
"@toast-ui/react-editor": "3.2.2",
"ajv": "8.11.0",
"ajv-errors": "3.0.0",
"ajv-keywords": "5.1.0",
"array-move": "4.0.0",
"buffer": "6.0.3",
"clean-stack": "4.2.0",
"codemirror": "5.65.9",
"common-tags": "1.8.1",
"copy-text-to-clipboard": "3.0.1",
"create-react-class": "15.7.0",
"date-fns": "2.29.3",
"deepmerge": "4.2.2",
"diacritics": "1.3.0",
"dompurify": "2.4.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-babel": "5.3.1",
"fuzzy": "0.1.3",
"globby": "12.2.0",
"gotrue-js": "0.9.29",
"graphql": "15.8.0",
"graphql-tag": "2.12.6",
"gray-matter": "4.0.3",
"history": "4.10.1",
"immer": "9.0.15",
"ini": "2.0.0",
"is-hotkey": "0.2.0",
"js-base64": "3.7.2",
"js-sha256": "0.9.0",
"jwt-decode": "3.1.2",
"localforage": "1.10.0",
"lodash": "4.17.21",
"mdast-util-definitions": "1.2.5",
"mdast-util-to-string": "1.1.0",
"minimatch": "3.0.4",
"moment": "2.29.4",
"node-polyglot": "2.4.2",
"ol": "6.15.1",
"path-browserify": "1.0.1",
"react": "18.2.0",
"react-aria-menubutton": "7.0.3",
"react-codemirror2": "7.2.1",
"react-color": "2.19.3",
"react-datetime": "3.1.1",
"react-dnd": "14.0.5",
"react-dnd-html5-backend": "14.1.0",
"react-dom": "18.2.0",
"react-frame-component": "5.2.3",
"react-is": "18.2.0",
"react-polyglot": "0.7.2",
"react-redux": "8.0.4",
"react-router-dom": "6.4.1",
"react-scroll-sync": "0.9.0",
"react-sortable-hoc": "2.0.0",
"react-split-pane": "0.1.92",
"react-textarea-autosize": "8.3.4",
"react-toggled": "1.2.7",
"react-topbar-progress-indicator": "4.1.1",
"react-virtualized-auto-sizer": "1.0.7",
"react-waypoint": "10.3.0",
"react-window": "1.8.7",
"rehype-stringify": "9.0.3",
"remark-gfm": "3.0.1",
"remark-parse": "10.0.1",
"remark-rehype": "10.1.0",
"sanitize-filename": "1.6.3",
"semaphore": "1.1.0",
"stream-browserify": "3.0.0",
"tomlify-j0.4": "3.0.0",
"ts-loader": "9.4.1",
"unified": "10.1.2",
"uploadcare-widget": "3.19.0",
"uploadcare-widget-tab-effects": "1.5.0",
"url": "0.11.0",
"url-join": "4.0.1",
"uuid": "3.4.0",
"validate-color": "2.2.1",
"what-input": "5.2.12",
"what-the-diff": "0.6.0",
"yaml": "1.10.2"
},
"devDependencies": {
"@babel/cli": "7.18.10",
"@babel/core": "7.19.1",
"@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",
"@babel/plugin-proposal-numeric-separator": "7.18.6",
"@babel/plugin-proposal-object-rest-spread": "7.18.9",
"@babel/plugin-proposal-optional-chaining": "7.18.9",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.19.1",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.18.6",
"@emotion/eslint-plugin": "11.10.0",
"@octokit/rest": "16.43.2",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.8",
"@stylelint/postcss-css-in-js": "0.37.3",
"@types/codemirror": "5.60.5",
"@types/common-tags": "1.8.0",
"@types/create-react-class": "15.6.3",
"@types/dompurify": "2.3.4",
"@types/fs-extra": "9.0.13",
"@types/history": "4.7.11",
"@types/is-hotkey": "0.1.7",
"@types/jest": "29.1.2",
"@types/js-base64": "3.3.1",
"@types/js-yaml": "4.0.5",
"@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.185",
"@types/minimatch": "5.1.2",
"@types/node-fetch": "2.6.2",
"@types/react": "18.0.21",
"@types/react-color": "3.0.6",
"@types/react-dom": "18.0.6",
"@types/react-scroll-sync": "0.8.4",
"@types/react-virtualized-auto-sizer": "1.0.1",
"@types/react-window": "1.8.5",
"@types/url-join": "4.0.1",
"@types/uuid": "3.4.10",
"@typescript-eslint/eslint-plugin": "5.38.0",
"@typescript-eslint/parser": "5.38.0",
"axios": "0.26.1",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "11.0.0-beta.2",
"babel-loader": "8.2.5",
"babel-plugin-emotion": "11.0.0",
"babel-plugin-inline-json-import": "0.3.2",
"babel-plugin-inline-react-svg": "2.0.1",
"babel-plugin-lodash": "3.3.4",
"babel-plugin-transform-builtin-extend": "1.1.2",
"babel-plugin-transform-define": "2.0.1",
"babel-plugin-transform-export-extensions": "6.22.0",
"babel-plugin-transform-inline-environment-variables": "0.4.4",
"cache-me-outside": "0.0.10",
"commonmark": "0.30.0",
"commonmark-spec": "0.30.0",
"cross-env": "7.0.3",
"css-loader": "3.6.0",
"dotenv": "10.0.0",
"eslint": "8.24.0",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.31.8",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-unicorn": "41.0.1",
"execa": "5.1.1",
"fs-extra": "10.1.0",
"gitlab": "14.2.2",
"http-server": "14.1.1",
"jest": "29.1.2",
"js-yaml": "4.1.0",
"mockserver-client": "5.14.0",
"mockserver-node": "5.14.0",
"ncp": "2.0.0",
"nock": "13.2.9",
"node-fetch": "2.6.7",
"npm-run-all": "4.1.5",
"postcss": "8.4.16",
"postcss-scss": "4.0.5",
"prettier": "2.7.1",
"process": "0.11.10",
"react-refresh": "0.14.0",
"react-svg-loader": "3.0.3",
"rimraf": "3.0.2",
"simple-git": "3.14.1",
"source-map-loader": "4.0.0",
"style-loader": "3.3.1",
"stylelint": "14.12.1",
"stylelint-config-standard-scss": "3.0.0",
"stylelint-config-styled-components": "0.1.1",
"stylelint-processor-styled-components": "1.10.0",
"to-string-loader": "1.2.0",
"typescript": "4.8.4",
"webpack": "5.74.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.11.1"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}

125
core/src/actions/auth.ts Normal file
View File

@ -0,0 +1,125 @@
import { currentBackend } from '../backend';
import { addSnackbar } from '../store/slices/snackbars';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { Credentials, User } from '../interface';
import type { RootState } from '../store';
export const AUTH_REQUEST = 'AUTH_REQUEST';
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAILURE = 'AUTH_FAILURE';
export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE';
export const LOGOUT = 'LOGOUT';
export function authenticating() {
return {
type: AUTH_REQUEST,
} as const;
}
export function authenticate(userData: User) {
return {
type: AUTH_SUCCESS,
payload: userData,
} as const;
}
export function authError(error: Error) {
return {
type: AUTH_FAILURE,
error: 'Failed to authenticate',
payload: error,
} as const;
}
export function doneAuthenticating() {
return {
type: AUTH_REQUEST_DONE,
} as const;
}
export function logout() {
return {
type: LOGOUT,
} as const;
}
// Check if user data token is cached and is valid
export function authenticateUser() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
dispatch(authenticating());
return Promise.resolve(backend.currentUser())
.then(user => {
if (user) {
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
}
})
.catch((error: Error) => {
dispatch(authError(error));
dispatch(logoutUser());
});
};
}
export function loginUser(credentials: Credentials) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
dispatch(authenticating());
return backend
.authenticate(credentials)
.then(user => {
dispatch(authenticate(user));
})
.catch((error: Error) => {
console.error(error);
dispatch(
addSnackbar({
type: 'warning',
message: {
key: 'ui.toast.onFailToAuth',
message: error.message,
},
}),
);
dispatch(authError(error));
});
};
}
export function logoutUser() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
Promise.resolve(backend.logout()).then(() => {
dispatch(logout());
});
};
}
export type AuthAction = ReturnType<
| typeof authenticating
| typeof authenticate
| typeof authError
| typeof doneAuthenticating
| typeof logout
>;

View File

@ -0,0 +1,18 @@
import { history } from '../routing/history';
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
export function searchCollections(query: string, collection?: string) {
if (collection) {
history.push(`/collections/${collection}/search/${query}`);
} else {
history.push(`/search/${query}`);
}
}
export function showCollection(collectionName: string) {
history.push(getCollectionUrl(collectionName));
}
export function createNewEntry(collectionName: string) {
history.push(getNewEntryUrl(collectionName));
}

475
core/src/actions/config.ts Normal file
View File

@ -0,0 +1,475 @@
import deepmerge from 'deepmerge';
import { produce } from 'immer';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import yaml from 'yaml';
import { resolveBackend } from '../backend';
import { FILES, FOLDER } from '../constants/collectionTypes';
import { validateConfig } from '../constants/configSchema';
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
import { selectDefaultSortableFields } from '../lib/util/collection.util';
import { getIntegrations, selectIntegration } from '../reducers/integrations';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
Collection,
Config,
Field,
BaseField,
ListField,
ObjectField,
I18nInfo,
LocalBackend,
} from '../interface';
import type { RootState } from '../store';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
function isObjectField(field: Field): field is BaseField & ObjectField {
return 'fields' in (field as ObjectField);
}
function isFieldList(field: Field): field is BaseField & ListField {
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[] {
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) };
}
return newField;
});
}
function getConfigUrl() {
const validTypes: { [type: string]: string } = {
'text/yaml': 'yaml',
'application/x-yaml': 'yaml',
};
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}"`);
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;
}
// Mapping between existing camelCase and its snake_case counterpart
const WIDGET_KEY_MAP = {
dateFormat: 'date_format',
timeFormat: 'time_format',
pickerUtc: 'picker_utc',
editorComponents: 'editor_components',
valueType: 'value_type',
valueField: 'value_field',
searchFields: 'search_fields',
displayFields: 'display_fields',
optionsLength: 'options_length',
} as const;
function setSnakeCaseConfig<T extends Field>(field: T) {
const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter(
camel => camel in field,
) as ReadonlyArray<keyof typeof WIDGET_KEY_MAP>;
const snakeValues = deprecatedKeys.map(camel => {
const snake = WIDGET_KEY_MAP[camel];
console.warn(
`Field ${field.name} is using a deprecated configuration '${camel}'. Please use '${snake}'`,
);
return { [snake]: (field as unknown as Record<string, unknown>)[camel] };
});
return Object.assign({}, field, ...snakeValues) as T;
}
function setI18nField<T extends Field>(field: T) {
if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD.TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) {
return { ...field, [I18N]: I18N_FIELD.NONE };
}
return field;
}
function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) {
if (typeof collectionOrFileI18n === 'boolean') {
return defaultI18n;
} else {
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0];
const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n);
mergedI18n.locales = locales;
mergedI18n.defaultLocale = defaultLocale;
throwOnMissingDefaultLocale(mergedI18n);
return mergedI18n;
}
}
function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: boolean) {
if (hasI18n) {
return traverseFieldsJS(collectionOrFileFields, setI18nField);
} else {
return traverseFieldsJS(collectionOrFileFields, field => {
const newField = { ...field };
delete newField[I18N];
return newField;
});
}
}
function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) {
if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) {
throw new Error(
`i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`,
);
}
}
function throwOnMissingDefaultLocale(i18n?: I18nInfo) {
if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) {
throw new Error(
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
i18n.defaultLocale
}`,
);
}
}
function hasIntegration(config: Config, collection: Collection) {
const integrations = getIntegrations(config);
const integration = selectIntegration(integrations, collection.name, 'listEntries');
return !!integration;
}
export function normalizeConfig(config: Config) {
const { collections = [] } = config;
const normalizedCollections = collections.map(collection => {
const { fields, files } = collection;
let normalizedCollection = collection;
if (fields) {
const normalizedFields = traverseFieldsJS(fields, setSnakeCaseConfig);
normalizedCollection = { ...normalizedCollection, fields: normalizedFields };
}
if (files) {
const normalizedFiles = files.map(file => {
const normalizedFileFields = traverseFieldsJS(file.fields, setSnakeCaseConfig);
return { ...file, fields: normalizedFileFields };
});
normalizedCollection = { ...normalizedCollection, files: normalizedFiles };
}
return normalizedCollection;
});
return { ...config, collections: normalizedCollections };
}
export function applyDefaults(originalConfig: Config) {
return produce(originalConfig, config => {
config.slug = config.slug || {};
config.collections = config.collections || [];
// Use `site_url` as default `display_url`.
if (!config.display_url && config.site_url) {
config.display_url = config.site_url;
}
// Use media_folder as default public_folder.
const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`;
if (!('public_folder' in config)) {
config.public_folder = defaultPublicFolder;
}
// default values for the slug config
if (!('encoding' in config.slug)) {
config.slug.encoding = 'unicode';
}
if (!('clean_accents' in config.slug)) {
config.slug.clean_accents = false;
}
if (!('sanitize_replacement' in config.slug)) {
config.slug.sanitize_replacement = '-';
}
const i18n = config[I18N];
if (i18n) {
i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0];
}
throwOnMissingDefaultLocale(i18n);
const backend = resolveBackend(config);
for (const collection of config.collections) {
let collectionI18n = collection[I18N];
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
} else {
collectionI18n = undefined;
delete collection[I18N];
}
if (collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
const { folder, files, view_filters, view_groups } = collection;
if (folder) {
collection.type = FOLDER;
if (collection.path && !collection.media_folder) {
// default value for media folder when using the path config
collection.media_folder = '';
}
if ('media_folder' in collection && !('public_folder' in collection)) {
collection.public_folder = collection.media_folder;
}
if (collection.fields) {
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
}
collection.folder = trim(folder, '/');
}
if (files) {
collection.type = FILES;
throwOnInvalidFileCollectionStructure(collectionI18n);
delete collection.nested;
for (const file of files) {
file.file = trimStart(file.file, '/');
if ('media_folder' in file && !('public_folder' in file)) {
file.public_folder = file.media_folder;
}
if (file.fields) {
file.fields = traverseFieldsJS(file.fields, setDefaultPublicFolderForField);
}
let fileI18n = file[I18N];
if (fileI18n && collectionI18n) {
fileI18n = getI18nDefaults(fileI18n, collectionI18n);
file[I18N] = fileI18n;
} else {
fileI18n = undefined;
delete file[I18N];
}
throwOnInvalidFileCollectionStructure(fileI18n);
if (file.fields) {
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
}
}
}
if (!collection.sortable_fields) {
collection.sortable_fields = {
fields: selectDefaultSortableFields(
collection,
backend,
hasIntegration(config, collection),
),
};
}
collection.view_filters = (view_filters || []).map(filter => {
return {
...filter,
id: `${filter.field}__${filter.pattern}`,
};
});
collection.view_groups = (view_groups || []).map(group => {
return {
...group,
id: `${group.field}__${group.pattern}`,
};
});
if (config.editor && !collection.editor) {
collection.editor = { preview: config.editor.preview };
}
}
});
}
export function parseConfig(data: string) {
const config = yaml.parse(data, { maxAliasCount: -1, prettyErrors: true, merge: true });
if (
typeof window !== 'undefined' &&
typeof window.CMS_ENV === 'string' &&
config[window.CMS_ENV]
) {
const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray<keyof Config>;
for (const key of configKeys) {
config[key] = config[window.CMS_ENV][key] as Config[keyof Config];
}
}
return config as Config;
}
async function getConfigYaml(file: string): Promise<Config> {
const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error);
if (response instanceof Error || response.status !== 200) {
const message = response instanceof Error ? response.message : response.status;
throw new Error(`Failed to load config.yml (${message})`);
}
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})`);
}
return parseConfig(await response.text());
}
export function configLoaded(config: Config) {
return {
type: CONFIG_SUCCESS,
payload: config,
} as const;
}
export function configLoading() {
return {
type: CONFIG_REQUEST,
} as const;
}
export function configFailed(err: Error) {
return {
type: CONFIG_FAILURE,
error: 'Error loading config',
payload: err,
} as const;
}
export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
const allowedHosts = [
'localhost',
'127.0.0.1',
...(typeof localBackend === 'boolean' ? [] : localBackend?.allowed_hosts || []),
];
if (!allowedHosts.includes(location.hostname) || !localBackend) {
return {};
}
const defaultUrl = 'http://localhost:8081/api/v1';
const proxyUrl =
localBackend === true
? defaultUrl
: localBackend.url || defaultUrl.replace('localhost', location.hostname);
try {
console.info(`Looking for Static CMS Proxy Server at '${proxyUrl}'`);
const res = await fetch(`${proxyUrl}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'info' }),
});
const { repo, type } = (await res.json()) as {
repo?: string;
type?: string;
};
if (typeof repo === 'string' && typeof type === 'string') {
console.info(`Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
return { proxyUrl, type };
} else {
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
} catch {
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
}
export async function handleLocalBackend(originalConfig: Config) {
if (!originalConfig.local_backend) {
return originalConfig;
}
const { proxyUrl } = await detectProxyServer(originalConfig.local_backend);
if (!proxyUrl) {
return originalConfig;
}
return produce(originalConfig, config => {
config.backend.name = 'proxy';
config.backend.proxy_url = proxyUrl;
});
}
export function loadConfig(manualConfig: Config | undefined, onLoad: () => unknown) {
if (window.CMS_CONFIG) {
return configLoaded(window.CMS_CONFIG);
}
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>) => {
dispatch(configLoading());
try {
const configUrl = getConfigUrl();
const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl);
validateConfig(mergedConfig);
const withLocalBackend = await handleLocalBackend(mergedConfig);
const normalizedConfig = normalizeConfig(withLocalBackend);
const config = applyDefaults(normalizedConfig);
dispatch(configLoaded(config));
if (typeof onLoad === 'function') {
onLoad();
}
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(configFailed(error));
}
throw error;
}
};
}
export type ConfigAction = ReturnType<
typeof configLoading | typeof configLoaded | typeof configFailed
>;

1150
core/src/actions/entries.ts Normal file

File diff suppressed because it is too large Load Diff

128
core/src/actions/media.ts Normal file
View File

@ -0,0 +1,128 @@
import { isAbsolutePath } from '../lib/util';
import { selectMediaFilePath } from '../lib/util/media.util';
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { Field, Collection, Entry } from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
export const ADD_ASSETS = 'ADD_ASSETS';
export const ADD_ASSET = 'ADD_ASSET';
export const REMOVE_ASSET = 'REMOVE_ASSET';
export const LOAD_ASSET_REQUEST = 'LOAD_ASSET_REQUEST';
export const LOAD_ASSET_SUCCESS = 'LOAD_ASSET_SUCCESS';
export const LOAD_ASSET_FAILURE = 'LOAD_ASSET_FAILURE';
export function addAssets(assets: AssetProxy[]) {
return { type: ADD_ASSETS, payload: assets } as const;
}
export function addAsset(assetProxy: AssetProxy) {
return { type: ADD_ASSET, payload: assetProxy } as const;
}
export function removeAsset(path: string) {
return { type: REMOVE_ASSET, payload: path } as const;
}
export function loadAssetRequest(path: string) {
return { type: LOAD_ASSET_REQUEST, payload: { path } } as const;
}
export function loadAssetSuccess(path: string) {
return { type: LOAD_ASSET_SUCCESS, payload: { path } } as const;
}
export function loadAssetFailure(path: string, error: Error) {
return { type: LOAD_ASSET_FAILURE, payload: { path, error } } as const;
}
export function loadAsset(resolvedPath: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
const asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(loadAssetFailure(resolvedPath, error));
}
}
};
}
const emptyAsset = createAssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
export function getAsset(collection: Collection, entry: Entry, path: string, field?: Field) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if (!path) {
return emptyAsset;
}
const state = getState();
if (!state.config.config) {
return emptyAsset;
}
const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) {
return emptyAsset;
}
if (asset) {
// There is already an AssetProxy in memory for this path. Use it.
return asset;
}
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));
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
dispatch(loadAsset(resolvedPath));
asset = emptyAsset;
}
}
return asset;
};
}
export type MediasAction = ReturnType<
| typeof addAssets
| typeof addAsset
| typeof removeAsset
| typeof loadAssetRequest
| typeof loadAssetSuccess
| typeof loadAssetFailure
>;

View File

@ -0,0 +1,652 @@
import { currentBackend } from '../backend';
import confirm from '../components/UI/Confirm';
import { getMediaIntegrationProvider } from '../integrations';
import { sanitizeSlug } from '../lib/urlHelper';
import { basename, getBlobSHA } from '../lib/util';
import { selectIntegration } from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
import { addSnackbar } from '../store/slices/snackbars';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { addAsset, removeAsset } from './media';
import { waitUntilWithTimeout } from './waitUntil';
import { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
Field,
DisplayURLState,
ImplementationMediaFile,
MediaFile,
MediaLibraryInstance,
} from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
export const MEDIA_LIBRARY_CREATE = 'MEDIA_LIBRARY_CREATE';
export const MEDIA_INSERT = 'MEDIA_INSERT';
export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST';
export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS';
export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE';
export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST';
export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS';
export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST';
export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS';
export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE';
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(
payload: {
controlID?: string;
forImage?: boolean;
privateUpload?: boolean;
value?: string | string[];
allowMultiple?: boolean;
replaceIndex?: number;
config?: Record<string, unknown>;
field?: Field;
} = {},
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
const { controlID: id, value, config = {}, allowMultiple, forImage } = payload;
mediaLibrary.show({ id, value, config, allowMultiple, imagesOnly: forImage });
}
dispatch(mediaLibraryOpened(payload));
};
}
export function closeMediaLibrary() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
if (mediaLibrary) {
mediaLibrary.hide?.();
}
dispatch(mediaLibraryClosed());
};
}
export function insertMedia(mediaPath: string | string[], field: Field | undefined) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
const entry = state.entryDraft.entry;
const collectionName = state.entryDraft.entry?.collection;
if (!collectionName || !config) {
return;
}
const collection = state.collections[collectionName];
if (Array.isArray(mediaPath)) {
mediaPath = mediaPath.map(path =>
selectMediaFilePublicPath(config, collection, path, entry, field),
);
} else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
}
dispatch(mediaInserted(mediaPath));
};
}
export function removeInsertedMedia(controlID: string) {
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
}
export function loadMedia(
opts: { delay?: number; query?: string; page?: number; privateUpload?: boolean } = {},
) {
const { delay = 0, query = '', page = 1, privateUpload = false } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
}
const backend = currentBackend(config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getMediaIntegrationProvider(
state.integrations,
backend.getToken,
integration,
);
if (!provider) {
throw new Error('Provider not found');
}
dispatch(mediaLoading(page));
try {
const files = await provider.retrieve(query, page, privateUpload);
const mediaLoadedOpts = {
page,
canPaginate: true,
dynamicSearch: true,
dynamicSearchQuery: query,
privateUpload,
};
return dispatch(mediaLoaded(files, mediaLoadedOpts));
} catch (error) {
return dispatch(mediaLoadFailed({ privateUpload }));
}
}
dispatch(mediaLoading(page));
function loadFunction() {
return backend
.getMedia()
.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.');
dispatch(mediaLoaded([]));
} else {
dispatch(mediaLoadFailed());
}
});
}
if (delay > 0) {
return new Promise(resolve => {
setTimeout(() => resolve(loadFunction()), delay);
});
} else {
return loadFunction();
}
};
}
function createMediaFileFromAsset({
id,
file,
assetProxy,
draft,
}: {
id: string;
file: File;
assetProxy: AssetProxy;
draft: boolean;
}): ImplementationMediaFile {
const mediaFile = {
id,
name: basename(assetProxy.path),
displayURL: assetProxy.url,
draft,
file,
size: file.size,
url: assetProxy.url,
path: assetProxy.path,
field: assetProxy.field,
};
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}) {
const { privateUpload, field } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
}
const backend = currentBackend(config);
const integration = selectIntegration(state, null, 'assetStore');
const files: MediaFile[] = selectMediaFiles(state, field);
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
const editingDraft = selectEditingDraft(state.entryDraft);
/**
* Check for existing files of the same name before persisting. If no asset
* store integration is used, files are being stored in Git, so we can
* expect file names to be unique. If an asset store is in use, file names
* may not be unique, so we forego this check.
*/
if (!integration && existingFile) {
if (
!(await confirm({
title: 'mediaLibrary.mediaLibrary.alreadyExistsTitle',
body: {
key: 'mediaLibrary.mediaLibrary.alreadyExistsBody',
options: { filename: existingFile.name },
},
color: 'error',
}))
) {
return;
} else {
await dispatch(deleteMedia(existingFile, { privateUpload }));
}
}
if (integration || !editingDraft) {
dispatch(mediaPersisting());
}
try {
let assetProxy: AssetProxy;
if (integration) {
try {
const provider = getMediaIntegrationProvider(
state.integrations,
backend.getToken,
integration,
);
if (!provider) {
throw new Error('Provider not found');
}
const response = await provider.upload(file, privateUpload);
assetProxy = createAssetProxy({
url: response.asset.url,
path: response.asset.url,
});
} catch (error) {
assetProxy = createAssetProxy({
file,
path: fileName,
});
}
} else if (privateUpload) {
throw new Error('The Private Upload option is only available for Asset Store Integration');
} else {
const entry = state.entryDraft.entry;
if (!entry?.collection) {
return;
}
const collection = state.collections[entry?.collection];
const path = selectMediaFilePath(config, collection, entry, fileName, field);
assetProxy = createAssetProxy({
file,
path,
field,
});
}
dispatch(addAsset(assetProxy));
let mediaFile: ImplementationMediaFile;
if (integration) {
const id = await getBlobSHA(file);
// integration assets are persisted immediately, thus draft is false
mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: false });
} else if (editingDraft) {
const id = await getBlobSHA(file);
mediaFile = createMediaFileFromAsset({
id,
file,
assetProxy,
draft: Boolean(editingDraft),
});
return dispatch(addDraftEntryMediaFile(mediaFile));
} else {
mediaFile = await backend.persistMedia(config, assetProxy);
}
return dispatch(mediaPersisted(mediaFile, { privateUpload }));
} catch (error) {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToPersistMedia',
details: error,
},
}),
);
return dispatch(mediaPersistFailed({ privateUpload }));
}
};
}
export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
}
const backend = currentBackend(config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getMediaIntegrationProvider(
state.integrations,
backend.getToken,
integration,
);
if (!provider) {
throw new Error('Provider not found');
}
dispatch(mediaDeleting());
try {
await provider.delete(file.id);
return dispatch(mediaDeleted(file, { privateUpload }));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToDeleteMedia',
details: error.message,
},
}),
);
}
return dispatch(mediaDeleteFailed({ privateUpload }));
}
}
try {
if (file.draft) {
dispatch(removeAsset(file.path));
dispatch(removeDraftEntryMediaFile({ id: file.id }));
} else {
const editingDraft = selectEditingDraft(state.entryDraft);
dispatch(mediaDeleting());
dispatch(removeAsset(file.path));
await backend.deleteMedia(config, file.path);
dispatch(mediaDeleted(file));
if (editingDraft) {
dispatch(removeDraftEntryMediaFile({ id: file.id }));
}
}
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToDeleteMedia',
details: error.message,
},
}),
);
}
return dispatch(mediaDeleteFailed());
}
};
}
export async function getMediaFile(state: RootState, path: string) {
const config = state.config.config;
if (!config) {
return { url: '' };
}
const backend = currentBackend(config);
const { url } = await backend.getMediaFile(path);
return { url };
}
export function loadMediaDisplayURL(file: MediaFile) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const { displayURL, id } = file;
const state = getState();
const config = state.config.config;
if (!config) {
return Promise.reject();
}
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id);
if (
!id ||
!displayURL ||
displayURLState.url ||
displayURLState.isFetching ||
displayURLState.err
) {
return Promise.resolve();
}
if (typeof displayURL === 'string') {
dispatch(mediaDisplayURLRequest(id));
dispatch(mediaDisplayURLSuccess(id, displayURL));
return;
}
try {
const backend = currentBackend(config);
dispatch(mediaDisplayURLRequest(id));
const newURL = await backend.getMediaDisplayURL(displayURL);
if (newURL) {
dispatch(mediaDisplayURLSuccess(id, newURL));
} else {
throw new Error('No display URL was returned!');
}
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(mediaDisplayURLFailure(id, error));
}
}
};
}
function mediaLibraryOpened(payload: {
controlID?: string;
forImage?: boolean;
privateUpload?: 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 mediaLoading(page: number) {
return {
type: MEDIA_LOAD_REQUEST,
payload: { page },
} as const;
}
export interface MediaOptions {
privateUpload?: boolean;
field?: Field;
page?: number;
canPaginate?: boolean;
dynamicSearch?: boolean;
dynamicSearchQuery?: string;
}
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {
return {
type: MEDIA_LOAD_SUCCESS,
payload: { files, ...opts },
} as const;
}
export function mediaLoadFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } } as const;
}
export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST } as const;
}
export function mediaPersisted(file: ImplementationMediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file, privateUpload },
} as const;
}
export function mediaPersistFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } } as const;
}
export function mediaDeleting() {
return { type: MEDIA_DELETE_REQUEST } as const;
}
export function mediaDeleted(file: MediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_DELETE_SUCCESS,
payload: { file, privateUpload },
} as const;
}
export function mediaDeleteFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } } as const;
}
export function mediaDisplayURLRequest(key: string) {
return { type: MEDIA_DISPLAY_URL_REQUEST, payload: { key } } as const;
}
export function mediaDisplayURLSuccess(key: string, url: string) {
return {
type: MEDIA_DISPLAY_URL_SUCCESS,
payload: { key, url },
} as const;
}
export function mediaDisplayURLFailure(key: string, err: Error) {
return {
type: MEDIA_DISPLAY_URL_FAILURE,
payload: { key, err },
} as const;
}
export async function waitForMediaLibraryToLoad(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
state: RootState,
) {
if (state.mediaLibrary.isLoading !== false && !state.mediaLibrary.externalLibrary) {
await waitUntilWithTimeout(dispatch, resolve => ({
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
run: () => resolve(),
}));
}
}
export async function getMediaDisplayURL(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
state: RootState,
file: MediaFile,
) {
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id);
let url: string | null | undefined;
if (displayURLState.url) {
// url was already loaded
url = displayURLState.url;
} else if (displayURLState.err) {
// url loading had an error
url = null;
} else {
const key = file.id;
const promise = waitUntilWithTimeout<string>(dispatch, resolve => ({
predicate: ({ type, payload }) =>
(type === MEDIA_DISPLAY_URL_SUCCESS || type === MEDIA_DISPLAY_URL_FAILURE) &&
payload.key === key,
run: (_dispatch, _getState, action) => resolve(action.payload.url),
}));
if (!displayURLState.isFetching) {
// load display url
dispatch(loadMediaDisplayURL(file));
}
url = await promise;
}
return url;
}
export type MediaLibraryAction = ReturnType<
| typeof createMediaLibrary
| typeof mediaLibraryOpened
| typeof mediaLibraryClosed
| typeof mediaInserted
| typeof removeInsertedMedia
| typeof mediaLoading
| typeof mediaLoaded
| typeof mediaLoadFailed
| typeof mediaPersisting
| typeof mediaPersisted
| typeof mediaPersistFailed
| typeof mediaDeleting
| typeof mediaDeleted
| typeof mediaDeleteFailed
| typeof mediaDisplayURLRequest
| typeof mediaDisplayURLSuccess
| typeof mediaDisplayURLFailure
>;

View File

@ -0,0 +1,32 @@
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { RootState } from '../store';
export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
export const TOGGLE_SCROLL = 'TOGGLE_SCROLL';
export const SET_SCROLL = 'SET_SCROLL';
export function togglingScroll() {
return {
type: TOGGLE_SCROLL,
} as const;
}
export function loadScroll() {
return {
type: SET_SCROLL,
payload: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
} as const;
}
export function toggleScroll() {
return async (
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
_getState: () => RootState,
) => {
return dispatch(togglingScroll());
};
}
export type ScrollAction = ReturnType<typeof togglingScroll | typeof loadScroll>;

209
core/src/actions/search.ts Normal file
View File

@ -0,0 +1,209 @@
import isEqual from 'lodash/isEqual';
import { currentBackend } from '../backend';
import { getSearchIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { Entry, SearchQueryResponse } from '../interface';
import type { RootState } from '../store';
/*
* Constant Declarations
*/
export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST';
export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS';
export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE';
export const QUERY_REQUEST = 'QUERY_REQUEST';
export const QUERY_SUCCESS = 'QUERY_SUCCESS';
export const QUERY_FAILURE = 'QUERY_FAILURE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
/*
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function searchingEntries(searchTerm: string, searchCollections: string[], page: number) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm, searchCollections, page },
} as const;
}
export function searchSuccess(entries: Entry[], page: number) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
entries,
page,
},
} as const;
}
export function searchFailure(error: Error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: { error },
} as const;
}
export function querying(searchTerm: string) {
return {
type: QUERY_REQUEST,
payload: {
searchTerm,
},
} as const;
}
export function querySuccess(namespace: string, hits: Entry[]) {
return {
type: QUERY_SUCCESS,
payload: {
namespace,
hits,
},
} as const;
}
export function queryFailure(error: Error) {
return {
type: QUERY_FAILURE,
payload: { error },
} as const;
}
/*
* Exported simple Action Creators
*/
export function clearSearch() {
return { type: SEARCH_CLEAR } as const;
}
/*
* Exported Thunk Action Creators
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) {
return async (
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
getState: () => RootState,
) => {
const state = getState();
const { search } = state;
const configState = state.config;
if (!configState.config) {
return;
}
const backend = currentBackend(configState.config);
const allCollections = searchCollections || Object.keys(state.collections);
const collections = allCollections.filter(collection =>
selectIntegration(state, collection, 'search'),
);
const integration = selectIntegration(state, collections[0], 'search');
// avoid duplicate searches
if (
search.isFetching &&
search.term === searchTerm &&
isEqual(allCollections, search.collections) &&
// if an integration doesn't exist, 'page' is not used
(search.page === page || !integration)
) {
return;
}
dispatch(searchingEntries(searchTerm, allCollections, page));
const searchPromise = integration
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.search(
collections,
searchTerm,
page,
)
: backend.search(
Object.entries(state.collections)
.filter(([key, _value]) => allCollections.indexOf(key) !== -1)
.map(([_key, value]) => value),
searchTerm,
);
try {
const response = await searchPromise;
if (!response) {
return dispatch(searchFailure(new Error(`No integration found for name "${integration}"`)));
}
return dispatch(searchSuccess(response.entries, response.pagination));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
return dispatch(searchFailure(error));
}
}
};
}
// Instead of searching for complete entries, query will search for specific fields
// in specific collections and return raw data (no entries).
export function query(
namespace: string,
collectionName: string,
searchFields: string[],
searchTerm: string,
file?: string,
limit?: number,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
dispatch(querying(searchTerm));
const state = getState();
const configState = state.config;
if (!configState.config) {
return dispatch(queryFailure(new Error('Config not found')));
}
const backend = currentBackend(configState.config);
const integration = selectIntegration(state, collectionName, 'search');
const collection = Object.values(state.collections).find(
collection => collection.name === collectionName,
);
if (!collection) {
return dispatch(queryFailure(new Error('Collection not found')));
}
const queryPromise = integration
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.searchBy(
JSON.stringify(searchFields.map(f => `data.${f}`)),
collectionName,
searchTerm,
)
: backend.query(collection, searchFields, searchTerm, file, limit);
try {
const response: SearchQueryResponse = await queryPromise;
return dispatch(querySuccess(namespace, response.hits));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
return dispatch(queryFailure(error));
}
}
};
}
export type SearchAction = ReturnType<
| typeof searchingEntries
| typeof searchSuccess
| typeof searchFailure
| typeof querying
| typeof querySuccess
| typeof queryFailure
| typeof clearSearch
>;

View File

@ -0,0 +1,98 @@
import { currentBackend } from '../backend';
import { addSnackbar, removeSnackbarById } from '../store/slices/snackbars';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { RootState } from '../store';
export const STATUS_REQUEST = 'STATUS_REQUEST';
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
export const STATUS_FAILURE = 'STATUS_FAILURE';
export function statusRequest() {
return {
type: STATUS_REQUEST,
} as const;
}
export function statusSuccess(status: {
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}) {
return {
type: STATUS_SUCCESS,
payload: { status },
} as const;
}
export function statusFailure(error: Error) {
return {
type: STATUS_FAILURE,
payload: { error },
} as const;
}
export function checkBackendStatus() {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
try {
const state = getState();
const config = state.config.config;
if (state.status.isFetching || !config) {
return;
}
dispatch(statusRequest());
const backend = currentBackend(config);
const status = await backend.status();
const backendDownKey = 'ui.toast.onBackendDown';
const previousBackendDownNotifs = state.snackbar.messages.filter(
n => typeof n.message !== 'string' && n.message.key === backendDownKey,
);
if (status.api.status === false) {
if (previousBackendDownNotifs.length === 0) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onBackendDown', details: status.api.statusPage },
}),
);
}
return dispatch(statusSuccess(status));
} else if (status.api.status === true && previousBackendDownNotifs.length > 0) {
// If backend is up, clear all the danger messages
previousBackendDownNotifs.forEach(notif => {
dispatch(removeSnackbarById(notif.id));
});
}
const authError = status.auth.status === false;
if (authError) {
const key = 'ui.toast.onLoggedOut';
const existingNotification = state.snackbar.messages.find(
n => typeof n.message !== 'string' && n.message.key === key,
);
if (!existingNotification) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onLoggedOut' },
}),
);
}
}
dispatch(statusSuccess(status));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(statusFailure(error));
}
}
};
}
export type StatusAction = ReturnType<
typeof statusRequest | typeof statusSuccess | typeof statusFailure
>;

View File

@ -0,0 +1,49 @@
import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { RootState } from '../store';
import type { WaitActionArgs } from '../store/middleware/waitUntilAction';
export function waitUntil({ predicate, run }: WaitActionArgs) {
return {
type: WAIT_UNTIL_ACTION,
predicate,
run,
};
}
export async function waitUntilWithTimeout<T>(
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
timeout = 30000,
): Promise<T | null | undefined> {
let waitDone = false;
const waitPromise = new Promise<T | undefined>(resolve => {
dispatch(waitUntil(waitActionArgs(resolve)));
});
const timeoutPromise = new Promise<T | null>(resolve => {
setTimeout(() => {
if (waitDone) {
resolve(null);
} else {
console.warn('Wait Action timed out');
resolve(null);
}
}, timeout);
});
const result = await Promise.race([
waitPromise
.then(result => {
waitDone = true;
return result;
})
.catch(null),
timeoutPromise,
]);
return result;
}

1027
core/src/backend.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,425 @@
import { Base64 } from 'js-base64';
import partial from 'lodash/partial';
import result from 'lodash/result';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import { basename, dirname } from 'path';
import {
APIError,
localForage,
readFile,
readFileMetadata,
requestWithBackoff,
responseParser,
unsentRequest,
} from '../../lib/util';
import type { DataFile, PersistOptions } from '../../interface';
import type { ApiRequest } from '../../lib/util';
import type { ApiRequestObject } from '../../lib/util/API';
import type AssetProxy from '../../valueObjects/AssetProxy';
export const API_NAME = 'Azure DevOps';
const API_VERSION = 'api-version';
type AzureUser = {
coreAttributes?: {
Avatar?: { value?: { value?: string } };
DisplayName?: { value?: string };
EmailAddress?: { value?: string };
};
};
type AzureGitItem = {
objectId: string;
gitObjectType: AzureObjectType;
path: string;
};
// This does not match Azure documentation, but it is what comes back from some calls
// PullRequest as an example is documented as returning PullRequest[], but it actually
// returns that inside of this value prop in the json
interface AzureArray<T> {
value: T[];
}
enum AzureCommitChangeType {
ADD = 'add',
DELETE = 'delete',
RENAME = 'rename',
EDIT = 'edit',
}
enum AzureItemContentType {
BASE64 = 'base64encoded',
}
enum AzureObjectType {
BLOB = 'blob',
TREE = 'tree',
}
type AzureRef = {
name: string;
objectId: string;
};
type AzureCommit = {
author: {
date: string;
email: string;
name: string;
};
};
function getChangeItem(item: AzureCommitItem) {
switch (item.action) {
case AzureCommitChangeType.ADD:
return {
changeType: AzureCommitChangeType.ADD,
item: { path: item.path },
newContent: {
content: item.base64Content,
contentType: AzureItemContentType.BASE64,
},
};
case AzureCommitChangeType.EDIT:
return {
changeType: AzureCommitChangeType.EDIT,
item: { path: item.path },
newContent: {
content: item.base64Content,
contentType: AzureItemContentType.BASE64,
},
};
case AzureCommitChangeType.DELETE:
return {
changeType: AzureCommitChangeType.DELETE,
item: { path: item.path },
};
case AzureCommitChangeType.RENAME:
return {
changeType: AzureCommitChangeType.RENAME,
item: { path: item.path },
sourceServerItem: item.oldPath,
};
default:
return {};
}
}
type AzureCommitItem = {
action: AzureCommitChangeType;
base64Content?: string;
text?: string;
path: string;
oldPath?: string;
};
interface AzureApiConfig {
apiRoot: string;
repo: { org: string; project: string; repoName: string };
branch: string;
apiVersion: string;
}
export default class API {
apiVersion: string;
token: string;
branch: string;
endpointUrl: string;
constructor(config: AzureApiConfig, token: string) {
const { repo } = config;
const apiRoot = trim(config.apiRoot, '/');
this.endpointUrl = `${apiRoot}/${repo.org}/${repo.project}/_apis/git/repositories/${repo.repoName}`;
this.token = token;
this.branch = config.branch;
this.apiVersion = config.apiVersion;
}
withHeaders = (req: ApiRequest) => {
const withHeaders = unsentRequest.withHeaders(
{
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json; charset=utf-8',
},
req,
);
return withHeaders;
};
withAzureFeatures = (req: ApiRequestObject) => {
if (API_VERSION in (req.params ?? {})) {
return req;
}
const withParams = unsentRequest.withParams(
{
[API_VERSION]: `${this.apiVersion}`,
},
req,
);
return withParams;
};
buildRequest = (req: ApiRequest) => {
const withHeaders = this.withHeaders(req);
const withAzureFeatures = this.withAzureFeatures(withHeaders);
if ('cache' in withAzureFeatures) {
return withAzureFeatures;
} else {
const withNoCache = unsentRequest.withNoCache(withAzureFeatures);
return withNoCache;
}
};
request = (req: ApiRequest): Promise<Response> => {
try {
return requestWithBackoff(this, req);
} catch (error: unknown) {
if (error instanceof Error) {
throw new APIError(error.message, null, API_NAME);
}
throw new APIError('Unknown api error', null, API_NAME);
}
};
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
responseToText = responseParser({ format: 'text', apiName: API_NAME });
requestJSON = <T>(req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<T>;
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
fromBase64 = (str: string) => Base64.decode(str);
branchToRef = (branch: string): string => `refs/heads/${branch}`;
refToBranch = (ref: string): string => ref.slice('refs/heads/'.length);
user = async () => {
const result = await this.requestJSON<AzureUser>({
url: 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me',
params: { [API_VERSION]: '6.1-preview.2' },
});
const name = result.coreAttributes?.DisplayName?.value;
const email = result.coreAttributes?.EmailAddress?.value;
const url = result.coreAttributes?.Avatar?.value?.value;
const user = {
name: name || email || '',
avatar_url: `data:image/png;base64,${url}`,
email,
};
return user;
};
async readFileMetadata(
path: string,
sha: string | null | undefined,
{ branch = this.branch } = {},
) {
const fetchFileMetadata = async () => {
try {
const { value } = await this.requestJSON<AzureArray<AzureCommit>>({
url: `${this.endpointUrl}/commits/`,
params: {
'searchCriteria.itemPath': path,
'searchCriteria.itemVersion.version': branch,
'searchCriteria.$top': '1',
},
});
const [commit] = value;
return {
author: commit.author.name || commit.author.email,
updatedOn: commit.author.date,
};
} catch (error) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
readFile = (
path: string,
sha?: string | null,
{ parseText = true, branch = this.branch } = {},
) => {
const fetchContent = () => {
return this.request({
url: `${this.endpointUrl}/items/`,
params: { version: branch, path },
cache: 'no-store',
}).then<Blob | string>(parseText ? this.responseToText : this.responseToBlob);
};
return readFile(sha, fetchContent, localForage, parseText);
};
listFiles = async (path: string, recursive: boolean, branch = this.branch) => {
try {
const { value: items } = await this.requestJSON<AzureArray<AzureGitItem>>({
url: `${this.endpointUrl}/items/`,
params: {
version: branch,
scopePath: path,
recursionLevel: recursive ? 'full' : 'oneLevel',
},
});
const files = items
.filter(item => item.gitObjectType === AzureObjectType.BLOB)
.map(file => ({
id: file.objectId,
path: trimStart(file.path, '/'),
name: basename(file.path),
}));
return files;
} catch (err: any) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return [];
} else {
throw err;
}
}
};
async getRef(branch: string = this.branch) {
const { value: refs } = await this.requestJSON<AzureArray<AzureRef>>({
url: `${this.endpointUrl}/refs`,
params: {
$top: '1', // There's only one ref, so keep the payload small
filter: 'heads/' + branch,
},
});
return refs.find(b => b.name == this.branchToRef(branch))!;
}
async uploadAndCommit(
items: AzureCommitItem[],
comment: string,
branch: string,
newBranch: boolean,
) {
const ref = await this.getRef(newBranch ? this.branch : branch);
const refUpdate = [
{
name: this.branchToRef(branch),
oldObjectId: ref.objectId,
},
];
const changes = items.map(item => getChangeItem(item));
const commits = [{ comment, changes }];
const push = {
refUpdates: refUpdate,
commits,
};
return this.requestJSON({
url: `${this.endpointUrl}/pushes`,
method: 'POST',
body: JSON.stringify(push),
});
}
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
const items = await Promise.all(
files.map(async file => {
const [base64Content, fileExists] = await Promise.all([
result(file, 'toBase64', partial(this.toBase64, (file as DataFile).raw)),
this.isFileExists(file.path, branch),
]);
const path = file.newPath || file.path;
const oldPath = file.path;
const renameOrEdit =
path !== oldPath ? AzureCommitChangeType.RENAME : AzureCommitChangeType.EDIT;
const action = fileExists ? renameOrEdit : AzureCommitChangeType.ADD;
return {
action,
base64Content,
path,
oldPath,
} as AzureCommitItem;
}),
);
// move children
for (const item of items.filter(i => i.oldPath && i.action === AzureCommitChangeType.RENAME)) {
const sourceDir = dirname(item.oldPath as string);
const destDir = dirname(item.path);
const children = await this.listFiles(sourceDir, true, branch);
children
.filter(file => file.path !== item.oldPath)
.forEach(file => {
items.push({
action: AzureCommitChangeType.RENAME,
path: file.path.replace(sourceDir, destDir),
oldPath: file.path,
});
});
}
return items;
}
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
const items = await this.getCommitItems(files, this.branch);
return this.uploadAndCommit(items, options.commitMessage, this.branch, true);
}
async deleteFiles(paths: string[], comment: string) {
const ref = await this.getRef(this.branch);
const refUpdate = {
name: ref.name,
oldObjectId: ref.objectId,
};
const changes = paths.map(path =>
getChangeItem({ action: AzureCommitChangeType.DELETE, path }),
);
const commits = [{ comment, changes }];
const push = {
refUpdates: [refUpdate],
commits,
};
return this.requestJSON({
url: `${this.endpointUrl}/pushes`,
method: 'POST',
body: JSON.stringify(push),
});
}
async isFileExists(path: string, branch: string) {
try {
await this.requestText({
url: `${this.endpointUrl}/items/`,
params: { version: branch, path },
cache: 'no-store',
});
return true;
} catch (error) {
if (error instanceof APIError && error.status === 404) {
return false;
}
throw error;
}
}
}

View File

@ -0,0 +1,80 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import alert from '../../components/UI/Alert';
import AuthenticationPage from '../../components/UI/AuthenticationPage';
import Icon from '../../components/UI/Icon';
import { ImplicitAuthenticator } from '../../lib/auth';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
const AzureAuthenticationPage = ({
inProgress = false,
config,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
const [loginError, setLoginError] = useState<string | null>(null);
const auth = useMemo(
() =>
new ImplicitAuthenticator({
base_url: `https://login.microsoftonline.com/${config.backend.tenant_id}`,
auth_endpoint: 'oauth2/authorize',
app_id: config.backend.app_id,
clearHash,
}),
[clearHash, config.backend.app_id, config.backend.tenant_id],
);
useEffect(() => {
// Complete implicit authentication if we were redirected back to from the provider.
auth.completeAuth((err, data) => {
if (err) {
alert({
title: 'auth.errors.authTitle',
body: { key: 'auth.errors.authBody', options: { details: err } },
});
return;
} else if (data) {
onLogin(data);
}
});
}, [auth, onLogin]);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
auth.authenticate(
{
scope: 'vso.code_full,user.read',
resource: '499b84ac-1321-427f-aa17-267ca6975798',
prompt: 'select_account',
},
(err, data) => {
if (err) {
setLoginError(err.toString());
} else if (data) {
onLogin(data);
}
},
);
},
[auth, onLogin],
);
return (
<AuthenticationPage
onLogin={handleLogin}
loginDisabled={inProgress}
loginErrorMessage={loginError}
logoUrl={config.logo_url}
icon={<Icon type="azure" />}
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')}
t={t}
/>
);
};
export default AzureAuthenticationPage;

View File

@ -0,0 +1,265 @@
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { BackendClass } from '../../interface';
import {
asyncLock,
basename,
entriesByFiles,
entriesByFolder,
filterByExtension,
getBlobSHA,
getMediaAsBlob,
getMediaDisplayURL,
} from '../../lib/util';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import type { Semaphore } from 'semaphore';
import type {
BackendEntry,
BackendInitializerOptions,
Config,
Credentials,
DisplayURL,
ImplementationEntry,
ImplementationFile,
ImplementationMediaFile,
PersistOptions,
User,
} from '../../interface';
import type { AsyncLock, Cursor } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
const MAX_CONCURRENT_DOWNLOADS = 10;
function parseAzureRepo(config: Config) {
const { repo } = config.backend;
if (typeof repo !== 'string') {
throw new Error('The Azure backend needs a "repo" in the backend configuration.');
}
const parts = repo.split('/');
if (parts.length !== 3) {
throw new Error('The Azure backend must be in a the format of {org}/{project}/{repo}');
}
const [org, project, repoName] = parts;
return {
org,
project,
repoName,
};
}
export default class Azure extends BackendClass {
lock: AsyncLock;
api?: API;
options: BackendInitializerOptions;
repo: {
org: string;
project: string;
repoName: string;
};
branch: string;
apiRoot: string;
apiVersion: string;
token: string | null;
mediaFolder: string;
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options: BackendInitializerOptions) {
super(config, options);
this.options = {
...options,
};
this.repo = parseAzureRepo(config);
this.branch = config.backend.branch || 'main';
this.apiRoot = config.backend.api_root || 'https://dev.azure.com';
this.apiVersion = config.backend.api_version || '6.1-preview';
this.token = '';
this.mediaFolder = trim(config.media_folder, '/');
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status(): Promise<{
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}> {
const auth =
(await this.api!.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Azure user', e);
return false;
})) || false;
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user: User) {
return this.authenticate(user);
}
async authenticate(state: Credentials) {
this.token = state.token as string;
this.api = new API(
{
apiRoot: this.apiRoot,
apiVersion: this.apiVersion,
repo: this.repo,
branch: this.branch,
},
this.token,
);
const user = await this.api.user();
return { token: state.token as string, ...user };
}
/**
* Log the user out by forgetting their access token.
* TODO: *Actual* logout by redirecting to:
* https://login.microsoftonline.com/{tenantId}/oauth2/logout?client_id={clientId}&post_logout_redirect_uri={baseUrl}
*/
logout() {
this.token = null;
return;
}
getToken() {
return Promise.resolve(this.token);
}
async entriesByFolder(folder: string, extension: string, depth: number) {
const listFiles = async () => {
const files = await this.api!.listFiles(folder, depth > 1);
const filtered = files.filter(file => filterByExtension({ path: file.path }, extension));
return filtered.map(file => ({
id: file.id,
path: file.path,
}));
};
const entries = await entriesByFolder(
listFiles,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return entries;
}
entriesByFiles(files: ImplementationFile[]) {
return entriesByFiles(
files,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
}
async getEntry(path: string) {
const data = (await this.api!.readFile(path)) as string;
return {
file: { path },
data,
};
}
async getMedia() {
const files = await this.api!.listFiles(this.mediaFolder, false);
const mediaFiles = await Promise.all(
files.map(async ({ id, path, name }) => {
const blobUrl = await this.getMediaDisplayURL({ id, path });
return { id, name, displayURL: blobUrl, path };
}),
);
return mediaFiles;
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = new File([blob], name);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
async persistEntry(entry: BackendEntry, options: PersistOptions): Promise<void> {
const mediaFiles: AssetProxy[] = entry.assets;
await this.api!.persistFiles(entry.dataFiles, mediaFiles, options);
}
async persistMedia(
mediaFile: AssetProxy,
options: PersistOptions,
): Promise<ImplementationMediaFile> {
const fileObj = mediaFile.fileObj as File;
const [id] = await Promise.all([
getBlobSHA(fileObj),
this.api!.persistFiles([], [mediaFile], options),
]);
const { path } = mediaFile;
const url = URL.createObjectURL(fileObj);
return {
displayURL: url,
path: trimStart(path, '/'),
name: fileObj!.name,
size: fileObj!.size,
file: fileObj,
url,
id: id as string,
};
}
async deleteFiles(paths: string[], commitMessage: string) {
await this.api!.deleteFiles(paths, commitMessage);
}
traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> {
throw new Error('Not supported');
}
allEntriesByFolder(
_folder: string,
_extension: string,
_depth: number,
): Promise<ImplementationEntry[]> {
throw new Error('Not supported');
}
}

View File

@ -0,0 +1,3 @@
export { default as AzureBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@ -0,0 +1,462 @@
import flow from 'lodash/flow';
import get from 'lodash/get';
import { dirname } from 'path';
import { parse } from 'what-the-diff';
import {
APIError,
basename,
Cursor,
localForage,
readFile,
readFileMetadata,
requestWithBackoff,
responseParser,
then,
throwOnConflictingBranches,
unsentRequest,
} from '../../lib/util';
import type { DataFile, PersistOptions } from '../../interface';
import type { ApiRequest, FetchError } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
interface Config {
apiRoot?: string;
token?: string;
branch?: string;
repo?: string;
requestFunction?: (req: ApiRequest) => Promise<Response>;
hasWriteAccess?: () => Promise<boolean>;
}
interface CommitAuthor {
name: string;
email: string;
}
type BitBucketFile = {
id: string;
type: string;
path: string;
commit?: { hash: string };
};
type BitBucketSrcResult = {
size: number;
page: number;
pagelen: number;
next: string;
previous: string;
values: BitBucketFile[];
};
type BitBucketUser = {
username: string;
display_name: string;
nickname: string;
links: {
avatar: {
href: string;
};
};
};
type BitBucketBranch = {
name: string;
target: { hash: string };
};
type BitBucketCommit = {
hash: string;
author: {
raw: string;
user: {
display_name: string;
nickname: string;
};
};
date: string;
};
export const API_NAME = 'Bitbucket';
function replace404WithEmptyResponse(err: FetchError) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return { size: 0, values: [] as BitBucketFile[] } as BitBucketSrcResult;
} else {
return Promise.reject(err);
}
}
export default class API {
apiRoot: string;
branch: string;
repo: string;
requestFunction: (req: ApiRequest) => Promise<Response>;
repoURL: string;
commitAuthor?: CommitAuthor;
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0';
this.branch = config.branch || 'main';
this.repo = config.repo || '';
this.requestFunction = config.requestFunction || unsentRequest.performRequest;
// Allow overriding this.hasWriteAccess
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
}
buildRequest = (req: ApiRequest) => {
const withRoot = unsentRequest.withRoot(this.apiRoot)(req);
if ('cache' in withRoot) {
return withRoot;
} else {
const withNoCache = unsentRequest.withNoCache(withRoot);
return withNoCache;
}
};
request = (req: ApiRequest): Promise<Response> => {
try {
return requestWithBackoff(this, req);
} catch (error: unknown) {
if (error instanceof Error) {
throw new APIError(error.message, null, API_NAME);
}
throw new APIError('Unknown api error', null, API_NAME);
}
};
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
responseToText = responseParser({ format: 'text', apiName: API_NAME });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestJSON = (req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<any>;
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
user = () => this.requestJSON('/user') as Promise<BitBucketUser>;
hasWriteAccess = async () => {
const response = await this.request(this.repoURL);
if (response.status === 404) {
throw Error('Repo not found');
}
return response.ok;
};
getBranch = async (branchName: string) => {
const branch: BitBucketBranch = await this.requestJSON(
`${this.repoURL}/refs/branches/${branchName}`,
);
return branch;
};
branchCommitSha = async (branch: string) => {
const {
target: { hash: branchSha },
}: BitBucketBranch = await this.getBranch(branch);
return branchSha;
};
defaultBranchCommitSha = () => {
return this.branchCommitSha(this.branch);
};
isFile = ({ type }: BitBucketFile) => type === 'commit_file';
getFileId = (commitHash: string, path: string) => {
return `${commitHash}/${path}`;
};
processFile = (file: BitBucketFile) => ({
id: file.id,
type: file.type,
path: file.path,
name: basename(file.path),
// BitBucket does not return file SHAs, but it does give us the
// commit SHA. Since the commit SHA will change if any files do,
// we can construct an ID using the commit SHA and the file path
// that will help with caching (though not as well as a normal
// SHA, since it will change even if the individual file itself
// 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);
readFile = async (
path: string,
sha?: string | null,
{ parseText = true, branch = this.branch, head = '' } = {},
): Promise<string | Blob> => {
const fetchContent = async () => {
const node = head ? head : await this.branchCommitSha(branch);
const content = await this.request({
url: `${this.repoURL}/src/${node}/${path}`,
cache: 'no-store',
}).then<string | Blob>(parseText ? this.responseToText : this.responseToBlob);
return content;
};
const content = await readFile(sha, fetchContent, localForage, parseText);
return content;
};
async readFileMetadata(path: string, sha: string | null | undefined) {
const fetchFileMetadata = async () => {
try {
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
url: `${this.repoURL}/commits`,
params: { path, include: this.branch },
});
const commit = values[0];
return {
author: commit.author.user
? commit.author.user.display_name || commit.author.user.nickname
: commit.author.raw,
updatedOn: commit.date,
};
} catch (e) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
async isShaExistsInBranch(branch: string, sha: string) {
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
url: `${this.repoURL}/commits`,
params: { include: branch, pagelen: '100' },
}).catch(e => {
console.info(`Failed getting commits for branch '${branch}'`, e);
return [];
});
return values.some(v => v.hash === sha);
}
getEntriesAndCursor = (jsonResponse: BitBucketSrcResult) => {
const {
size: count,
page,
pagelen: pageSize,
next,
previous: prev,
values: entries,
} = jsonResponse;
const pageCount = pageSize && count ? Math.ceil(count / pageSize) : undefined;
return {
entries,
cursor: Cursor.create({
actions: [...(next ? ['next'] : []), ...(prev ? ['prev'] : [])],
meta: { page, count, pageSize, pageCount },
data: { links: { next, prev } },
}),
};
};
listFiles = async (path: string, depth = 1, pagelen: number, branch: string) => {
const node = await this.branchCommitSha(branch);
const result: BitBucketSrcResult = await this.requestJSON({
url: `${this.repoURL}/src/${node}/${path}`,
params: {
max_depth: `${depth}`,
pagelen: `${pagelen}`,
},
}).catch(replace404WithEmptyResponse);
const { entries, cursor } = this.getEntriesAndCursor(result);
return { entries: this.processFiles(entries), cursor: cursor as Cursor };
};
traverseCursor = async (
cursor: Cursor,
action: string,
): Promise<{
cursor: Cursor;
entries: { path: string; name: string; type: string; id: string }[];
}> =>
flow([
this.requestJSON,
then(this.getEntriesAndCursor),
then<
{ cursor: Cursor; entries: BitBucketFile[] },
{ cursor: Cursor; entries: BitBucketFile[] }
>(({ cursor: newCursor, entries }) => ({
cursor: newCursor,
entries: this.processFiles(entries),
})),
])((cursor.data?.links as Record<string, unknown>)[action]);
listAllFiles = async (path: string, depth: number, branch: string) => {
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
path,
depth,
100,
branch,
);
const entries = [...initialEntries];
let currentCursor = initialCursor;
while (currentCursor && currentCursor.actions!.has('next')) {
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(
currentCursor,
'next',
);
entries.push(...newEntries);
currentCursor = newCursor;
}
return this.processFiles(entries);
};
async uploadFiles(
files: { path: string; newPath?: string; delete?: boolean }[],
{
commitMessage,
branch,
parentSha,
}: { commitMessage: string; branch: string; parentSha?: string },
) {
const formData = new FormData();
const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
files.forEach(file => {
if (file.delete) {
// delete the file
formData.append('files', file.path);
} else if (file.newPath) {
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
toMove.push({ from: file.path, to: file.newPath, contentBlob });
} else {
// add/modify the file
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
// Third param is filename header, in case path is `message`, `branch`, etc.
formData.append(file.path, contentBlob, basename(file.path));
}
});
for (const { from, to, contentBlob } of toMove) {
const sourceDir = dirname(from);
const destDir = dirname(to);
const filesBranch = parentSha ? this.branch : branch;
const files = await this.listAllFiles(sourceDir, 100, filesBranch);
for (const file of files) {
// to move a file in Bitbucket we need to delete the old path
// and upload the file content to the new path
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
// reports these files as deleted+added instead of renamed
// delete current path
formData.append('files', file.path);
// create in new path
const content =
file.path === from
? contentBlob
: await this.readFile(file.path, null, {
branch: filesBranch,
parseText: false,
});
formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
}
}
if (commitMessage) {
formData.append('message', commitMessage);
}
if (this.commitAuthor) {
const { name, email } = this.commitAuthor;
formData.append('author', `${name} <${email}>`);
}
formData.append('branch', branch);
if (parentSha) {
formData.append('parents', parentSha);
}
try {
await this.requestText({
url: `${this.repoURL}/src`,
method: 'POST',
body: formData,
});
} catch (error: unknown) {
if (error instanceof Error) {
const message = error.message || '';
// very descriptive message from Bitbucket
if (parentSha && message.includes('Something went wrong')) {
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
}
}
throw error;
}
return files;
}
async persistFiles(
dataFiles: DataFile[],
mediaFiles: (
| {
fileObj: File;
size: number;
sha: string;
raw: string;
path: string;
}
| AssetProxy
)[],
options: PersistOptions,
) {
const files = [...dataFiles, ...mediaFiles];
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
}
async getDifferences(source: string, destination: string = this.branch) {
if (source === destination) {
return [];
}
const rawDiff = await this.requestText({
url: `${this.repoURL}/diff/${source}..${destination}`,
params: {
binary: 'false',
},
});
const diffs = parse(rawDiff).map(d => {
const oldPath = d.oldPath?.replace(/b\//, '') || '';
const newPath = d.newPath?.replace(/b\//, '') || '';
const path = newPath || (oldPath as string);
return {
oldPath,
newPath,
status: d.status,
newFile: d.status === 'added',
path,
binary: d.binary || /.svg$/.test(path),
};
});
return diffs;
}
deleteFiles = (paths: string[], message: string) => {
const body = new FormData();
paths.forEach(path => {
body.append('files', path);
});
body.append('branch', this.branch);
if (message) {
body.append('message', message);
}
if (this.commitAuthor) {
const { name, email } = this.commitAuthor;
body.append('author', `${name} <${email}>`);
}
return this.request(
unsentRequest.withBody(body, unsentRequest.withMethod('POST', `${this.repoURL}/src`)),
);
};
}

View File

@ -0,0 +1,96 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import AuthenticationPage from '../../components/UI/AuthenticationPage';
import Icon from '../../components/UI/Icon';
import { ImplicitAuthenticator, NetlifyAuthenticator } from '../../lib/auth';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const BitbucketAuthenticationPage = ({
inProgress = false,
config,
base_url,
siteId,
authEndpoint,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
const [loginError, setLoginError] = useState<string | null>(null);
const [auth, authSettings] = useMemo(() => {
const { auth_type: authType = '' } = config.backend;
if (authType === 'implicit') {
const {
base_url = 'https://bitbucket.org',
auth_endpoint = 'site/oauth2/authorize',
app_id = '',
} = config.backend;
const implicityAuth = new ImplicitAuthenticator({
base_url,
auth_endpoint,
app_id,
clearHash,
});
// Complete implicit authentication if we were redirected back to from the provider.
implicityAuth.completeAuth((err, data) => {
if (err) {
setLoginError(err.toString());
return;
} else if (data) {
onLogin(data);
}
});
return [implicityAuth, { scope: 'repository:write' }];
} else {
return [
new NetlifyAuthenticator({
base_url,
site_id:
document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
auth_endpoint: authEndpoint,
}),
{ provider: 'bitbucket', scope: 'repo' },
] as const;
}
}, [authEndpoint, base_url, clearHash, config.backend, onLogin, siteId]);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
auth.authenticate(authSettings, (err, data) => {
if (err) {
setLoginError(err.toString());
} else if (data) {
onLogin(data);
}
});
},
[auth, authSettings, onLogin],
);
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}
/>
);
};
export default BitbucketAuthenticationPage;

View File

@ -0,0 +1,103 @@
import minimatch from 'minimatch';
import { unsentRequest } from '../../lib/util';
import type { ApiRequest, PointerFile } from '../../lib/util';
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
interface LfsBatchAction {
href: string;
header?: { [key: string]: string };
expires_in?: number;
expires_at?: string;
}
interface LfsBatchObject {
oid: string;
size: number;
}
interface LfsBatchObjectUpload extends LfsBatchObject {
actions?: {
upload: LfsBatchAction;
verify?: LfsBatchAction;
};
}
interface LfsBatchObjectError extends LfsBatchObject {
error: {
code: number;
message: string;
};
}
interface LfsBatchUploadResponse {
transfer?: string;
objects: (LfsBatchObjectUpload | LfsBatchObjectError)[];
}
export class GitLfsClient {
private static defaultContentHeaders = {
Accept: 'application/vnd.git-lfs+json',
['Content-Type']: 'application/vnd.git-lfs+json',
};
constructor(
public enabled: boolean,
public rootURL: string,
public patterns: string[],
private makeAuthorizedRequest: MakeAuthorizedRequest,
) {}
matchPath(path: string) {
return this.patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
}
async uploadResource(pointer: PointerFile, resource: Blob): Promise<string> {
const requests = await this.getResourceUploadRequests([pointer]);
for (const request of requests) {
await this.doUpload(request.actions!.upload, resource);
if (request.actions!.verify) {
await this.doVerify(request.actions!.verify, request);
}
}
return pointer.sha;
}
private async doUpload(upload: LfsBatchAction, resource: Blob) {
await unsentRequest.fetchWithTimeout(decodeURI(upload.href), {
method: 'PUT',
body: resource,
headers: upload.header,
});
}
private async doVerify(verify: LfsBatchAction, object: LfsBatchObject) {
this.makeAuthorizedRequest({
url: decodeURI(verify.href),
method: 'POST',
headers: { ...GitLfsClient.defaultContentHeaders, ...verify.header },
body: JSON.stringify({ oid: object.oid, size: object.size }),
});
}
private async getResourceUploadRequests(objects: PointerFile[]): Promise<LfsBatchObjectUpload[]> {
const response = await this.makeAuthorizedRequest({
url: `${this.rootURL}/objects/batch`,
method: 'POST',
headers: GitLfsClient.defaultContentHeaders,
body: JSON.stringify({
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
}),
});
return ((await response.json()) as LfsBatchUploadResponse).objects.filter(object => {
if ('error' in object) {
console.error(object.error);
return false;
}
return object.actions;
});
}
}

View File

@ -0,0 +1,541 @@
import { stripIndent } from 'common-tags';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { NetlifyAuthenticator } from '../../lib/auth';
import {
AccessTokenError,
allEntriesByFolder,
asyncLock,
basename,
blobToFileObj,
CURSOR_COMPATIBILITY_SYMBOL,
entriesByFiles,
entriesByFolder,
filterByExtension,
getBlobSHA,
getLargeMediaFilteredMediaFiles,
getLargeMediaPatternsFromGitAttributesFile,
getMediaAsBlob,
getMediaDisplayURL,
getPointerFileForMediaFileObj,
localForage,
runWithLock,
unsentRequest,
} from '../../lib/util';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import { GitLfsClient } from './git-lfs-client';
import type { Semaphore } from 'semaphore';
import type {
BackendEntry,
BackendClass,
Config,
Credentials,
DisplayURL,
ImplementationFile,
PersistOptions,
User,
} from '../../interface';
import type { ApiRequest, AsyncLock, Cursor, FetchError } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
const MAX_CONCURRENT_DOWNLOADS = 10;
const STATUS_PAGE = 'https://bitbucket.status.atlassian.com';
const BITBUCKET_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const BITBUCKET_OPERATIONAL_UNITS = ['API', 'Authentication and user management', 'Git LFS'];
type BitbucketStatusComponent = {
id: string;
name: string;
status: string;
};
// Implementation wrapper class
export default class BitbucketBackend implements BackendClass {
lock: AsyncLock;
api: API | null;
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
options: {
proxied: boolean;
API: API | null;
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
};
repo: string;
branch: string;
apiRoot: string;
baseUrl: string;
siteId: string;
token: string | null;
mediaFolder?: string;
refreshToken?: string;
refreshedTokenPromise?: Promise<string>;
authenticator?: NetlifyAuthenticator;
_mediaDisplayURLSem?: Semaphore;
largeMediaURL: string;
_largeMediaClientPromise?: Promise<GitLfsClient>;
authType: string;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
updateUserCredentials: async () => null,
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The BitBucket backend needs a "repo" in the backend configuration.');
}
this.api = this.options.API || null;
this.updateUserCredentials = this.options.updateUserCredentials;
this.repo = config.backend.repo || '';
this.branch = config.backend.branch || 'main';
this.apiRoot = config.backend.api_root || 'https://api.bitbucket.org/2.0';
this.baseUrl = config.base_url || '';
this.siteId = config.site_id || '';
this.largeMediaURL =
config.backend.large_media_url || `https://bitbucket.org/${config.backend.repo}/info/lfs`;
this.token = '';
this.mediaFolder = config.media_folder;
this.lock = asyncLock();
this.authType = config.backend.auth_type || '';
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(BITBUCKET_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: BitbucketStatusComponent) =>
BITBUCKET_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every(
(statusComponent: BitbucketStatusComponent) => statusComponent.status === 'operational',
);
})
.catch(e => {
console.warn('Failed getting BitBucket status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.api
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Bitbucket user', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
authComponent() {
return AuthenticationPage;
}
setUser(user: { token: string }) {
this.token = user.token;
this.api = new API({
requestFunction: this.apiRequestFunction,
branch: this.branch,
repo: this.repo,
});
}
requestFunction = async (req: ApiRequest) => {
const token = await this.getToken();
const authorizedRequest = unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req);
return unsentRequest.performRequest(authorizedRequest);
};
restoreUser(user: User) {
return this.authenticate(user);
}
async authenticate(state: Credentials) {
this.token = state.token as string;
this.refreshToken = state.refresh_token;
this.api = new API({
requestFunction: this.apiRequestFunction,
branch: this.branch,
repo: this.repo,
apiRoot: this.apiRoot,
});
const isCollab = await this.api.hasWriteAccess().catch(error => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a Bitbucket account with access.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your BitBucket user account does not have access to this repo.');
}
const user = await this.api.user();
// Authorized user
return {
...user,
name: user.display_name,
login: user.username,
token: state.token,
avatar_url: user.links.avatar.href,
refresh_token: state.refresh_token,
};
}
getRefreshedAccessToken() {
if (this.authType === 'implicit') {
throw new AccessTokenError(`Can't refresh access token when using implicit auth`);
}
if (this.refreshedTokenPromise) {
return this.refreshedTokenPromise;
}
// instantiating a new Authenticator on each refresh isn't ideal,
if (!this.authenticator) {
const cfg = {
base_url: this.baseUrl,
site_id: this.siteId,
};
this.authenticator = new NetlifyAuthenticator(cfg);
}
this.refreshedTokenPromise = this.authenticator!.refresh({
provider: 'bitbucket',
refresh_token: this.refreshToken as string,
})?.then(({ token, refresh_token }: { token: string; refresh_token: string }) => {
this.token = token;
this.refreshToken = refresh_token;
this.refreshedTokenPromise = undefined;
this.updateUserCredentials({ token, refresh_token });
return token;
});
return this.refreshedTokenPromise;
}
logout() {
this.token = null;
return;
}
getToken() {
if (this.refreshedTokenPromise) {
return this.refreshedTokenPromise;
}
return Promise.resolve(this.token);
}
apiRequestFunction = async (req: ApiRequest) => {
const token = (
this.refreshedTokenPromise ? await this.refreshedTokenPromise : this.token
) as string;
const authorizedRequest = unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req);
const response: Response = await unsentRequest.performRequest(authorizedRequest);
if (response.status === 401) {
const json = await response.json().catch(() => null);
if (json && json.type === 'error' && /^access token expired/i.test(json.error.message)) {
const newToken = await this.getRefreshedAccessToken();
const reqWithNewToken = unsentRequest.withHeaders(
{
Authorization: `Bearer ${newToken}`,
},
req,
) as ApiRequest;
return unsentRequest.performRequest(reqWithNewToken);
}
}
return response;
};
async entriesByFolder(folder: string, extension: string, depth: number) {
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, depth, 20, this.branch).then(({ entries, cursor: c }) => {
cursor = c.mergeMeta({ extension });
return entries.filter(e => filterByExtension(e, extension));
});
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth, this.branch);
const filtered = files.filter(file => filterByExtension(file, extension));
return filtered;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
const files = await allEntriesByFolder({
listAllFiles: () => this.listAllFiles(folder, extension, depth),
readFile,
readFileMetadata: this.api!.readFileMetadata.bind(this.api),
apiName: API_NAME,
branch: this.branch,
localForage,
folder,
extension,
depth,
getDefaultBranch: () => Promise.resolve({ name: this.branch, sha: head }),
isShaExistsInBranch: this.api!.isShaExistsInBranch.bind(this.api!),
getDifferences: (source, destination) => this.api!.getDifferences(source, destination),
getFileId: path => Promise.resolve(this.api!.getFileId(head, path)),
filterFile: file => filterByExtension(file, extension),
});
return files;
}
async entriesByFiles(files: ImplementationFile[]) {
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
}
getEntry(path: string) {
return this.api!.readFile(path).then(data => ({
file: { path, id: null },
data: data as string,
}));
}
async getMedia(mediaFolder = this.mediaFolder) {
if (!mediaFolder) {
return [];
}
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
);
}
getLargeMediaClient() {
if (!this._largeMediaClientPromise) {
this._largeMediaClientPromise = (async (): Promise<GitLfsClient> => {
const patterns = await this.api!.readFile('.gitattributes')
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
.catch((err: FetchError) => {
if (err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
} else {
console.error(err);
}
return [];
});
return new GitLfsClient(
!!(this.largeMediaURL && patterns.length > 0),
this.largeMediaURL,
patterns,
this.requestFunction,
);
})();
}
return this._largeMediaClientPromise;
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(fileObj);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
async persistEntry(entry: BackendEntry, options: PersistOptions) {
const client = await this.getLargeMediaClient();
// persistEntry is a transactional operation
return runWithLock(
this.lock,
async () =>
this.api!.persistFiles(
entry.dataFiles,
client.enabled
? await getLargeMediaFilteredMediaFiles(client, entry.assets)
: entry.assets,
options,
),
'Failed to acquire persist entry lock',
);
}
async persistMedia(
mediaFile:
| {
fileObj: File;
size: number;
sha: string;
raw: string;
path: string;
}
| AssetProxy,
options: PersistOptions,
) {
const { fileObj, path } = mediaFile;
const displayURL = URL.createObjectURL(fileObj as Blob);
const client = await this.getLargeMediaClient();
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
if (!client.enabled || !client.matchPath(fixedPath)) {
return this._persistMedia(mediaFile, options);
}
const persistMediaArgument = await getPointerFileForMediaFileObj(client, fileObj as File, path);
return {
...(await this._persistMedia(persistMediaArgument, options)),
displayURL,
};
}
async _persistMedia(
mediaFile:
| {
fileObj: File;
size: number;
sha: string;
raw: string;
path: string;
}
| AssetProxy,
options: PersistOptions,
) {
const fileObj = mediaFile.fileObj as File;
const [id] = await Promise.all([
getBlobSHA(fileObj),
this.api!.persistFiles([], [mediaFile], options),
]);
const url = URL.createObjectURL(fileObj);
return {
displayURL: url,
path: trimStart(mediaFile.path, '/k'),
name: fileObj!.name,
size: fileObj!.size,
id,
file: fileObj,
url,
};
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
traverseCursor(cursor: Cursor, action: string) {
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
const extension = cursor.meta?.extension as string | undefined;
if (extension) {
entries = entries.filter(e => filterByExtension(e, extension));
newCursor = newCursor.mergeMeta({ extension });
}
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
const entriesWithData = await entriesByFiles(
entries,
readFile,
this.api!.readFileMetadata.bind(this.api)!,
API_NAME,
);
return {
entries: entriesWithData,
cursor: newCursor,
};
});
}
async loadMediaFile(path: string, id: string, { branch }: { branch: string }) {
const readFile = async (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => {
const content = await this.api!.readFile(path, id, { branch, parseText });
return content;
};
const blob = await getMediaAsBlob(path, id, readFile);
const name = basename(path);
const fileObj = blobToFileObj(name, blob);
return {
id: path,
displayURL: URL.createObjectURL(fileObj),
path,
name,
size: fileObj.size,
file: fileObj,
};
}
}

View File

@ -0,0 +1,3 @@
export { default as BitbucketBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@ -0,0 +1,225 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useState } from 'react';
import AuthenticationPage from '../../components/UI/AuthenticationPage';
import { colors } from '../../components/UI/styles';
import type { ChangeEvent, FormEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps, User } from '../../interface';
const StyledAuthForm = styled('form')`
width: 350px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const ErrorMessage = styled('div')`
color: ${colors.errorText};
`;
function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void;
function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void;
function useNetlifyIdentifyEvent(eventName: 'error', callback: (err: Error) => void): void;
function useNetlifyIdentifyEvent(
eventName: 'login' | 'logout' | 'error',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (input?: any) => void,
): void {
useEffect(() => {
window.netlifyIdentity?.on(eventName, callback);
}, [callback, eventName]);
}
export interface GitGatewayAuthenticationPageProps
extends TranslatedProps<AuthenticationPageProps> {
handleAuth: (email: string, password: string) => Promise<User | string>;
}
const GitGatewayAuthenticationPage = ({
inProgress = false,
config,
onLogin,
handleAuth,
t,
}: GitGatewayAuthenticationPageProps) => {
const [loggedIn, setLoggedIn] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{
identity?: string;
server?: string;
email?: string;
password?: string;
}>({});
useEffect(() => {
if (!loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
onLogin(window.netlifyIdentity.currentUser());
window.netlifyIdentity.close();
}
}, [loggedIn, onLogin]);
const handleIdentityLogin = useCallback(
(user: User) => {
onLogin(user);
window.netlifyIdentity?.close();
},
[onLogin],
);
useNetlifyIdentifyEvent('login', handleIdentityLogin);
const handleIdentityLogout = useCallback(() => {
window.netlifyIdentity?.open();
}, []);
useNetlifyIdentifyEvent('logout', handleIdentityLogout);
const handleIdentityError = useCallback(
(err: Error) => {
if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) {
window.netlifyIdentity?.close();
setErrors({ identity: t('auth.errors.identitySettings') });
}
},
[t],
);
useNetlifyIdentifyEvent('error', handleIdentityError);
const handleIdentity = useCallback(() => {
const user = window.netlifyIdentity?.currentUser();
if (user) {
onLogin(user);
} else {
window.netlifyIdentity?.open();
}
}, [onLogin]);
const handleEmailChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
}, []);
const handlePasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, []);
const handleLogin = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const validationErrors: typeof errors = {};
if (!email) {
validationErrors.email = t('auth.errors.email');
}
if (!password) {
validationErrors.password = t('auth.errors.password');
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
let response: User | string;
try {
response = await handleAuth(email, password);
} catch (e: unknown) {
if (e instanceof Error) {
response = e.message;
} else {
response = 'Unknown authentication error';
}
}
if (typeof response === 'string') {
setErrors({ server: response });
setLoggedIn(false);
return;
}
onLogin(response);
},
[email, handleAuth, onLogin, password, t],
);
if (window.netlifyIdentity) {
if (errors.identity) {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={handleIdentity}
pageContent={
<a
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
target="_blank"
rel="noopener noreferrer"
>
{errors.identity}
</a>
}
t={t}
/>
);
} else {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={handleIdentity}
buttonContent={t('auth.loginWithNetlifyIdentity')}
t={t}
/>
);
}
}
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
pageContent={
<StyledAuthForm onSubmit={handleLogin}>
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
<TextField
type="text"
name="email"
label="Email"
value={email}
onChange={handleEmailChange}
fullWidth
variant="outlined"
error={Boolean(errors.email)}
helperText={errors.email ?? undefined}
/>
<TextField
type="password"
name="password"
label="Password"
value={password}
onChange={handlePasswordChange}
fullWidth
variant="outlined"
error={Boolean(errors.password)}
helperText={errors.password ?? undefined}
/>
<Button
variant="contained"
type="submit"
disabled={inProgress}
sx={{ width: 120, alignSelf: 'center' }}
>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</Button>
</StyledAuthForm>
}
t={t}
/>
);
};
export default GitGatewayAuthenticationPage;

View File

@ -0,0 +1,121 @@
import { APIError } from '../../lib/util';
import { API as GithubAPI } from '../github';
import type { FetchError } from '../../lib/util';
import type { Config as GitHubConfig } from '../github/API';
type Config = GitHubConfig & {
apiRoot: string;
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
};
export default class API extends GithubAPI {
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
constructor(config: Config) {
super(config);
this.apiRoot = config.apiRoot;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.isLargeMedia = config.isLargeMedia;
this.repoURL = '';
this.originRepoURL = '';
}
hasWriteAccess() {
return this.getDefaultBranch()
.then(() => true)
.catch((error: FetchError) => {
if (error.status === 401) {
if (error.message === 'Bad credentials') {
throw new APIError(
'Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.',
error.status,
'Git Gateway',
);
} else {
return false;
}
} else if (
error.status === 404 &&
(error.message === undefined || error.message === 'Unable to locate site configuration')
) {
throw new APIError(
`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`,
error.status,
'Git Gateway',
);
} else {
console.error('Problem fetching repo data from Git Gateway');
throw error;
}
});
}
requestHeaders(headers = {}) {
return this.tokenPromise().then(jwtToken => {
const baseHeader = {
Authorization: `Bearer ${jwtToken}`,
'Content-Type': 'application/json; charset=utf-8',
...headers,
};
return baseHeader;
});
}
handleRequestError(error: FetchError & { msg: string }, responseStatus: number) {
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
}
user() {
return Promise.resolve({ login: '', ...this.commitAuthor });
}
async getHeadReference(head: string) {
if (!this.repoOwner) {
// get the repo owner from the branch url
// this is required for returning the full head reference, e.g. owner:head
// when filtering pull requests based on the head
const branch = await this.getDefaultBranch();
const self = branch._links.self;
const regex = new RegExp('https?://.+?/repos/(.+?)/');
const owner = self.match(regex);
this.repoOwner = owner ? owner[1] : '';
}
return super.getHeadReference(head);
}
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
const commitParams: {
message: string;
tree: string;
parents: string[];
author?: { name: string; date: string };
} = {
message,
tree: changeTree.sha,
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
};
if (this.commitAuthor) {
commitParams.author = {
...this.commitAuthor,
date: new Date().toISOString(),
};
}
return this.request('/git/commits', {
method: 'POST',
body: JSON.stringify(commitParams),
});
}
nextUrlProcessor() {
return (url: string) => url.replace(/^(?:[a-z]+:\/\/.+?\/.+?\/.+?\/)/, `${this.apiRoot}/`);
}
}

View File

@ -0,0 +1,30 @@
import { unsentRequest } from '../../lib/util';
import { API as GitlabAPI } from '../gitlab';
import type { Config as GitLabConfig, CommitAuthor } from '../gitlab/API';
import type { ApiRequest } from '../../lib/util';
type Config = GitLabConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };
export default class API extends GitlabAPI {
tokenPromise: () => Promise<string>;
constructor(config: Config) {
super(config);
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = '';
}
withAuthorizationHeaders = async (req: ApiRequest) => {
const token = await this.tokenPromise();
return unsentRequest.withHeaders(
{
Authorization: `Bearer ${token}`,
},
req,
);
};
hasWriteAccess = () => Promise.resolve(true);
}

View File

@ -0,0 +1,581 @@
import React, { useCallback } from 'react';
import GoTrue from 'gotrue-js';
import ini from 'ini';
import jwtDecode from 'jwt-decode';
import get from 'lodash/get';
import intersection from 'lodash/intersection';
import pick from 'lodash/pick';
import {
AccessTokenError,
APIError,
basename,
entriesByFiles,
getLargeMediaFilteredMediaFiles,
getLargeMediaPatternsFromGitAttributesFile,
getPointerFileForMediaFileObj,
parsePointerFile,
unsentRequest,
} from '../../lib/util';
import { API as BitBucketAPI, BitbucketBackend } from '../bitbucket';
import { GitHubBackend } from '../github';
import { GitLabBackend } from '../gitlab';
import AuthenticationPage from './AuthenticationPage';
import GitHubAPI from './GitHubAPI';
import GitLabAPI from './GitLabAPI';
import { getClient } from './netlify-lfs-client';
import type { ApiRequest, Cursor } from '../../lib/util';
import type {
Config,
Credentials,
DisplayURL,
DisplayURLObject,
BackendEntry,
BackendClass,
ImplementationFile,
PersistOptions,
User,
TranslatedProps,
AuthenticationPageProps,
} from '../../interface';
import type { Client } from './netlify-lfs-client';
import type AssetProxy from '../../valueObjects/AssetProxy';
const STATUS_PAGE = 'https://www.netlifystatus.com';
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GIT_GATEWAY_OPERATIONAL_UNITS = ['Git Gateway'];
type GitGatewayStatus = {
id: string;
name: string;
status: string;
};
type NetlifyIdentity = {
logout: () => void;
currentUser: () => User;
on: (
eventName: 'init' | 'login' | 'logout' | 'error',
callback: (input?: unknown) => void,
) => void;
init: () => void;
store: { user: unknown; modal: { page: string }; saving: boolean };
open: () => void;
close: () => void;
};
type AuthClient = {
logout: () => void;
currentUser: () => unknown;
login?: (email: string, password: string, remember?: boolean) => Promise<User>;
clearStore: () => void;
};
declare global {
interface Window {
netlifyIdentity?: NetlifyIdentity;
}
}
const localHosts: Record<string, boolean> = {
localhost: true,
'127.0.0.1': true,
'0.0.0.0': true,
};
const defaults = {
identity: '/.netlify/identity',
gateway: '/.netlify/git',
largeMedia: '/.netlify/large-media',
};
function getEndpoint(endpoint: string, netlifySiteURL: string | null) {
if (
localHosts[document.location.host.split(':').shift() as string] &&
netlifySiteURL &&
endpoint.match(/^\/\.netlify\//)
) {
const parts = [];
if (netlifySiteURL) {
parts.push(netlifySiteURL);
if (!netlifySiteURL.match(/\/$/)) {
parts.push('/');
}
}
parts.push(endpoint.replace(/^\//, ''));
return parts.join('');
}
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;
user_metadata: { full_name: string; avatar_url: string };
}
export default class GitGateway implements BackendClass {
config: Config;
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
branch: string;
mediaFolder?: string;
transformImages: boolean;
gatewayUrl: string;
netlifyLargeMediaURL: string;
backendType: string | null;
apiUrl: string;
authClient?: AuthClient;
backend: GitHubBackend | GitLabBackend | BitbucketBackend | null;
acceptRoles?: string[];
tokenPromise?: () => Promise<string>;
_largeMediaClientPromise?: Promise<Client>;
options: {
proxied: boolean;
API: GitHubAPI | GitLabAPI | BitBucketAPI | null;
};
constructor(config: Config, options = {}) {
this.options = {
proxied: true,
API: null,
...options,
};
this.config = config;
this.branch = config.backend.branch?.trim() || 'main';
this.mediaFolder = config.media_folder;
const { use_large_media_transforms_in_media_library: transformImages = true } = config.backend;
this.transformImages = transformImages;
const netlifySiteURL = localStorage.getItem('netlifySiteURL');
this.apiUrl = getEndpoint(config.backend.identity_url || defaults.identity, netlifySiteURL);
this.gatewayUrl = getEndpoint(config.backend.gateway_url || defaults.gateway, netlifySiteURL);
this.netlifyLargeMediaURL = getEndpoint(
config.backend.large_media_url || defaults.largeMedia,
netlifySiteURL,
);
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
if (backendTypeMatches) {
this.backendType = backendTypeMatches[1];
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '');
} else {
this.backendType = null;
}
this.backend = null;
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: GitGatewayStatus) =>
GIT_GATEWAY_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every((statusComponent: GitGatewayStatus) => statusComponent.status === 'operational');
})
.catch(e => {
console.warn('Failed getting Git Gateway status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.tokenPromise?.()
.then(token => !!token)
.catch(e => {
console.warn('Failed getting Identity token', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
async getAuthClient() {
if (this.authClient) {
return this.authClient;
}
await initPromise;
if (window.netlifyIdentity) {
this.authClient = {
logout: () => window.netlifyIdentity?.logout(),
currentUser: () => window.netlifyIdentity?.currentUser(),
clearStore: () => {
const store = window.netlifyIdentity?.store;
if (store) {
store.user = null;
store.modal.page = 'login';
store.saving = false;
}
},
};
} else {
const goTrue = new GoTrue({ APIUrl: this.apiUrl });
this.authClient = {
logout: () => {
const user = goTrue.currentUser();
if (user) {
return user.logout();
}
},
currentUser: () => goTrue.currentUser(),
login: goTrue.login.bind(goTrue),
clearStore: () => undefined,
};
}
}
requestFunction = (req: ApiRequest) =>
this.tokenPromise!()
.then(
token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req) as ApiRequest,
)
.then(unsentRequest.performRequest);
authenticate(credentials: Credentials) {
const user = credentials as NetlifyUser;
this.tokenPromise = async () => {
try {
const func = user.jwt.bind(user);
const token = await func();
return token;
} catch (error: unknown) {
if (error instanceof Error) {
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
}
throw new AccessTokenError('Failed getting access token');
}
};
return this.tokenPromise!().then(async token => {
if (!this.backendType) {
const {
github_enabled: githubEnabled,
gitlab_enabled: gitlabEnabled,
bitbucket_enabled: bitbucketEnabled,
roles,
} = await unsentRequest
.fetchWithTimeout(`${this.gatewayUrl}/settings`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(async res => {
const contentType = res.headers.get('Content-Type') || '';
if (!contentType.includes('application/json') && !contentType.includes('text/json')) {
throw new APIError(
`Your Git Gateway backend is not returning valid settings. Please make sure it is enabled.`,
res.status,
'Git Gateway',
);
}
const body = await res.json();
if (!res.ok) {
throw new APIError(
`Git Gateway Error: ${body.message ? body.message : body}`,
res.status,
'Git Gateway',
);
}
return body;
});
this.acceptRoles = roles;
if (githubEnabled) {
this.backendType = 'github';
} else if (gitlabEnabled) {
this.backendType = 'gitlab';
} else if (bitbucketEnabled) {
this.backendType = 'bitbucket';
}
}
if (this.acceptRoles && this.acceptRoles.length > 0) {
const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
const validRole = intersection(userRoles, this.acceptRoles).length > 0;
if (!validRole) {
throw new Error("You don't have sufficient permissions to access Static CMS");
}
}
const userData = {
name: user.user_metadata.full_name || user.email.split('@').shift()!,
email: user.email,
avatar_url: user.user_metadata.avatar_url,
metadata: user.user_metadata,
};
const apiConfig = {
apiRoot: `${this.gatewayUrl}/${this.backendType}`,
branch: this.branch,
tokenPromise: this.tokenPromise!,
commitAuthor: pick(userData, ['name', 'email']),
isLargeMedia: (filename: string) => this.isLargeMediaFile(filename),
};
if (this.backendType === 'github') {
this.api = new GitHubAPI(apiConfig);
this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
} else if (this.backendType === 'gitlab') {
this.api = new GitLabAPI(apiConfig);
this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
} else if (this.backendType === 'bitbucket') {
this.api = new BitBucketAPI({
...apiConfig,
requestFunction: this.requestFunction,
hasWriteAccess: async () => true,
});
this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
}
if (!(await this.api!.hasWriteAccess())) {
throw new Error("You don't have sufficient permissions to access Static CMS");
}
return { name: userData.name, login: userData.email } as User;
});
}
async restoreUser() {
const client = await this.getAuthClient();
const user = client?.currentUser();
if (!user) {
return Promise.reject();
}
return this.authenticate(user as Credentials);
}
authComponent() {
const WrappedAuthenticationPage = (props: TranslatedProps<AuthenticationPageProps>) => {
const handleAuth = useCallback(
async (email: string, password: string): Promise<User | string> => {
try {
const authClient = await this.getAuthClient();
if (!authClient) {
return 'Auth client not started';
}
if (!authClient.login) {
return 'Auth client login function not found';
}
return authClient.login(email, password, true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
return error.description || error.msg || error;
}
},
[],
);
return <AuthenticationPage {...props} handleAuth={handleAuth} />;
};
WrappedAuthenticationPage.displayName = 'AuthenticationPage';
return WrappedAuthenticationPage;
}
async logout() {
const client = await this.getAuthClient();
try {
client?.logout();
} catch (e) {
console.error(e);
}
}
getToken() {
return this.tokenPromise!();
}
async entriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.entriesByFolder(folder, extension, depth);
}
allEntriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.allEntriesByFolder(folder, extension, depth);
}
entriesByFiles(files: ImplementationFile[]) {
return this.backend!.entriesByFiles(files);
}
getEntry(path: string) {
return this.backend!.getEntry(path);
}
async isLargeMediaFile(path: string) {
const client = await this.getLargeMediaClient();
return client.enabled && client.matchPath(path);
}
getMedia(mediaFolder = this.mediaFolder) {
return this.backend!.getMedia(mediaFolder);
}
// this method memoizes this._getLargeMediaClient so that there can
// only be one client at a time
getLargeMediaClient() {
if (this._largeMediaClientPromise) {
return this._largeMediaClientPromise;
}
this._largeMediaClientPromise = this._getLargeMediaClient();
return this._largeMediaClientPromise;
}
_getLargeMediaClient() {
const netlifyLargeMediaEnabledPromise = this.api!.readFile('.lfsconfig')
.then(config => ini.decode<{ lfs: { url: string } }>(config as string))
.then(({ lfs: { url } }) => new URL(url))
.then(lfsURL => ({
enabled: lfsURL.hostname.endsWith('netlify.com') || lfsURL.hostname.endsWith('netlify.app'),
}))
.catch((err: Error) => ({ enabled: false, err }));
const lfsPatternsPromise = this.api!.readFile('.gitattributes')
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
.then((patterns: string[]) => ({ err: null, patterns }))
.catch((err: Error) => {
if (err.message.includes('404')) {
console.info('This 404 was expected and handled appropriately.');
return { err: null, patterns: [] as string[] };
} else {
return { err, patterns: [] as string[] };
}
});
return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
const enabled = maybeEnabled && !patternsErr;
// We expect LFS patterns to exist when the .lfsconfig states
// that we're using Netlify Large Media
if (maybeEnabled && patternsErr) {
console.error(patternsErr);
}
return getClient({
enabled,
rootURL: this.netlifyLargeMediaURL,
makeAuthorizedRequest: this.requestFunction,
patterns,
transformImages: this.transformImages ? { nf_resize: 'fit', w: 560, h: 320 } : false,
});
},
);
}
async getLargeMediaDisplayURL(
{ path, id }: { path: string; id: string | null },
branch = this.branch,
) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
const items = await entriesByFiles(
[{ path, id }],
readFile,
this.api!.readFileMetadata.bind(this.api),
'Git-Gateway',
);
const entry = items[0];
const pointerFile = parsePointerFile(entry.data);
if (!pointerFile.sha) {
console.warn(`Failed parsing pointer file ${path}`);
return { url: path, blob: new Blob() };
}
const client = await this.getLargeMediaClient();
const { url, blob } = await client.getDownloadURL(pointerFile);
return { url, blob };
}
async getMediaDisplayURL(displayURL: DisplayURL) {
const { path, id } = displayURL as DisplayURLObject;
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const { url } = await this.getLargeMediaDisplayURL({ path, id });
return url;
}
if (typeof displayURL === 'string') {
return displayURL;
}
const url = await this.backend!.getMediaDisplayURL(displayURL);
return url;
}
async getMediaFile(path: string) {
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id: null });
const name = basename(path);
return {
id: url,
name,
path,
url,
displayURL: url,
file: new File([blob], name),
size: blob.size,
};
}
return this.backend!.getMediaFile(path);
}
async persistEntry(entry: BackendEntry, options: PersistOptions) {
const client = await this.getLargeMediaClient();
if (client.enabled) {
const assets = (await getLargeMediaFilteredMediaFiles(client, entry.assets)) as any;
return this.backend!.persistEntry({ ...entry, assets }, options);
} else {
return this.backend!.persistEntry(entry, options);
}
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const { fileObj, path } = mediaFile;
const displayURL = URL.createObjectURL(fileObj as Blob);
const client = await this.getLargeMediaClient();
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
const isLargeMedia = await this.isLargeMediaFile(fixedPath);
if (isLargeMedia) {
const persistMediaArgument = (await getPointerFileForMediaFileObj(
client,
fileObj as File,
path,
)) as any;
return {
...(await this.backend!.persistMedia(persistMediaArgument, options)),
displayURL,
};
}
return await this.backend!.persistMedia(mediaFile, options);
}
deleteFiles(paths: string[], commitMessage: string) {
return this.backend!.deleteFiles(paths, commitMessage);
}
traverseCursor(cursor: Cursor, action: string) {
return this.backend!.traverseCursor!(cursor, action);
}
}

View File

@ -0,0 +1,2 @@
export { default as GitGatewayBackend } from './implementation';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@ -0,0 +1,181 @@
import { flow, fromPairs, map } from 'lodash/fp';
import isPlainObject from 'lodash/isPlainObject';
import isEmpty from 'lodash/isEmpty';
import minimatch from 'minimatch';
import { unsentRequest } from '../../lib/util';
import type { ApiRequest, PointerFile } from '../../lib/util';
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
type ImageTransformations = { nf_resize: string; w: number; h: number };
type ClientConfig = {
rootURL: string;
makeAuthorizedRequest: MakeAuthorizedRequest;
patterns: string[];
enabled: boolean;
transformImages: ImageTransformations | boolean;
};
export function matchPath({ patterns }: ClientConfig, path: string) {
return patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
}
//
// API interactions
const defaultContentHeaders = {
Accept: 'application/vnd.git-lfs+json',
['Content-Type']: 'application/vnd.git-lfs+json',
};
async function resourceExists(
{ rootURL, makeAuthorizedRequest }: ClientConfig,
{ sha, size }: PointerFile,
) {
const response = await makeAuthorizedRequest({
url: `${rootURL}/verify`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify({ oid: sha, size }),
});
if (response.ok) {
return true;
}
if (response.status === 404) {
return false;
}
// TODO: what kind of error to throw here? APIError doesn't seem to fit
}
function getTransofrmationsParams(t: boolean | ImageTransformations) {
if (isPlainObject(t) && !isEmpty(t)) {
const { nf_resize: resize, w, h } = t as ImageTransformations;
return `?nf_resize=${resize}&w=${w}&h=${h}`;
}
return '';
}
async function getDownloadURL(
{ rootURL, transformImages: t, makeAuthorizedRequest }: ClientConfig,
{ sha }: PointerFile,
) {
try {
const transformation = getTransofrmationsParams(t);
const transformedPromise = makeAuthorizedRequest(`${rootURL}/origin/${sha}${transformation}`);
const [transformed, original] = await Promise.all([
transformedPromise,
// if transformation is defined, we need to load the original so we have the correct meta data
transformation ? makeAuthorizedRequest(`${rootURL}/origin/${sha}`) : transformedPromise,
]);
if (!transformed.ok) {
const error = await transformed.json();
throw new Error(
`Failed getting large media for sha '${sha}': '${error.code} - ${error.msg}'`,
);
}
const transformedBlob = await transformed.blob();
const url = URL.createObjectURL(transformedBlob);
return { url, blob: transformation ? await original.blob() : transformedBlob };
} catch (error) {
console.error(error);
return { url: '', blob: new Blob() };
}
}
function uploadOperation(objects: PointerFile[]) {
return {
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
};
}
async function getResourceUploadURLs(
{
rootURL,
makeAuthorizedRequest,
}: { rootURL: string; makeAuthorizedRequest: MakeAuthorizedRequest },
pointerFiles: PointerFile[],
) {
const response = await makeAuthorizedRequest({
url: `${rootURL}/objects/batch`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify(uploadOperation(pointerFiles)),
});
const { objects } = await response.json();
const uploadUrls = objects.map(
(object: { error?: { message: string }; actions: { upload: { href: string } } }) => {
if (object.error) {
throw new Error(object.error.message);
}
return object.actions.upload.href;
},
);
return uploadUrls;
}
function uploadBlob(uploadURL: string, blob: Blob) {
return unsentRequest.fetchWithTimeout(uploadURL, {
method: 'PUT',
body: blob,
});
}
async function uploadResource(
clientConfig: ClientConfig,
{ sha, size }: PointerFile,
resource: Blob,
) {
const existingFile = await resourceExists(clientConfig, { sha, size });
if (existingFile) {
return sha;
}
const [uploadURL] = await getResourceUploadURLs(clientConfig, [{ sha, size }]);
await uploadBlob(uploadURL, resource);
return sha;
}
//
// Create Large Media client
function configureFn(config: ClientConfig, fn: Function) {
return (...args: unknown[]) => fn(config, ...args);
}
const clientFns: Record<string, Function> = {
resourceExists,
getResourceUploadURLs,
getDownloadURL,
uploadResource,
matchPath,
};
export type Client = {
resourceExists: (pointer: PointerFile) => Promise<boolean | undefined>;
getResourceUploadURLs: (objects: PointerFile[]) => Promise<string>;
getDownloadURL: (pointer: PointerFile) => Promise<{ url: string; blob: Blob }>;
uploadResource: (pointer: PointerFile, blob: Blob) => Promise<string>;
matchPath: (path: string) => boolean;
patterns: string[];
enabled: boolean;
};
export function getClient(clientConfig: ClientConfig) {
return flow([
Object.keys,
map((key: string) => [key, configureFn(clientConfig, clientFns[key])]),
fromPairs,
configuredFns => ({
...configuredFns,
patterns: clientConfig.patterns,
enabled: clientConfig.enabled,
}),
])(clientFns);
}

View File

@ -0,0 +1,542 @@
import { Base64 } from 'js-base64';
import initial from 'lodash/initial';
import last from 'lodash/last';
import partial from 'lodash/partial';
import result from 'lodash/result';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import { dirname } from 'path';
import {
APIError,
basename,
generateContentKey,
getAllResponses,
localForage,
parseContentKey,
readFileMetadata,
requestWithBackoff,
unsentRequest,
} from '../../lib/util';
import type { Octokit } from '@octokit/rest';
import type { Semaphore } from 'semaphore';
import type { DataFile, PersistOptions } from '../../interface';
import type { ApiRequest, FetchError } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
type GitCreateTreeParamsTree = Octokit.GitCreateTreeParamsTree;
type GitHubAuthor = Octokit.GitCreateCommitResponseAuthor;
type GitHubCommitter = Octokit.GitCreateCommitResponseCommitter;
export const API_NAME = 'GitHub';
export interface Config {
apiRoot?: string;
token?: string;
branch?: string;
repo?: string;
originRepo?: string;
}
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
type TreeEntry = Override<GitCreateTreeParamsTree, { sha: string | null }>;
interface MetaDataObjects {
entry: { path: string; sha: string };
files: MediaFile[];
}
export interface Metadata {
type: string;
objects: MetaDataObjects;
branch: string;
status: string;
collection: string;
commitMessage: string;
version?: string;
user: string;
title?: string;
description?: string;
timeStamp: string;
}
export interface BlobArgs {
sha: string;
repoURL: string;
parseText: boolean;
}
type Param = string | number | undefined;
type Options = RequestInit & { params?: Record<string, Param | Record<string, Param> | string[]> };
type MediaFile = {
sha: string;
path: string;
};
export type Diff = {
path: string;
newFile: boolean;
sha: string;
binary: boolean;
};
export default class API {
apiRoot: string;
token: string;
branch: string;
repo: string;
originRepo: string;
repoOwner: string;
repoName: string;
originRepoOwner: string;
originRepoName: string;
repoURL: string;
originRepoURL: string;
_userPromise?: Promise<GitHubUser>;
_metadataSemaphore?: Semaphore;
commitAuthor?: {};
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://api.github.com';
this.token = config.token || '';
this.branch = config.branch || 'main';
this.repo = config.repo || '';
this.originRepo = config.originRepo || this.repo;
this.repoURL = `/repos/${this.repo}`;
this.originRepoURL = `/repos/${this.originRepo}`;
const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
this.repoOwner = repoParts[0];
this.repoName = repoParts[1];
this.originRepoOwner = originRepoParts[0];
this.originRepoName = originRepoParts[1];
}
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
user(): Promise<{ name: string; login: string }> {
if (!this._userPromise) {
this._userPromise = this.getUser();
}
return this._userPromise;
}
getUser() {
return this.request('/user') as Promise<GitHubUser>;
}
async hasWriteAccess() {
try {
const result: Octokit.ReposGetResponse = await this.request(this.repoURL);
// update config repoOwner to avoid case sensitivity issues with GitHub
this.repoOwner = result.owner.login;
return result.permissions.push;
} catch (error) {
console.error('Problem fetching repo data from GitHub');
throw error;
}
}
reset() {
// no op
}
requestHeaders(headers = {}) {
const baseHeader: Record<string, string> = {
'Content-Type': 'application/json; charset=utf-8',
...headers,
};
if (this.token) {
baseHeader.Authorization = `token ${this.token}`;
return Promise.resolve(baseHeader);
}
return Promise.resolve(baseHeader);
}
parseJsonResponse(response: Response) {
return response.json().then(json => {
if (!response.ok) {
return Promise.reject(json);
}
return json;
});
}
urlFor(path: string, options: Options) {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
}
}
if (params.length) {
path += `?${params.join('&')}`;
}
return this.apiRoot + path;
}
parseResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
const textPromise = response.text().then(text => {
if (!response.ok) {
return Promise.reject(text);
}
return text;
});
return textPromise;
}
handleRequestError(error: FetchError, responseStatus: number) {
throw new APIError(error.message, responseStatus, API_NAME);
}
buildRequest(req: ApiRequest) {
return req;
}
async request(
path: string,
options: Options = {},
parser = (response: Response) => this.parseResponse(response),
) {
options = { cache: 'no-cache', ...options };
const headers = await this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
let responseStatus = 500;
try {
const req = unsentRequest.fromFetchArguments(url, {
...options,
headers,
}) as unknown as ApiRequest;
const response = await requestWithBackoff(this, req);
responseStatus = response.status;
const parsedResponse = await parser(response);
return parsedResponse;
} catch (error: any) {
return this.handleRequestError(error, responseStatus);
}
}
nextUrlProcessor() {
return (url: string) => url;
}
async requestAllPages<T>(url: string, options: Options = {}) {
options = { cache: 'no-cache', ...options };
const headers = await this.requestHeaders(options.headers || {});
const processedURL = this.urlFor(url, options);
const allResponses = await getAllResponses(
processedURL,
{ ...options, headers },
'next',
this.nextUrlProcessor(),
);
const pages: T[][] = await Promise.all(
allResponses.map((res: Response) => this.parseResponse(res)),
);
return ([] as T[]).concat(...pages);
}
generateContentKey(collectionName: string, slug: string) {
return generateContentKey(collectionName, slug);
}
parseContentKey(contentKey: string) {
return parseContentKey(contentKey);
}
async readFile(
path: string,
sha?: string | null,
{
branch = this.branch,
repoURL = this.repoURL,
parseText = true,
}: {
branch?: string;
repoURL?: string;
parseText?: boolean;
} = {},
) {
if (!sha) {
sha = await this.getFileSha(path, { repoURL, branch });
}
const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
return content;
}
async readFileMetadata(path: string, sha: string | null | undefined) {
const fetchFileMetadata = async () => {
try {
const result: Octokit.ReposListCommitsResponse = await this.request(
`${this.originRepoURL}/commits`,
{
params: { path, sha: this.branch },
},
);
const { commit } = result[0];
return {
author: commit.author.name || commit.author.email,
updatedOn: commit.author.date,
};
} catch (e) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
const result: Octokit.GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
cache: 'force-cache',
});
if (parseText) {
// treat content as a utf-8 string
const content = Base64.decode(result.content);
return content;
} else {
// treat content as binary and convert to blob
const content = Base64.atob(result.content);
const byteArray = new Uint8Array(content.length);
for (let i = 0; i < content.length; i++) {
byteArray[i] = content.charCodeAt(i);
}
const blob = new Blob([byteArray]);
return blob;
}
}
async listFiles(
path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/');
try {
const result: Octokit.GitGetTreeResponse = await this.request(
`${repoURL}/git/trees/${branch}:${folder}`,
{
// GitHub API supports recursive=1 for getting the entire recursive tree
// or omitting it to get the non-recursive tree
params: depth > 1 ? { recursive: 1 } : {},
},
);
return (
result.tree
// filter only files and up to the required depth
.filter(file => file.type === 'blob' && file.path.split('/').length <= depth)
.map(file => ({
type: file.type,
id: file.sha,
name: basename(file.path),
path: `${folder}/${file.path}`,
size: file.size!,
}))
);
} catch (err: any) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return [];
} else {
throw err;
}
}
}
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
const uploadPromises = files.map(file => this.uploadBlob(file));
await Promise.all(uploadPromises);
return this.getDefaultBranch()
.then(branchData => this.updateTree(branchData.commit.sha, files as any))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
}
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
/**
* We need to request the tree first to get the SHA. We use extended SHA-1
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
* through the tree.
*/
const pathArray = path.split('/');
const filename = last(pathArray);
const directory = initial(pathArray).join('/');
const fileDataPath = encodeURIComponent(directory);
const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
const result: Octokit.GitGetTreeResponse = await this.request(fileDataURL);
const file = result.tree.find(file => file.path === filename);
if (file) {
return file.sha;
} else {
throw new APIError('Not Found', 404, API_NAME);
}
}
async deleteFiles(paths: string[], message: string) {
const branchData = await this.getDefaultBranch();
const files = paths.map(path => ({ path, sha: null }));
const changeTree = await this.updateTree(branchData.commit.sha, files);
const commit = await this.commit(message, changeTree);
await this.patchBranch(this.branch, commit.sha);
}
async createRef(type: string, name: string, sha: string) {
const result: Octokit.GitCreateRefResponse = await this.request(`${this.repoURL}/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
});
return result;
}
async patchRef(type: string, name: string, sha: string) {
const result: Octokit.GitUpdateRefResponse = await this.request(
`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`,
{
method: 'PATCH',
body: JSON.stringify({ sha }),
},
);
return result;
}
deleteRef(type: string, name: string) {
return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
async getDefaultBranch() {
const result: Octokit.ReposGetBranchResponse = await this.request(
`${this.originRepoURL}/branches/${encodeURIComponent(this.branch)}`,
);
return result;
}
patchBranch(branchName: string, sha: string) {
return this.patchRef('heads', branchName, sha);
}
async getHeadReference(head: string) {
return `${this.repoOwner}:${head}`;
}
toBase64(str: string) {
return Promise.resolve(Base64.encode(str));
}
async uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
const contentBase64 = await result(
item,
'toBase64',
partial(this.toBase64, item.raw as string),
);
const response = await this.request(`${this.repoURL}/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64',
}),
});
item.sha = response.sha;
return item;
}
async updateTree(
baseSha: string,
files: { path: string; sha: string | null; newPath?: string }[],
branch = this.branch,
) {
const toMove: { from: string; to: string; sha: string }[] = [];
const tree = files.reduce((acc, file) => {
const entry = {
path: trimStart(file.path, '/'),
mode: '100644',
type: 'blob',
sha: file.sha,
} as TreeEntry;
if (file.newPath) {
toMove.push({ from: file.path, to: file.newPath, sha: file.sha as string });
} else {
acc.push(entry);
}
return acc;
}, [] as TreeEntry[]);
for (const { from, to, sha } of toMove) {
const sourceDir = dirname(from);
const destDir = dirname(to);
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
for (const file of files) {
// delete current path
tree.push({
path: file.path,
mode: '100644',
type: 'blob',
sha: null,
});
// create in new path
tree.push({
path: file.path.replace(sourceDir, destDir),
mode: '100644',
type: 'blob',
sha: file.path === from ? sha : file.id,
});
}
}
const newTree = await this.createTree(baseSha, tree);
return { ...newTree, parentSha: baseSha };
}
async createTree(baseSha: string, tree: TreeEntry[]) {
const result: Octokit.GitCreateTreeResponse = await this.request(`${this.repoURL}/git/trees`, {
method: 'POST',
body: JSON.stringify({ base_tree: baseSha, tree }),
});
return result;
}
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
return this.createCommit(message, changeTree.sha, parents);
}
async createCommit(
message: string,
treeSha: string,
parents: string[],
author?: GitHubAuthor,
committer?: GitHubCommitter,
) {
const result: Octokit.GitCreateCommitResponse = await this.request(
`${this.repoURL}/git/commits`,
{
method: 'POST',
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
},
);
return result;
}
}

View File

@ -0,0 +1,64 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useState } from 'react';
import AuthenticationPage from '../../components/UI/AuthenticationPage';
import Icon from '../../components/UI/Icon';
import { NetlifyAuthenticator } from '../../lib/auth';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const GitHubAuthenticationPage = ({
inProgress = false,
config,
base_url,
siteId,
authEndpoint,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
const [loginError, setLoginError] = useState<string | null>(null);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const cfg = {
base_url,
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
auth_endpoint: authEndpoint,
};
const auth = new NetlifyAuthenticator(cfg);
const { auth_scope: authScope = '' } = config.backend;
const scope = authScope || 'repo';
auth.authenticate({ provider: 'github', scope }, (err, data) => {
if (err) {
setLoginError(err.toString());
} else if (data) {
onLogin(data);
}
});
},
[authEndpoint, base_url, config.backend, onLogin, siteId],
);
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}
/>
);
};
export default GitHubAuthenticationPage;

View File

@ -0,0 +1,308 @@
import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { ApolloClient } from 'apollo-client';
import { setContext } from 'apollo-link-context';
import { createHttpLink } from 'apollo-link-http';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import { APIError, localForage, readFile, throwOnConflictingBranches } from '../../lib/util';
import API, { API_NAME } from './API';
import introspectionQueryResultData from './fragmentTypes';
import * as mutations from './mutations';
import * as queries from './queries';
import type { NormalizedCacheObject } from 'apollo-cache-inmemory';
import type { MutationOptions, OperationVariables, QueryOptions } from 'apollo-client';
import type { BlobArgs, Config } from './API';
const NO_CACHE = 'no-cache';
const CACHE_FIRST = 'cache-first';
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
interface TreeEntry {
object?: {
entries: TreeEntry[];
};
type: 'blob' | 'tree';
name: string;
sha: string;
blob?: {
size: number;
};
}
interface TreeFile {
path: string;
id: string;
size: number;
type: string;
name: string;
}
export default class GraphQLAPI extends API {
client: ApolloClient<NormalizedCacheObject>;
constructor(config: Config) {
super(config);
this.client = this.getApolloClient();
}
getApolloClient() {
const authLink = setContext((_, { headers }) => {
return {
headers: {
'Content-Type': 'application/json; charset=utf-8',
...headers,
authorization: this.token ? `token ${this.token}` : '',
},
};
});
const httpLink = createHttpLink({ uri: `${this.apiRoot}/graphql` });
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({ fragmentMatcher }),
defaultOptions: {
watchQuery: {
fetchPolicy: NO_CACHE,
errorPolicy: 'ignore',
},
query: {
fetchPolicy: NO_CACHE,
errorPolicy: 'all',
},
},
});
}
reset() {
return this.client.resetStore();
}
async getRepository(owner: string, name: string) {
const { data } = await this.query({
query: queries.repository,
variables: { owner, name },
fetchPolicy: CACHE_FIRST, // repository id doesn't change
});
return data.repository;
}
query(options: QueryOptions<OperationVariables>) {
return this.client.query(options).catch(error => {
throw new APIError(error.message, 500, 'GitHub');
});
}
async mutate(options: MutationOptions<OperationVariables>) {
try {
const result = await this.client.mutate(options);
return result;
} catch (error: any) {
const errors = error.graphQLErrors;
if (Array.isArray(errors) && errors.some(e => e.message === 'Ref cannot be created.')) {
const refName = options?.variables?.createRefInput?.name || '';
const branchName = trimStart(refName, 'refs/heads/');
if (branchName) {
await throwOnConflictingBranches(branchName, name => this.getBranch(name), API_NAME);
}
}
throw new APIError(error.message, 500, 'GitHub');
}
}
async hasWriteAccess() {
const { repoOwner: owner, repoName: name } = this;
try {
const { data } = await this.query({
query: queries.repoPermission,
variables: { owner, name },
fetchPolicy: CACHE_FIRST, // we can assume permission doesn't change often
});
// https://developer.github.com/v4/enum/repositorypermission/
const { viewerPermission } = data.repository;
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission);
} catch (error: any) {
console.error('Problem fetching repo data from GitHub');
throw error;
}
}
async user() {
const { data } = await this.query({
query: queries.user,
fetchPolicy: CACHE_FIRST, // we can assume user details don't change often
});
return data.viewer;
}
async retrieveBlobObject(owner: string, name: string, expression: string, options = {}) {
const { data } = await this.query({
query: queries.blob,
variables: { owner, name, expression },
...options,
});
// https://developer.github.com/v4/object/blob/
if (data.repository.object) {
const { is_binary: isBinary, text } = data.repository.object;
return { isNull: false, isBinary, text };
} else {
return { isNull: true };
}
}
getOwnerAndNameFromRepoUrl(repoURL: string) {
let { repoOwner: owner, repoName: name } = this;
if (repoURL === this.originRepoURL) {
({ originRepoOwner: owner, originRepoName: name } = this);
}
return { owner, name };
}
async readFile(
path: string,
sha?: string | null,
{
branch = this.branch,
repoURL = this.repoURL,
parseText = true,
}: {
branch?: string;
repoURL?: string;
parseText?: boolean;
} = {},
) {
if (!sha) {
sha = await this.getFileSha(path, { repoURL, branch });
}
const fetchContent = () => this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
const content = await readFile(sha, fetchContent, localForage, parseText);
return content;
}
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
if (!parseText) {
return super.fetchBlobContent({ sha, repoURL, parseText });
}
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { isNull, isBinary, text } = await this.retrieveBlobObject(
owner,
name,
sha,
{ fetchPolicy: CACHE_FIRST }, // blob sha is derived from file content
);
if (isNull) {
throw new APIError('Not Found', 404, 'GitHub');
} else if (!isBinary) {
return text;
} else {
return super.fetchBlobContent({ sha, repoURL, parseText });
}
}
getAllFiles(entries: TreeEntry[], path: string) {
const allFiles: TreeFile[] = entries.reduce((acc, item) => {
if (item.type === 'tree') {
const entries = item.object?.entries || [];
return [...acc, ...this.getAllFiles(entries, `${path}/${item.name}`)];
} else if (item.type === 'blob') {
return [
...acc,
{
name: item.name,
type: item.type,
id: item.sha,
path: `${path}/${item.name}`,
size: item.blob ? item.blob.size : 0,
},
];
}
return acc;
}, [] as TreeFile[]);
return allFiles;
}
async listFiles(path: string, { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const folder = trim(path, '/');
const { data } = await this.query({
query: queries.files(depth),
variables: { owner, name, expression: `${branch}:${folder}` },
});
if (data.repository.object) {
const allFiles = this.getAllFiles(data.repository.object.entries, folder);
return allFiles;
} else {
return [];
}
}
getBranchQualifiedName(branch: string) {
return `refs/heads/${branch}`;
}
getBranchQuery(branch: string, owner: string, name: string) {
return {
query: queries.branch,
variables: {
owner,
name,
qualifiedName: this.getBranchQualifiedName(branch),
},
};
}
async getDefaultBranch() {
const { data } = await this.query({
...this.getBranchQuery(this.branch, this.originRepoOwner, this.originRepoName),
});
return data.repository.branch;
}
async getBranch(branch: string) {
const { data } = await this.query({
...this.getBranchQuery(branch, this.repoOwner, this.repoName),
fetchPolicy: CACHE_FIRST,
});
if (!data.repository.branch) {
throw new APIError('Branch not found', 404, API_NAME);
}
return data.repository.branch;
}
async patchRef(type: string, name: string, sha: string) {
if (type !== 'heads') {
return super.patchRef(type, name, sha);
}
const branch = await this.getBranch(name);
const { data } = await this.mutate({
mutation: mutations.updateBranch,
variables: {
input: { oid: sha, refId: branch.id },
},
});
return data!.updateRef.branch;
}
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { data } = await this.query({
query: queries.fileSha,
variables: { owner, name, expression: `${branch}:${path}` },
});
if (data.repository.file) {
return data.repository.file.sha;
}
throw new APIError('Not Found', 404, API_NAME);
}
}

View File

@ -0,0 +1,572 @@
export default {
__schema: {
types: [
{
kind: 'INTERFACE',
name: 'Node',
possibleTypes: [
{ name: 'AddedToProjectEvent' },
{ name: 'App' },
{ name: 'AssignedEvent' },
{ name: 'BaseRefChangedEvent' },
{ name: 'BaseRefForcePushedEvent' },
{ name: 'Blob' },
{ name: 'Bot' },
{ name: 'BranchProtectionRule' },
{ name: 'ClosedEvent' },
{ name: 'CodeOfConduct' },
{ name: 'CommentDeletedEvent' },
{ name: 'Commit' },
{ name: 'CommitComment' },
{ name: 'CommitCommentThread' },
{ name: 'ConvertedNoteToIssueEvent' },
{ name: 'CrossReferencedEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'DeployKey' },
{ name: 'DeployedEvent' },
{ name: 'Deployment' },
{ name: 'DeploymentEnvironmentChangedEvent' },
{ name: 'DeploymentStatus' },
{ name: 'ExternalIdentity' },
{ name: 'Gist' },
{ name: 'GistComment' },
{ name: 'HeadRefDeletedEvent' },
{ name: 'HeadRefForcePushedEvent' },
{ name: 'HeadRefRestoredEvent' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'Label' },
{ name: 'LabeledEvent' },
{ name: 'Language' },
{ name: 'License' },
{ name: 'LockedEvent' },
{ name: 'Mannequin' },
{ name: 'MarketplaceCategory' },
{ name: 'MarketplaceListing' },
{ name: 'MentionedEvent' },
{ name: 'MergedEvent' },
{ name: 'Milestone' },
{ name: 'MilestonedEvent' },
{ name: 'MovedColumnsInProjectEvent' },
{ name: 'Organization' },
{ name: 'OrganizationIdentityProvider' },
{ name: 'OrganizationInvitation' },
{ name: 'PinnedEvent' },
{ name: 'Project' },
{ name: 'ProjectCard' },
{ name: 'ProjectColumn' },
{ name: 'PublicKey' },
{ name: 'PullRequest' },
{ name: 'PullRequestCommit' },
{ name: 'PullRequestCommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
{ name: 'PullRequestReviewThread' },
{ name: 'PushAllowance' },
{ name: 'Reaction' },
{ name: 'ReadyForReviewEvent' },
{ name: 'Ref' },
{ name: 'ReferencedEvent' },
{ name: 'RegistryPackage' },
{ name: 'RegistryPackageDependency' },
{ name: 'RegistryPackageFile' },
{ name: 'RegistryPackageTag' },
{ name: 'RegistryPackageVersion' },
{ name: 'Release' },
{ name: 'ReleaseAsset' },
{ name: 'RemovedFromProjectEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'ReopenedEvent' },
{ name: 'Repository' },
{ name: 'RepositoryInvitation' },
{ name: 'RepositoryTopic' },
{ name: 'ReviewDismissalAllowance' },
{ name: 'ReviewDismissedEvent' },
{ name: 'ReviewRequest' },
{ name: 'ReviewRequestRemovedEvent' },
{ name: 'ReviewRequestedEvent' },
{ name: 'SavedReply' },
{ name: 'SecurityAdvisory' },
{ name: 'SponsorsListing' },
{ name: 'Sponsorship' },
{ name: 'Status' },
{ name: 'StatusContext' },
{ name: 'SubscribedEvent' },
{ name: 'Tag' },
{ name: 'Team' },
{ name: 'Topic' },
{ name: 'TransferredEvent' },
{ name: 'Tree' },
{ name: 'UnassignedEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UnlockedEvent' },
{ name: 'UnpinnedEvent' },
{ name: 'UnsubscribedEvent' },
{ name: 'User' },
{ name: 'UserBlockedEvent' },
{ name: 'UserContentEdit' },
{ name: 'UserStatus' },
],
},
{
kind: 'INTERFACE',
name: 'UniformResourceLocatable',
possibleTypes: [
{ name: 'Bot' },
{ name: 'ClosedEvent' },
{ name: 'Commit' },
{ name: 'CrossReferencedEvent' },
{ name: 'Gist' },
{ name: 'Issue' },
{ name: 'Mannequin' },
{ name: 'MergedEvent' },
{ name: 'Milestone' },
{ name: 'Organization' },
{ name: 'PullRequest' },
{ name: 'PullRequestCommit' },
{ name: 'ReadyForReviewEvent' },
{ name: 'Release' },
{ name: 'Repository' },
{ name: 'RepositoryTopic' },
{ name: 'ReviewDismissedEvent' },
{ name: 'User' },
],
},
{
kind: 'INTERFACE',
name: 'Actor',
possibleTypes: [
{ name: 'Bot' },
{ name: 'Mannequin' },
{ name: 'Organization' },
{ name: 'User' },
],
},
{
kind: 'INTERFACE',
name: 'RegistryPackageOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'ProjectOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'Closable',
possibleTypes: [
{ name: 'Issue' },
{ name: 'Milestone' },
{ name: 'Project' },
{ name: 'PullRequest' },
],
},
{
kind: 'INTERFACE',
name: 'Updatable',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'Project' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'UNION',
name: 'ProjectCardItem',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'Assignable',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'Comment',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'UpdatableComment',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'Labelable',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'Lockable',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'RegistryPackageSearch',
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'RepositoryOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'MemberStatusable',
possibleTypes: [{ name: 'Organization' }, { name: 'Team' }],
},
{
kind: 'INTERFACE',
name: 'ProfileOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
},
{
kind: 'UNION',
name: 'PinnableItem',
possibleTypes: [{ name: 'Gist' }, { name: 'Repository' }],
},
{
kind: 'INTERFACE',
name: 'Starrable',
possibleTypes: [{ name: 'Gist' }, { name: 'Repository' }, { name: 'Topic' }],
},
{ kind: 'INTERFACE', name: 'RepositoryInfo', possibleTypes: [{ name: 'Repository' }] },
{
kind: 'INTERFACE',
name: 'GitObject',
possibleTypes: [{ name: 'Blob' }, { name: 'Commit' }, { name: 'Tag' }, { name: 'Tree' }],
},
{
kind: 'INTERFACE',
name: 'RepositoryNode',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'CommitCommentThread' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestCommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'Subscribable',
possibleTypes: [
{ name: 'Commit' },
{ name: 'Issue' },
{ name: 'PullRequest' },
{ name: 'Repository' },
{ name: 'Team' },
],
},
{
kind: 'INTERFACE',
name: 'Deletable',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'IssueComment' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'Reactable',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'GitSignature',
possibleTypes: [
{ name: 'GpgSignature' },
{ name: 'SmimeSignature' },
{ name: 'UnknownSignature' },
],
},
{
kind: 'UNION',
name: 'RequestedReviewer',
possibleTypes: [{ name: 'User' }, { name: 'Team' }, { name: 'Mannequin' }],
},
{
kind: 'UNION',
name: 'PullRequestTimelineItem',
possibleTypes: [
{ name: 'Commit' },
{ name: 'CommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewThread' },
{ name: 'PullRequestReviewComment' },
{ name: 'IssueComment' },
{ name: 'ClosedEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'UnsubscribedEvent' },
{ name: 'MergedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'CrossReferencedEvent' },
{ name: 'AssignedEvent' },
{ name: 'UnassignedEvent' },
{ name: 'LabeledEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'MilestonedEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'LockedEvent' },
{ name: 'UnlockedEvent' },
{ name: 'DeployedEvent' },
{ name: 'DeploymentEnvironmentChangedEvent' },
{ name: 'HeadRefDeletedEvent' },
{ name: 'HeadRefRestoredEvent' },
{ name: 'HeadRefForcePushedEvent' },
{ name: 'BaseRefForcePushedEvent' },
{ name: 'ReviewRequestedEvent' },
{ name: 'ReviewRequestRemovedEvent' },
{ name: 'ReviewDismissedEvent' },
{ name: 'UserBlockedEvent' },
],
},
{
kind: 'UNION',
name: 'Closer',
possibleTypes: [{ name: 'Commit' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'ReferencedSubject',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'Assignee',
possibleTypes: [
{ name: 'Bot' },
{ name: 'Mannequin' },
{ name: 'Organization' },
{ name: 'User' },
],
},
{
kind: 'UNION',
name: 'MilestoneItem',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'RenamedTitleSubject',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'PullRequestTimelineItems',
possibleTypes: [
{ name: 'PullRequestCommit' },
{ name: 'PullRequestCommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewThread' },
{ name: 'PullRequestRevisionMarker' },
{ name: 'BaseRefChangedEvent' },
{ name: 'BaseRefForcePushedEvent' },
{ name: 'DeployedEvent' },
{ name: 'DeploymentEnvironmentChangedEvent' },
{ name: 'HeadRefDeletedEvent' },
{ name: 'HeadRefForcePushedEvent' },
{ name: 'HeadRefRestoredEvent' },
{ name: 'MergedEvent' },
{ name: 'ReviewDismissedEvent' },
{ name: 'ReviewRequestedEvent' },
{ name: 'ReviewRequestRemovedEvent' },
{ name: 'ReadyForReviewEvent' },
{ name: 'IssueComment' },
{ name: 'CrossReferencedEvent' },
{ name: 'AddedToProjectEvent' },
{ name: 'AssignedEvent' },
{ name: 'ClosedEvent' },
{ name: 'CommentDeletedEvent' },
{ name: 'ConvertedNoteToIssueEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'LabeledEvent' },
{ name: 'LockedEvent' },
{ name: 'MentionedEvent' },
{ name: 'MilestonedEvent' },
{ name: 'MovedColumnsInProjectEvent' },
{ name: 'PinnedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'RemovedFromProjectEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'TransferredEvent' },
{ name: 'UnassignedEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UnlockedEvent' },
{ name: 'UserBlockedEvent' },
{ name: 'UnpinnedEvent' },
{ name: 'UnsubscribedEvent' },
],
},
{
kind: 'UNION',
name: 'IssueOrPullRequest',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'IssueTimelineItem',
possibleTypes: [
{ name: 'Commit' },
{ name: 'IssueComment' },
{ name: 'CrossReferencedEvent' },
{ name: 'ClosedEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'UnsubscribedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'AssignedEvent' },
{ name: 'UnassignedEvent' },
{ name: 'LabeledEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UserBlockedEvent' },
{ name: 'MilestonedEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'LockedEvent' },
{ name: 'UnlockedEvent' },
{ name: 'TransferredEvent' },
],
},
{
kind: 'UNION',
name: 'IssueTimelineItems',
possibleTypes: [
{ name: 'IssueComment' },
{ name: 'CrossReferencedEvent' },
{ name: 'AddedToProjectEvent' },
{ name: 'AssignedEvent' },
{ name: 'ClosedEvent' },
{ name: 'CommentDeletedEvent' },
{ name: 'ConvertedNoteToIssueEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'LabeledEvent' },
{ name: 'LockedEvent' },
{ name: 'MentionedEvent' },
{ name: 'MilestonedEvent' },
{ name: 'MovedColumnsInProjectEvent' },
{ name: 'PinnedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'RemovedFromProjectEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'TransferredEvent' },
{ name: 'UnassignedEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UnlockedEvent' },
{ name: 'UserBlockedEvent' },
{ name: 'UnpinnedEvent' },
{ name: 'UnsubscribedEvent' },
],
},
{
kind: 'UNION',
name: 'ReviewDismissalAllowanceActor',
possibleTypes: [{ name: 'User' }, { name: 'Team' }],
},
{
kind: 'UNION',
name: 'PushAllowanceActor',
possibleTypes: [{ name: 'User' }, { name: 'Team' }],
},
{
kind: 'UNION',
name: 'PermissionGranter',
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'Team' }],
},
{ kind: 'INTERFACE', name: 'Sponsorable', possibleTypes: [{ name: 'User' }] },
{
kind: 'INTERFACE',
name: 'Contribution',
possibleTypes: [
{ name: 'CreatedCommitContribution' },
{ name: 'CreatedIssueContribution' },
{ name: 'CreatedPullRequestContribution' },
{ name: 'CreatedPullRequestReviewContribution' },
{ name: 'CreatedRepositoryContribution' },
{ name: 'JoinedGitHubContribution' },
{ name: 'RestrictedContribution' },
],
},
{
kind: 'UNION',
name: 'CreatedRepositoryOrRestrictedContribution',
possibleTypes: [
{ name: 'CreatedRepositoryContribution' },
{ name: 'RestrictedContribution' },
],
},
{
kind: 'UNION',
name: 'CreatedIssueOrRestrictedContribution',
possibleTypes: [{ name: 'CreatedIssueContribution' }, { name: 'RestrictedContribution' }],
},
{
kind: 'UNION',
name: 'CreatedPullRequestOrRestrictedContribution',
possibleTypes: [
{ name: 'CreatedPullRequestContribution' },
{ name: 'RestrictedContribution' },
],
},
{
kind: 'UNION',
name: 'SearchResultItem',
possibleTypes: [
{ name: 'Issue' },
{ name: 'PullRequest' },
{ name: 'Repository' },
{ name: 'User' },
{ name: 'Organization' },
{ name: 'MarketplaceListing' },
{ name: 'App' },
],
},
{
kind: 'UNION',
name: 'CollectionItemContent',
possibleTypes: [{ name: 'Repository' }, { name: 'Organization' }, { name: 'User' }],
},
],
},
};

View File

@ -0,0 +1,92 @@
import { gql } from 'graphql-tag';
export const repository = gql`
fragment RepositoryParts on Repository {
id
isFork
}
`;
export const blobWithText = gql`
fragment BlobWithTextParts on Blob {
id
text
is_binary: isBinary
}
`;
export const object = gql`
fragment ObjectParts on GitObject {
id
sha: oid
}
`;
export const branch = gql`
fragment BranchParts on Ref {
commit: target {
...ObjectParts
}
id
name
prefix
repository {
...RepositoryParts
}
}
${object}
${repository}
`;
export const pullRequest = gql`
fragment PullRequestParts on PullRequest {
id
baseRefName
baseRefOid
body
headRefName
headRefOid
number
state
title
merged_at: mergedAt
updated_at: updatedAt
user: author {
login
... on User {
name
}
}
repository {
...RepositoryParts
}
labels(last: 100) {
nodes {
name
}
}
}
${repository}
`;
export const treeEntry = gql`
fragment TreeEntryParts on TreeEntry {
path: name
sha: oid
type
mode
}
`;
export const fileEntry = gql`
fragment FileEntryParts on TreeEntry {
name
sha: oid
type
blob: object {
... on Blob {
size: byteSize
}
}
}
`;

View File

@ -0,0 +1,436 @@
import { stripIndent } from 'common-tags';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import {
asyncLock,
basename,
blobToFileObj,
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
entriesByFiles,
entriesByFolder,
filterByExtension,
getBlobSHA,
getMediaAsBlob,
getMediaDisplayURL,
runWithLock,
unsentRequest,
} from '../../lib/util';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import type { Octokit } from '@octokit/rest';
import type { Semaphore } from 'semaphore';
import type {
BackendEntry,
BackendClass,
Config,
Credentials,
DisplayURL,
ImplementationFile,
PersistOptions,
User,
} from '../../interface';
import type { AsyncLock } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
const MAX_CONCURRENT_DOWNLOADS = 10;
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
const { fetchWithTimeout: fetch } = unsentRequest;
const STATUS_PAGE = 'https://www.githubstatus.com';
const GITHUB_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GITHUB_OPERATIONAL_UNITS = ['API Requests', 'Issues, Pull Requests, Projects'];
type GitHubStatusComponent = {
id: string;
name: string;
status: string;
};
export default class GitHub implements BackendClass {
lock: AsyncLock;
api: API | null;
options: {
proxied: boolean;
API: API | null;
};
originRepo: string;
repo?: string;
branch: string;
apiRoot: string;
mediaFolder?: string;
token: string | null;
_currentUserPromise?: Promise<GitHubUser>;
_userIsOriginMaintainerPromises?: {
[key: string]: Promise<boolean>;
};
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
}
this.api = this.options.API || null;
this.repo = this.originRepo = config.backend.repo || '';
this.branch = config.backend.branch?.trim() || 'main';
this.apiRoot = config.backend.api_root || 'https://api.github.com';
this.token = '';
this.mediaFolder = config.media_folder;
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GITHUB_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: GitHubStatusComponent) =>
GITHUB_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every(
(statusComponent: GitHubStatusComponent) => statusComponent.status === 'operational',
);
})
.catch(e => {
console.warn('Failed getting GitHub status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.api
?.getUser()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitHub user', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user: User) {
return this.authenticate(user);
}
async currentUser({ token }: { token: string }) {
if (!this._currentUserPromise) {
this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
}
return this._currentUserPromise;
}
async userIsOriginMaintainer({
username: usernameArg,
token,
}: {
username?: string;
token: string;
}) {
const username = usernameArg || (await this.currentUser({ token })).login;
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
if (!this._userIsOriginMaintainerPromises[username]) {
this._userIsOriginMaintainerPromises[username] = fetch(
`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
{
headers: {
Authorization: `token ${token}`,
},
},
)
.then(res => res.json())
.then(({ permission }) => permission === 'admin' || permission === 'write');
}
return this._userIsOriginMaintainerPromises[username];
}
async authenticate(state: Credentials) {
this.token = state.token as string;
const apiCtor = API;
this.api = new apiCtor({
token: this.token,
branch: this.branch,
repo: this.repo,
originRepo: this.originRepo,
apiRoot: this.apiRoot,
});
const user = await this.api!.user();
const isCollab = await this.api!.hasWriteAccess().catch(error => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a GitHub account with access.
If your repo is under an organization, ensure the organization has granted access to Netlify
CMS.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your GitHub user account does not have access to this repo.');
}
// Authorized user
return { ...user, token: state.token as string };
}
logout() {
this.token = null;
if (this.api && this.api.reset && typeof this.api.reset === 'function') {
return this.api.reset();
}
}
getToken() {
return Promise.resolve(this.token);
}
getCursorAndFiles = (files: ApiFile[], page: number) => {
const pageSize = 20;
const count = files.length;
const pageCount = Math.ceil(files.length / pageSize);
const actions = [] as string[];
if (page > 1) {
actions.push('prev');
actions.push('first');
}
if (page < pageCount) {
actions.push('next');
actions.push('last');
}
const cursor = Cursor.create({
actions,
meta: { page, count, pageSize, pageCount },
data: { files },
});
const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
return { cursor, files: pageFiles };
};
async entriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.api!.originRepoURL;
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, {
repoURL,
depth,
}).then(files => {
const filtered = files.filter(file => filterByExtension(file, extension));
const result = this.getCursorAndFiles(filtered, 1);
cursor = result.cursor;
return result.files;
});
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.api!.originRepoURL;
const listFiles = () =>
this.api!.listFiles(folder, {
repoURL,
depth,
}).then(files => files.filter(file => filterByExtension(file, extension)));
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { repoURL }) as Promise<string>;
};
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return files;
}
entriesByFiles(files: ImplementationFile[]) {
const repoURL = this.api!.repoURL;
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
}
// Fetches a single entry.
getEntry(path: string) {
const repoURL = this.api!.originRepoURL;
return this.api!.readFile(path, null, { repoURL })
.then(data => ({
file: { path, id: null },
data: data as string,
}))
.catch(() => ({ file: { path, id: null }, data: '' }));
}
async getMedia(mediaFolder = this.mediaFolder) {
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 GitHub raw content urls
// for private repositories
return { id, name, size, displayURL: { id, path }, path };
}),
);
}
async getMediaFile(path: string) {
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const name = basename(path);
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
persistEntry(entry: BackendEntry, options: PersistOptions) {
// persistEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
'Failed to acquire persist entry lock',
);
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
try {
await this.api!.persistFiles([], [mediaFile], options);
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
const displayURL = URL.createObjectURL(fileObj as Blob);
return {
id: sha,
name: fileObj!.name,
size: fileObj!.size,
displayURL,
path: trimStart(path, '/'),
};
} catch (error) {
console.error(error);
throw error;
}
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
async traverseCursor(cursor: Cursor, action: string) {
const meta = cursor.meta;
const files = (cursor.data?.files ?? []) as ApiFile[];
let result: { cursor: Cursor; files: ApiFile[] };
switch (action) {
case 'first': {
result = this.getCursorAndFiles(files, 1);
break;
}
case 'last': {
result = this.getCursorAndFiles(files, (meta?.['pageCount'] as number) ?? 1);
break;
}
case 'next': {
result = this.getCursorAndFiles(files, (meta?.['page'] as number) + 1 ?? 1);
break;
}
case 'prev': {
result = this.getCursorAndFiles(files, (meta?.['page'] as number) - 1 ?? 1);
break;
}
default: {
result = this.getCursorAndFiles(files, 1);
break;
}
}
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch(
() => '',
) as Promise<string>;
const entries = await entriesByFiles(
result.files,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return {
entries,
cursor: result.cursor,
};
}
}

View File

@ -0,0 +1,3 @@
export { default as GitHubBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@ -0,0 +1,15 @@
import { gql } from 'graphql-tag';
import * as fragments from './fragments';
// updateRef only works for branches at the moment
export const updateBranch = gql`
mutation updateRef($input: UpdateRefInput!) {
updateRef(input: $input) {
branch: ref {
...BranchParts
}
}
}
${fragments.branch}
`;

View File

@ -0,0 +1,152 @@
import { gql } from 'graphql-tag';
import { oneLine } from 'common-tags';
import * as fragments from './fragments';
export const repoPermission = gql`
query repoPermission($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
viewerPermission
}
}
${fragments.repository}
`;
export const user = gql`
query {
viewer {
id
avatar_url: avatarUrl
name
login
}
}
`;
export const blob = gql`
query blob($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
... on Blob {
...BlobWithTextParts
}
}
}
}
${fragments.repository}
${fragments.blobWithText}
`;
export const statues = gql`
query statues($owner: String!, $name: String!, $sha: GitObjectID!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(oid: $sha) {
...ObjectParts
... on Commit {
status {
id
contexts {
id
context
state
target_url: targetUrl
}
}
}
}
}
}
${fragments.repository}
${fragments.object}
`;
function buildFilesQuery(depth = 1) {
const PLACE_HOLDER = 'PLACE_HOLDER';
let query = oneLine`
...ObjectParts
... on Tree {
entries {
...FileEntryParts
${PLACE_HOLDER}
}
}
`;
for (let i = 0; i < depth - 1; i++) {
query = query.replace(
PLACE_HOLDER,
oneLine`
object {
... on Tree {
entries {
...FileEntryParts
${PLACE_HOLDER}
}
}
}
`,
);
}
query = query.replace(PLACE_HOLDER, '');
return query;
}
export function files(depth: number) {
return gql`
query files($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
${buildFilesQuery(depth)}
}
}
}
${fragments.repository}
${fragments.object}
${fragments.fileEntry}
`;
}
const branchQueryPart = `
branch: ref(qualifiedName: $qualifiedName) {
...BranchParts
}
`;
export const branch = gql`
query branch($owner: String!, $name: String!, $qualifiedName: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
${branchQueryPart}
}
}
${fragments.repository}
${fragments.branch}
`;
export const repository = gql`
query repository($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
}
}
${fragments.repository}
`;
export const fileSha = gql`
query fileSha($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
file: object(expression: $expression) {
...ObjectParts
}
}
}
${fragments.repository}
${fragments.object}
`;

View File

@ -0,0 +1,50 @@
import fs from 'fs';
import fetch from 'node-fetch';
import path from 'path';
const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com';
const API_TOKEN = process.env.GITHUB_API_TOKEN;
if (!API_TOKEN) {
throw new Error('Missing environment variable GITHUB_API_TOKEN');
}
fetch(`${API_HOST}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `bearer ${API_TOKEN}` },
body: JSON.stringify({
variables: {},
query: `
{
__schema {
types {
kind
name
possibleTypes {
name
}
}
}
}
`,
}),
})
.then(result => result.json())
.then(result => {
// here we're filtering out any type information unrelated to unions or interfaces
const filteredData = result.data.__schema.types.filter(
(type: { possibleTypes: string[] | null }) => type.possibleTypes !== null,
);
result.data.__schema.types = filteredData;
fs.writeFile(
path.join(__dirname, '..', 'src', 'fragmentTypes.js'),
`module.exports = ${JSON.stringify(result.data)}`,
err => {
if (err) {
console.error('Error writing fragmentTypes file', err);
} else {
console.info('Fragment types successfully extracted!');
}
},
);
});

View File

@ -0,0 +1,540 @@
import { Base64 } from 'js-base64';
import partial from 'lodash/partial';
import result from 'lodash/result';
import trimStart from 'lodash/trimStart';
import { dirname } from 'path';
import {
APIError,
Cursor,
localForage,
parseLinkHeader,
readFile,
readFileMetadata,
requestWithBackoff,
responseParser,
throwOnConflictingBranches,
unsentRequest,
} from '../../lib/util';
import type { DataFile, PersistOptions } from '../../interface';
import type { ApiRequest, FetchError } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
export const API_NAME = 'GitLab';
export interface Config {
apiRoot?: string;
token?: string;
branch?: string;
repo?: string;
}
export interface CommitAuthor {
name: string;
email: string;
}
enum CommitAction {
CREATE = 'create',
DELETE = 'delete',
MOVE = 'move',
UPDATE = 'update',
}
type CommitItem = {
base64Content?: string;
path: string;
oldPath?: string;
action: CommitAction;
};
type FileEntry = { id: string; type: string; path: string; name: string };
interface CommitsParams {
commit_message: string;
branch: string;
author_name?: string;
author_email?: string;
actions?: {
action: string;
file_path: string;
previous_path?: string;
content?: string;
encoding?: string;
}[];
}
type GitLabCommitDiff = {
diff: string;
new_path: string;
old_path: string;
new_file: boolean;
renamed_file: boolean;
deleted_file: boolean;
};
type GitLabRepo = {
shared_with_groups: { group_access_level: number }[] | null;
permissions: {
project_access: { access_level: number } | null;
group_access: { access_level: number } | null;
};
};
type GitLabBranch = {
name: string;
developers_can_push: boolean;
developers_can_merge: boolean;
commit: {
id: string;
};
};
type GitLabCommitRef = {
type: string;
name: string;
};
type GitLabCommit = {
id: string;
short_id: string;
title: string;
author_name: string;
author_email: string;
authored_date: string;
committer_name: string;
committer_email: string;
committed_date: string;
created_at: string;
message: string;
};
export function getMaxAccess(groups: { group_access_level: number }[]) {
return groups.reduce((previous, current) => {
if (current.group_access_level > previous.group_access_level) {
return current;
}
return previous;
}, groups[0]);
}
export default class API {
apiRoot: string;
token: string | boolean;
branch: string;
repo: string;
repoURL: string;
commitAuthor?: CommitAuthor;
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4';
this.token = config.token || false;
this.branch = config.branch || 'main';
this.repo = config.repo || '';
this.repoURL = `/projects/${encodeURIComponent(this.repo)}`;
}
withAuthorizationHeaders = (req: ApiRequest) => {
const withHeaders = unsentRequest.withHeaders(
this.token ? { Authorization: `Bearer ${this.token}` } : {},
req,
);
return Promise.resolve(withHeaders);
};
buildRequest = async (req: ApiRequest) => {
const withRoot: ApiRequest = unsentRequest.withRoot(this.apiRoot)(req);
const withAuthorizationHeaders = await this.withAuthorizationHeaders(withRoot);
if ('cache' in withAuthorizationHeaders) {
return withAuthorizationHeaders;
} else {
const withNoCache: ApiRequest = unsentRequest.withNoCache(withAuthorizationHeaders);
return withNoCache;
}
};
request = async (req: ApiRequest): Promise<Response> => {
try {
return requestWithBackoff(this, req);
} catch (error: unknown) {
if (error instanceof Error) {
throw new APIError(error.message, null, API_NAME);
}
throw error;
}
};
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
responseToText = responseParser({ format: 'text', apiName: API_NAME });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestJSON = (req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<any>;
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
user = () => this.requestJSON('/user');
WRITE_ACCESS = 30;
MAINTAINER_ACCESS = 40;
hasWriteAccess = async () => {
const { shared_with_groups: sharedWithGroups, permissions }: GitLabRepo =
await this.requestJSON(this.repoURL);
const { project_access: projectAccess, group_access: groupAccess } = permissions;
if (projectAccess && projectAccess.access_level >= this.WRITE_ACCESS) {
return true;
}
if (groupAccess && groupAccess.access_level >= this.WRITE_ACCESS) {
return true;
}
// check for group write permissions
if (sharedWithGroups && sharedWithGroups.length > 0) {
const maxAccess = getMaxAccess(sharedWithGroups);
// maintainer access
if (maxAccess.group_access_level >= this.MAINTAINER_ACCESS) {
return true;
}
// developer access
if (maxAccess.group_access_level >= this.WRITE_ACCESS) {
// check permissions to merge and push
try {
const branch = await this.getDefaultBranch();
if (branch.developers_can_merge && branch.developers_can_push) {
return true;
}
} catch (e) {
console.error('Failed getting default branch', e);
}
}
}
return false;
};
readFile = async (
path: string,
sha?: string | null,
{ parseText = true, branch = this.branch } = {},
): Promise<string | Blob> => {
const fetchContent = async () => {
const content = await this.request({
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`,
params: { ref: branch },
cache: 'no-store',
}).then<Blob | string>(parseText ? this.responseToText : this.responseToBlob);
return content;
};
const content = await readFile(sha, fetchContent, localForage, parseText);
return content;
};
async readFileMetadata(path: string, sha: string | null | undefined) {
const fetchFileMetadata = async () => {
try {
const result: GitLabCommit[] = await this.requestJSON({
url: `${this.repoURL}/repository/commits`,
params: { path, ref_name: this.branch },
});
const commit = result[0];
return {
author: commit.author_name || commit.author_email,
updatedOn: commit.authored_date,
};
} catch (e) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
getCursorFromHeaders = (headers: Headers) => {
const page = parseInt(headers.get('X-Page') as string, 10);
const pageCount = parseInt(headers.get('X-Total-Pages') as string, 10);
const pageSize = parseInt(headers.get('X-Per-Page') as string, 10);
const count = parseInt(headers.get('X-Total') as string, 10);
const links = parseLinkHeader(headers.get('Link'));
const actions = Object.keys(links).flatMap(key =>
(key === 'prev' && page > 1) ||
(key === 'next' && page < pageCount) ||
(key === 'first' && page > 1) ||
(key === 'last' && page < pageCount)
? [key]
: [],
);
return Cursor.create({
actions,
meta: { page, count, pageSize, pageCount },
data: { links },
});
};
getCursor = ({ headers }: { headers: Headers }) => this.getCursorFromHeaders(headers);
// Gets a cursor without retrieving the entries by using a HEAD request
fetchCursor = (req: ApiRequest) =>
this.request(unsentRequest.withMethod('HEAD', req)).then(value => this.getCursor(value));
fetchCursorAndEntries = (
req: ApiRequest,
): Promise<{
entries: FileEntry[];
cursor: Cursor;
}> => {
const request = this.request(unsentRequest.withMethod('GET', req));
return Promise.all([
request.then(this.getCursor),
request.then(this.responseToJSON).catch((e: FetchError) => {
if (e.status === 404) {
return [];
} else {
throw e;
}
}),
]).then(([cursor, entries]) => ({ cursor, entries }));
};
listFiles = async (path: string, recursive = false) => {
const { entries, cursor } = await this.fetchCursorAndEntries({
url: `${this.repoURL}/repository/tree`,
params: { path, ref: this.branch, recursive: `${recursive}` },
});
return {
files: entries.filter(({ type }) => type === 'blob'),
cursor,
};
};
traverseCursor = async (cursor: Cursor, action: string) => {
const link = (cursor.data?.links as Record<string, ApiRequest>)[action];
const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link);
return {
entries: entries.filter(({ type }) => type === 'blob'),
cursor: newCursor,
};
};
listAllFiles = async (path: string, recursive = false, branch = this.branch) => {
const entries = [];
// eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
url: `${this.repoURL}/repository/tree`,
// Get the maximum number of entries per page
params: { path, ref: branch, per_page: '100', recursive: `${recursive}` },
});
entries.push(...initialEntries);
while (cursor && cursor.actions!.has('next')) {
const link = (cursor.data?.links as Record<string, ApiRequest>).next;
const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link);
entries.push(...newEntries);
cursor = newCursor;
}
return entries.filter(({ type }) => type === 'blob');
};
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
fromBase64 = (str: string) => Base64.decode(str);
async getBranch(branchName: string) {
const branch: GitLabBranch = await this.requestJSON(
`${this.repoURL}/repository/branches/${encodeURIComponent(branchName)}`,
);
return branch;
}
async uploadAndCommit(
items: CommitItem[],
{ commitMessage = '', branch = this.branch, newBranch = false },
) {
const actions = items.map(item => ({
action: item.action,
file_path: item.path,
...(item.oldPath ? { previous_path: item.oldPath } : {}),
...(item.base64Content !== undefined
? { content: item.base64Content, encoding: 'base64' }
: {}),
}));
const commitParams: CommitsParams = {
branch,
commit_message: commitMessage,
actions,
...(newBranch ? { start_branch: this.branch } : {}),
};
if (this.commitAuthor) {
const { name, email } = this.commitAuthor;
commitParams.author_name = name;
commitParams.author_email = email;
}
try {
const result = await this.requestJSON({
url: `${this.repoURL}/repository/commits`,
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(commitParams),
});
return result;
} catch (error: unknown) {
if (error instanceof Error) {
const message = error.message || '';
if (newBranch && message.includes(`Could not update ${branch}`)) {
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
}
}
throw error;
}
}
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
const items: CommitItem[] = await Promise.all(
files.map(async file => {
const [base64Content, fileExists] = await Promise.all([
result(file, 'toBase64', partial(this.toBase64, (file as DataFile).raw)),
this.isFileExists(file.path, branch),
]);
let action = CommitAction.CREATE;
let path = trimStart(file.path, '/');
let oldPath = undefined;
if (fileExists) {
oldPath = file.newPath && path;
action =
file.newPath && file.newPath !== oldPath ? CommitAction.MOVE : CommitAction.UPDATE;
path = file.newPath ? trimStart(file.newPath, '/') : path;
}
return {
action,
base64Content,
path,
oldPath,
};
}),
);
// move children
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);
children
.filter(f => f.path !== item.oldPath)
.forEach(file => {
items.push({
action: CommitAction.MOVE,
path: file.path.replace(sourceDir, destDir),
oldPath: file.path,
});
});
}
return items;
}
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
const items = await this.getCommitItems(files, this.branch);
return this.uploadAndCommit(items, {
commitMessage: options.commitMessage,
});
}
deleteFiles = (paths: string[], commitMessage: string) => {
const branch = this.branch;
const commitParams: CommitsParams = { commit_message: commitMessage, branch };
if (this.commitAuthor) {
const { name, email } = this.commitAuthor;
commitParams.author_name = name;
commitParams.author_email = email;
}
const items = paths.map(path => ({ path, action: CommitAction.DELETE }));
return this.uploadAndCommit(items, {
commitMessage,
});
};
async getFileId(path: string, branch: string) {
const request = await this.request({
method: 'HEAD',
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}`,
params: { ref: branch },
});
const blobId = request.headers.get('X - Gitlab - Blob - Id') as string;
return blobId;
}
async isFileExists(path: string, branch: string) {
const fileExists = await this.requestText({
method: 'HEAD',
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}`,
params: { ref: branch },
})
.then(() => true)
.catch(error => {
if (error instanceof APIError && error.status === 404) {
return false;
}
throw error;
});
return fileExists;
}
async getDifferences(to: string, from = this.branch) {
if (to === from) {
return [];
}
const result: { diffs: GitLabCommitDiff[] } = await this.requestJSON({
url: `${this.repoURL}/repository/compare`,
params: {
from,
to,
},
});
if (result.diffs.length >= 1000) {
throw new APIError('Diff limit reached', null, API_NAME);
}
return result.diffs.map(d => {
let status = 'modified';
if (d.new_file) {
status = 'added';
} else if (d.deleted_file) {
status = 'deleted';
} else if (d.renamed_file) {
status = 'renamed';
}
return {
status,
oldPath: d.old_path,
newPath: d.new_path,
newFile: d.new_file,
path: d.new_path || d.old_path,
binary: d.diff.startsWith('Binary') || /.svg$/.test(d.new_path),
};
});
}
async getDefaultBranch() {
const branch: GitLabBranch = await this.getBranch(this.branch);
return branch;
}
async isShaExistsInBranch(branch: string, sha: string) {
const refs: GitLabCommitRef[] = await this.requestJSON({
url: `${this.repoURL}/repository/commits/${sha}/refs`,
params: {
type: 'branch',
},
});
return refs.some(r => r.name === branch);
}
}

View File

@ -0,0 +1,99 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import AuthenticationPage from '../../components/UI/AuthenticationPage';
import Icon from '../../components/UI/Icon';
import { NetlifyAuthenticator, PkceAuthenticator } from '../../lib/auth';
import { isNotEmpty } from '../../lib/util/string.util';
import type { MouseEvent } from 'react';
import type {
AuthenticationPageProps,
AuthenticatorConfig,
TranslatedProps,
} from '../../interface';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const clientSideAuthenticators = {
pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config),
} as const;
const GitLabAuthenticationPage = ({
inProgress = false,
config,
siteId,
authEndpoint,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
const [loginError, setLoginError] = useState<string | null>(null);
const auth = useMemo(() => {
const {
auth_type: authType = '',
base_url = 'https://gitlab.com',
auth_endpoint = 'oauth/authorize',
app_id = '',
} = config.backend;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (isNotEmpty(authType) && authType in clientSideAuthenticators) {
const clientSizeAuth = clientSideAuthenticators[
authType as keyof typeof clientSideAuthenticators
]({
base_url,
auth_endpoint,
app_id,
auth_token_endpoint: 'oauth/token',
clearHash,
});
// Complete implicit authentication if we were redirected back to from the provider.
clientSizeAuth.completeAuth((err, data) => {
if (err) {
setLoginError(err.toString());
} else if (data) {
onLogin(data);
}
});
return clientSizeAuth;
} else {
return new NetlifyAuthenticator({
base_url,
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
auth_endpoint: authEndpoint,
});
}
}, [authEndpoint, clearHash, config.backend, onLogin, siteId]);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
auth.authenticate({ provider: 'gitlab', scope: 'api' }, err => {
if (err) {
setLoginError(err.toString());
return;
}
});
},
[auth],
);
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}
/>
);
};
export default GitLabAuthenticationPage;

View File

@ -0,0 +1,316 @@
import { stripIndent } from 'common-tags';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import {
allEntriesByFolder,
asyncLock,
basename,
blobToFileObj,
CURSOR_COMPATIBILITY_SYMBOL,
entriesByFiles,
entriesByFolder,
filterByExtension,
getBlobSHA,
getMediaAsBlob,
getMediaDisplayURL,
localForage,
runWithLock,
} from '../../lib/util';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import type { Semaphore } from 'semaphore';
import type { AsyncLock, Cursor } from '../../lib/util';
import type {
Config,
Credentials,
DisplayURL,
BackendEntry,
BackendClass,
ImplementationFile,
PersistOptions,
User,
} from '../../interface';
import type AssetProxy from '../../valueObjects/AssetProxy';
const MAX_CONCURRENT_DOWNLOADS = 10;
export default class GitLab implements BackendClass {
lock: AsyncLock;
api: API | null;
options: {
proxied: boolean;
API: API | null;
};
repo: string;
branch: string;
apiRoot: string;
token: string | null;
mediaFolder?: string;
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The GitLab backend needs a "repo" in the backend configuration.');
}
this.api = this.options.API || null;
this.repo = config.backend.repo || '';
this.branch = config.backend.branch || 'main';
this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4';
this.token = '';
this.mediaFolder = config.media_folder;
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const auth =
(await this.api
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitLab user', e);
return false;
})) || false;
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user: User) {
return this.authenticate(user);
}
async authenticate(state: Credentials) {
this.token = state.token as string;
this.api = new API({
token: this.token,
branch: this.branch,
repo: this.repo,
apiRoot: this.apiRoot,
});
const user = await this.api.user();
const isCollab = await this.api.hasWriteAccess().catch((error: Error) => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a GitLab account with access.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your GitLab user account does not have access to this repo.');
}
// Authorized user
return { ...user, login: user.username, token: state.token as string };
}
async logout() {
this.token = null;
return;
}
getToken() {
return Promise.resolve(this.token);
}
filterFile(
folder: string,
file: { path: string; name: string },
extension: string,
depth: number,
) {
// gitlab paths include the root folder
const fileFolder = trim(file.path.split(folder)[1] || '/', '/');
return filterByExtension(file, extension) && fileFolder.split('/').length <= depth;
}
async entriesByFolder(folder: string, extension: string, depth: number) {
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, depth > 1).then(({ files, cursor: c }) => {
cursor = c.mergeMeta({ folder, extension, depth });
return files.filter(file => this.filterFile(folder, file, extension, depth));
});
const files = await entriesByFolder(
listFiles,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth > 1);
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
return filtered;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const files = await allEntriesByFolder({
listAllFiles: () => this.listAllFiles(folder, extension, depth),
readFile: this.api!.readFile.bind(this.api!),
readFileMetadata: this.api!.readFileMetadata.bind(this.api),
apiName: API_NAME,
branch: this.branch,
localForage,
folder,
extension,
depth,
getDefaultBranch: () =>
this.api!.getDefaultBranch().then(b => ({ name: b.name, sha: b.commit.id })),
isShaExistsInBranch: this.api!.isShaExistsInBranch.bind(this.api!),
getDifferences: (to, from) => this.api!.getDifferences(to, from),
getFileId: path => this.api!.getFileId(path, this.branch),
filterFile: file => this.filterFile(folder, file, extension, depth),
customFetch: undefined,
});
return files;
}
entriesByFiles(files: ImplementationFile[]) {
return entriesByFiles(
files,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
}
// Fetches a single entry.
getEntry(path: string) {
return this.api!.readFile(path).then(data => ({
file: { path, id: null },
data: data as string,
}));
}
async getMedia(mediaFolder = this.mediaFolder) {
if (!mediaFolder) {
return [];
}
return this.api!.listAllFiles(mediaFolder).then(files =>
files.map(({ id, name, path }) => {
return { id, name, path, displayURL: { id, name, path } };
}),
);
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
async persistEntry(entry: BackendEntry, options: PersistOptions) {
// persistEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
'Failed to acquire persist entry lock',
);
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const fileObj = mediaFile.fileObj as File;
const [id] = await Promise.all([
getBlobSHA(fileObj),
this.api!.persistFiles([], [mediaFile], options),
]);
const { path } = mediaFile;
const url = URL.createObjectURL(fileObj);
return {
displayURL: url,
path: trimStart(path, '/'),
name: fileObj!.name,
size: fileObj!.size,
file: fileObj,
url,
id,
};
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
traverseCursor(cursor: Cursor, action: string) {
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
const [folder, depth, extension] = [
cursor.meta?.folder as string,
cursor.meta?.depth as number,
cursor.meta?.extension as string,
];
if (folder && depth && extension) {
entries = entries.filter(f => this.filterFile(folder, f, extension, depth));
newCursor = newCursor.mergeMeta({ folder, extension, depth });
}
const entriesWithData = await entriesByFiles(
entries,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api)!,
API_NAME,
);
return {
entries: entriesWithData,
cursor: newCursor,
};
});
}
}

View File

@ -0,0 +1,3 @@
export { default as GitLabBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@ -0,0 +1,73 @@
import { gql } from 'graphql-tag';
import { oneLine } from 'common-tags';
export const files = gql`
query files($repo: ID!, $branch: String!, $path: String!, $recursive: Boolean!, $cursor: String) {
project(fullPath: $repo) {
repository {
tree(ref: $branch, path: $path, recursive: $recursive) {
blobs(after: $cursor) {
nodes {
type
id: sha
path
name
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
}
`;
export const blobs = gql`
query blobs($repo: ID!, $branch: String!, $paths: [String!]!) {
project(fullPath: $repo) {
repository {
blobs(ref: $branch, paths: $paths) {
nodes {
id
data: rawBlob
}
}
}
}
}
`;
export function lastCommits(paths: string[]) {
const tree = paths
.map(
(path, index) => oneLine`
tree${index}: tree(ref: $branch, path: "${path}") {
lastCommit {
authorName
authoredDate
author {
id
username
name
publicEmail
}
}
}
`,
)
.join('\n');
const query = gql`
query lastCommits($repo: ID!, $branch: String!) {
project(fullPath: $repo) {
repository {
${tree}
}
}
}
`;
return query;
}

View File

@ -0,0 +1,7 @@
export { AzureBackend } from './azure';
export { BitbucketBackend } from './bitbucket';
export { GitGatewayBackend } from './git-gateway';
export { GitHubBackend } from './github';
export { GitLabBackend } from './gitlab';
export { ProxyBackend } from './proxy';
export { TestBackend } from './test';

View File

@ -0,0 +1,48 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useCallback } from 'react';
import GoBackButton from '../../components/UI/GoBackButton';
import Icon from '../../components/UI/Icon';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
const StyledAuthenticationPage = styled('section')`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
`;
const PageLogoIcon = styled(Icon)`
color: #c4c6d2;
`;
const AuthenticationPage = ({
inProgress = false,
config,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
onLogin({ token: 'fake_token' });
},
[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>
);
};
export default AuthenticationPage;

View File

@ -0,0 +1,196 @@
import { APIError, basename, blobToFileObj, unsentRequest } from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import type {
BackendEntry,
BackendClass,
Config,
DisplayURL,
ImplementationEntry,
ImplementationFile,
PersistOptions,
User,
} from '../../interface';
import type { Cursor } from '../../lib/util';
import type AssetProxy from '../../valueObjects/AssetProxy';
async function serializeAsset(assetProxy: AssetProxy) {
const base64content = await assetProxy.toBase64!();
return { path: assetProxy.path, content: base64content, encoding: 'base64' };
}
type MediaFile = {
id: string;
content: string;
encoding: string;
name: string;
path: string;
};
function deserializeMediaFile({ id, content, encoding, path, name }: MediaFile) {
let byteArray = new Uint8Array(0);
if (encoding !== 'base64') {
console.error(`Unsupported encoding '${encoding}' for file '${path}'`);
} else {
const decodedContent = atob(content);
byteArray = new Uint8Array(decodedContent.length);
for (let i = 0; i < decodedContent.length; i++) {
byteArray[i] = decodedContent.charCodeAt(i);
}
}
const blob = new Blob([byteArray]);
const file = blobToFileObj(name, blob);
const url = URL.createObjectURL(file);
return { id, name, path, file, size: file.size, url, displayURL: url };
}
export default class ProxyBackend implements BackendClass {
proxyUrl: string;
mediaFolder?: string;
options: {};
branch: string;
constructor(config: Config, options = {}) {
if (!config.backend.proxy_url) {
throw new Error('The Proxy backend needs a "proxy_url" in the backend configuration.');
}
this.branch = config.backend.branch || 'main';
this.proxyUrl = config.backend.proxy_url;
this.mediaFolder = config.media_folder;
this.options = options;
}
isGitBackend() {
return false;
}
status() {
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
}
authComponent() {
return AuthenticationPage;
}
restoreUser() {
return this.authenticate();
}
authenticate() {
return Promise.resolve() as unknown as Promise<User>;
}
logout() {
return null;
}
getToken() {
return Promise.resolve('');
}
async request(payload: { action: string; params: Record<string, unknown> }) {
const response = await unsentRequest.fetchWithTimeout(this.proxyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ branch: this.branch, ...payload }),
});
const json = await response.json();
if (response.ok) {
return json;
} else {
throw new APIError(json.error, response.status, 'Proxy');
}
}
entriesByFolder(folder: string, extension: string, depth: number) {
return this.request({
action: 'entriesByFolder',
params: { branch: this.branch, folder, extension, depth },
});
}
entriesByFiles(files: ImplementationFile[]) {
return this.request({
action: 'entriesByFiles',
params: { branch: this.branch, files },
});
}
getEntry(path: string) {
return this.request({
action: 'getEntry',
params: { branch: this.branch, path },
});
}
async persistEntry(entry: BackendEntry, options: PersistOptions) {
const assets = await Promise.all(entry.assets.map(serializeAsset));
return this.request({
action: 'persistEntry',
params: {
branch: this.branch,
dataFiles: entry.dataFiles,
assets,
options: { ...options },
},
});
}
async getMedia(mediaFolder = this.mediaFolder) {
const files: { path: string; url: string }[] = await this.request({
action: 'getMedia',
params: { branch: this.branch, mediaFolder },
});
return files.map(({ url, path }) => {
const id = url;
const name = basename(path);
return { id, name, displayURL: { id, path }, path };
});
}
async getMediaFile(path: string) {
const file = await this.request({
action: 'getMediaFile',
params: { branch: this.branch, path },
});
return deserializeMediaFile(file);
}
getMediaDisplayURL(displayURL: DisplayURL) {
return Promise.resolve(typeof displayURL === 'string' ? displayURL : displayURL.id);
}
async persistMedia(assetProxy: AssetProxy, options: PersistOptions) {
const asset = await serializeAsset(assetProxy);
const file: MediaFile = await this.request({
action: 'persistMedia',
params: { branch: this.branch, asset, options: { commitMessage: options.commitMessage } },
});
return deserializeMediaFile(file);
}
deleteFiles(paths: string[], commitMessage: string) {
return this.request({
action: 'deleteFiles',
params: { branch: this.branch, paths, options: { commitMessage } },
});
}
traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> {
throw new Error('Not supported');
}
allEntriesByFolder(
_folder: string,
_extension: string,
_depth: number,
): Promise<ImplementationEntry[]> {
throw new Error('Not supported');
}
}

View File

@ -0,0 +1,2 @@
export { default as ProxyBackend } from './implementation';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@ -0,0 +1,63 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect } from 'react';
import GoBackButton from '../../components/UI/GoBackButton';
import Icon from '../../components/UI/Icon';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
const StyledAuthenticationPage = styled('section')`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
`;
const PageLogoIcon = styled(Icon)`
color: #c4c6d2;
`;
const AuthenticationPage = ({
inProgress = false,
config,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
useEffect(() => {
/**
* Allow login screen to be skipped for demo purposes.
*/
const skipLogin = config.backend.login === false;
if (skipLogin) {
onLogin({ token: 'fake_token' });
}
}, [config.backend.login, onLogin]);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
onLogin({ token: 'fake_token' });
},
[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>
);
};
export default AuthenticationPage;

View File

@ -0,0 +1,292 @@
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 uuid from 'uuid/v4';
import { basename, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import type {
BackendEntry,
BackendClass,
Config,
DisplayURL,
ImplementationEntry,
ImplementationFile,
User,
} from '../../interface';
import type AssetProxy from '../../valueObjects/AssetProxy';
type RepoFile = { path: string; content: string | AssetProxy };
type RepoTree = { [key: string]: RepoFile | RepoTree };
declare global {
interface Window {
repoFiles: RepoTree;
}
}
window.repoFiles = window.repoFiles || {};
function getFile(path: string, tree: RepoTree) {
const segments = path.split('/');
let obj: RepoTree = tree;
while (obj && segments.length) {
obj = obj[segments.shift() as string] as RepoTree;
}
return (obj as unknown as RepoFile) || {};
}
function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) {
const segments = path.split('/');
let obj = tree;
while (segments.length > 1) {
const segment = segments.shift() as string;
obj[segment] = obj[segment] || {};
obj = obj[segment] as RepoTree;
}
(obj[segments.shift() as string] as RepoFile) = { content, path };
}
function deleteFile(path: string, tree: RepoTree) {
unset(tree, path.split('/'));
}
const pageSize = 10;
function getCursor(
folder: string,
extension: string,
entries: ImplementationEntry[],
index: number,
depth: number,
) {
const count = entries.length;
const pageCount = Math.floor(count / pageSize);
return Cursor.create({
actions: [
...(index < pageCount ? ['next', 'last'] : []),
...(index > 0 ? ['prev', 'first'] : []),
],
meta: { index, count, pageSize, pageCount },
data: { folder, extension, index, pageCount, depth },
});
}
export function getFolderFiles(
tree: RepoTree,
folder: string,
extension: string,
depth: number,
files = [] as RepoFile[],
path = folder,
) {
if (depth <= 0) {
return files;
}
Object.keys(tree[folder] || {}).forEach(key => {
if (extname(key)) {
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 files;
}
export default class TestBackend implements BackendClass {
mediaFolder?: string;
options: {};
constructor(config: Config, options = {}) {
this.options = options;
this.mediaFolder = config.media_folder;
}
isGitBackend() {
return false;
}
status() {
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
}
authComponent() {
return AuthenticationPage;
}
restoreUser() {
return this.authenticate();
}
authenticate() {
return Promise.resolve() as unknown as Promise<User>;
}
logout() {
return null;
}
getToken() {
return Promise.resolve('');
}
traverseCursor(cursor: Cursor, action: string) {
const { folder, extension, index, pageCount, depth } = cursor.data as {
folder: string;
extension: string;
index: number;
pageCount: number;
depth: number;
};
const newIndex = (() => {
if (action === 'next') {
return (index as number) + 1;
}
if (action === 'prev') {
return (index as number) - 1;
}
if (action === 'first') {
return 0;
}
if (action === 'last') {
return pageCount;
}
return 0;
})();
// TODO: stop assuming cursors are for collections
const allFiles = getFolderFiles(window.repoFiles, folder, extension, depth);
const allEntries = allFiles.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize);
const newCursor = getCursor(folder, extension, allEntries, newIndex, depth);
return Promise.resolve({ entries, cursor: newCursor });
}
entriesByFolder(folder: string, extension: string, depth: number) {
const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : [];
const entries = files.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const cursor = getCursor(folder, extension, entries, 0, depth);
const ret = take(entries, pageSize);
// TODO Remove
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return Promise.resolve(ret);
}
entriesByFiles(files: ImplementationFile[]) {
return Promise.all(
files.map(file => ({
file,
data: getFile(file.path, window.repoFiles).content as string,
})),
);
}
getEntry(path: string) {
return Promise.resolve({
file: { path, id: null },
data: getFile(path, window.repoFiles).content as string,
});
}
async persistEntry(entry: BackendEntry) {
entry.dataFiles.forEach(dataFile => {
const { path, raw } = dataFile;
writeFile(path, raw, window.repoFiles);
});
entry.assets.forEach(a => {
writeFile(a.path, a, window.repoFiles);
});
return Promise.resolve();
}
async getMedia(mediaFolder = this.mediaFolder) {
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));
}
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,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
normalizeAsset(assetProxy: AssetProxy) {
const fileObj = assetProxy.fileObj as File;
const { name, size } = fileObj;
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
const url = isError(objectUrl) ? '' : objectUrl;
const normalizedAsset = {
id: uuid(),
name,
size,
path: assetProxy.path,
url,
displayURL: url,
fileObj,
};
return normalizedAsset;
}
persistMedia(assetProxy: AssetProxy) {
const normalizedAsset = this.normalizeAsset(assetProxy);
writeFile(assetProxy.path, assetProxy, window.repoFiles);
return Promise.resolve(normalizedAsset);
}
deleteFiles(paths: string[]) {
paths.forEach(path => {
deleteFile(path, window.repoFiles);
});
return Promise.resolve();
}
async allEntriesByFolder(
folder: string,
extension: string,
depth: number,
): Promise<ImplementationEntry[]> {
return this.entriesByFolder(folder, extension, depth);
}
getMediaDisplayURL(_displayURL: DisplayURL): Promise<string> {
throw new Error('Not supported');
}
}

View File

@ -0,0 +1,2 @@
export { default as TestBackend } from './implementation';
export { default as AuthenticationPage } from './AuthenticationPage';

114
core/src/bootstrap.tsx Normal file
View File

@ -0,0 +1,114 @@
import 'symbol-observable';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { I18n } from 'react-polyglot';
import { connect, Provider } from 'react-redux';
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 { addExtensions } from './extensions';
import { getPhrases } from './lib/phrases';
import './mediaLibrary';
import { selectLocale } from './reducers/config';
import { store } from './store';
import type { AnyAction } from '@reduxjs/toolkit';
import type { ConnectedProps } from 'react-redux';
import type { Config } from './interface';
import type { RootState } from './store';
const ROOT_ID = 'nc-root';
const TranslatedApp = ({ locale, config }: AppRootProps) => {
if (!config) {
return null;
}
return (
<I18n locale={locale} messages={getPhrases(locale)}>
<ErrorBoundary showBackup config={config}>
<Router>
<App />
</Router>
</ErrorBoundary>
</I18n>
);
};
function mapDispatchToProps(state: RootState) {
return { locale: selectLocale(state.config.config), config: state.config.config };
}
const connector = connect(mapDispatchToProps);
export type AppRootProps = ConnectedProps<typeof connector>;
const ConnectedTranslatedApp = connector(TranslatedApp);
function bootstrap(opts?: { config?: Config; autoInitialize?: boolean }) {
const { config, autoInitialize = true } = opts ?? {};
/**
* Log the version number.
*/
if (typeof STATIC_CMS_CORE_VERSION === 'string') {
console.info(`static-cms-core ${STATIC_CMS_CORE_VERSION}`);
}
/**
* Get DOM element where app will mount.
*/
function getRoot() {
/**
* Return existing root if found.
*/
const existingRoot = document.getElementById(ROOT_ID);
if (existingRoot) {
return existingRoot;
}
/**
* If no existing root, create and return a new root.
*/
const newRoot = document.createElement('div');
newRoot.id = ROOT_ID;
document.body.appendChild(newRoot);
return newRoot;
}
if (autoInitialize) {
addExtensions();
}
store.dispatch(
loadConfig(config, function onLoad() {
store.dispatch(authenticateUser() as unknown as AnyAction);
}) as AnyAction,
);
/**
* Create connected root component.
*/
function Root() {
return (
<>
<Provider store={store}>
<ConnectedTranslatedApp />
</Provider>
</>
);
}
/**
* Render application root.
*/
const root = createRoot(getRoot());
root.render(<Root />);
}
export default bootstrap;

View File

@ -0,0 +1,264 @@
import { styled } from '@mui/material/styles';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import Fab from '@mui/material/Fab';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { ScrollSync } from 'react-scroll-sync';
import TopBarProgress from 'react-topbar-progress-indicator';
import { loginUser as loginUserAction } from '../../actions/auth';
import { currentBackend } from '../../backend';
import { colors, GlobalStyles } from '../../components/UI/styles';
import { history } from '../../routing/history';
import CollectionRoute from '../Collection/CollectionRoute';
import EditorRoute from '../Editor/EditorRoute';
import MediaLibrary from '../MediaLibrary/MediaLibrary';
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 NotFoundPage from './NotFoundPage';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { Collections, Credentials, TranslatedProps } from '../../interface';
import type { RootState } from '../../store';
TopBarProgress.config({
barColors: {
0: colors.active,
'1.0': colors.active,
},
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;
`;
function getDefaultPath(collections: Collections) {
const options = Object.values(collections).filter(
collection =>
collection.hide !== true && (!('files' in collection) || (collection.files?.length ?? 0) > 1),
);
if (options.length > 0) {
return `/collections/${options[0].name}`;
} else {
throw new Error('Could not find a non hidden collection');
}
}
function CollectionSearchRedirect() {
const { name } = useParams();
return <Navigate to={`/collections/${name}`} />;
}
function EditEntityRedirect() {
const { name, entryName } = useParams();
return <Navigate to={`/collections/${name}/entries/${entryName}`} />;
}
history.listen(e => {
console.log(e);
});
const App = ({
auth,
user,
config,
collections,
loginUser,
isFetching,
useMediaLibrary,
t,
scrollSyncEnabled,
}: TranslatedProps<AppProps>) => {
const configError = useCallback(() => {
return (
<ErrorContainer>
<h1>{t('app.app.errorHeader')}</h1>
<div>
<strong>{t('app.app.configErrors')}:</strong>
<ErrorCodeBlock>{config.error}</ErrorCodeBlock>
<span>{t('app.app.checkConfigYml')}</span>
</div>
</ErrorContainer>
);
}, [config.error, t]);
const handleLogin = useCallback(
(credentials: Credentials) => {
loginUser(credentials);
},
[loginUser],
);
const authenticating = useCallback(() => {
if (!config.config) {
return null;
}
const backend = currentBackend(config.config);
if (backend == null) {
return (
<div>
<h1>{t('app.app.waitingBackend')}</h1>
</div>
);
}
return (
<div>
{React.createElement(backend.authComponent(), {
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: () => history.replace('/'),
t,
})}
</div>
);
}, [auth.error, auth.isFetching, config.config, handleLogin, t]);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
if (!config.config) {
return null;
}
if (config.error) {
return configError();
}
if (config.isFetching) {
return <Loader>{t('app.app.loadingConfig')}</Loader>;
}
if (!user) {
return authenticating();
}
return (
<>
<GlobalStyles />
<ScrollSync enabled={scrollSyncEnabled}>
<>
<div id="back-to-top-anchor" />
<AppRoot id="cms-root">
<AppWrapper className="cms-wrapper">
<Snackbars />
{isFetching && <TopBarProgress />}
<Routes>
<Route path="/" element={<Navigate to={defaultPath} />} />
<Route path="/search" element={<Navigate to={defaultPath} />} />
<Route path="/collections/:name/search/" element={<CollectionSearchRedirect />} />
<Route
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/:name/new"
element={<EditorRoute collections={collections} newRecord />}
/>
<Route
path="/collections/:name/entries/:slug"
element={<EditorRoute collections={collections} />}
/>
<Route
path="/collections/:name/search/:searchTerm"
element={
<CollectionRoute
collections={collections}
isSearchResults
isSingleSearchResult
/>
}
/>
<Route
path="/collections/:name/filter/:filterTerm"
element={<CollectionRoute collections={collections} />}
/>
<Route
path="/search/:searchTerm"
element={<CollectionRoute collections={collections} isSearchResults />}
/>
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
<Route element={<NotFoundPage />} />
</Routes>
{useMediaLibrary ? <MediaLibrary /> : null}
<Alert />
<Confirm />
</AppWrapper>
</AppRoot>
<ScrollTop>
<Fab size="small" aria-label="scroll back to top">
<KeyboardArrowUpIcon />
</Fab>
</ScrollTop>
</>
</ScrollSync>
</>
);
};
function mapStateToProps(state: RootState) {
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
const user = auth.user;
const isFetching = globalUI.isFetching;
const useMediaLibrary = !mediaLibrary.externalLibrary;
const scrollSyncEnabled = scroll.isScrolling;
return {
auth,
config,
collections,
user,
isFetching,
useMediaLibrary,
scrollSyncEnabled,
};
}
const mapDispatchToProps = {
loginUser: loginUserAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type AppProps = ConnectedProps<typeof connector>;
export default connector(translate()(App) as ComponentType<AppProps>);

View File

@ -0,0 +1,212 @@
import { styled } from '@mui/material/styles';
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 Toolbar from '@mui/material/Toolbar';
import React, { useCallback, useEffect, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { logoutUser as logoutUserAction } from '../../actions/auth';
import { createNewEntry } from '../../actions/collections';
import { openMediaLibrary as openMediaLibraryAction } from '../../actions/mediaLibrary';
import { checkBackendStatus as checkBackendStatusAction } from '../../actions/status';
import { buttons, colors } from '../../components/UI/styles';
import { stripProtocol } from '../../lib/urlHelper';
import NavLink from '../UI/NavLink';
import SettingsDropdown from '../UI/SettingsDropdown';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { TranslatedProps } from '../../interface';
import type { RootState } from '../../store';
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] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const handleCreatePostClick = useCallback((collectionName: string) => {
createNewEntry(collectionName);
}, []);
const createableCollections = useMemo(
() => Object.values(collections).filter(collection => collection.create),
[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>
{createableCollections.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',
}}
>
{createableCollections.map(collection => (
<MenuItem
key={collection.name}
onClick={() => handleCreatePostClick(collection.name)}
>
{collection.label_singular || collection.label}
</MenuItem>
))}
</Menu>
</div>
)}
{isTestRepo && (
<Button
href="https://staticjscms.github.io/static-cms/docs/test-backend"
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none' }}
endIcon={<OpenInNewIcon />}
>
Test Backend
</Button>
)}
{displayUrl ? (
<Button
href={displayUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none' }}
endIcon={<OpenInNewIcon />}
>
{stripProtocol(displayUrl)}
</Button>
) : null}
<SettingsDropdown
displayUrl={displayUrl}
isTestRepo={isTestRepo}
imageUrl={user?.avatar_url}
onLogoutClick={logoutUser}
/>
</StyledAppHeaderActions>
</StyledToolbar>
</StyledAppBar>
);
};
function mapStateToProps(state: RootState) {
const { auth, config, collections, mediaLibrary } = state;
const user = auth.user;
const showMediaButton = mediaLibrary.showMediaButton;
return {
user,
collections,
displayUrl: config.config?.display_url,
isTestRepo: config.config?.backend.name === 'test-repo',
showMediaButton,
};
}
const mapDispatchToProps = {
checkBackendStatus: checkBackendStatusAction,
openMediaLibrary: openMediaLibraryAction,
logoutUser: logoutUserAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type HeaderProps = ConnectedProps<typeof connector>;
export default connector(translate()(Header) as ComponentType<HeaderProps>);

View File

@ -0,0 +1,49 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import TopBarProgress from 'react-topbar-progress-indicator';
import { colors } from '../../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: 1200px;
max-width: 1440px;
margin: 0 auto;
display: flex;
gap: 24px;
position: relative;
`;
interface MainViewProps {
children: ReactNode;
}
const MainView = ({ children }: MainViewProps) => {
return (
<>
<Header />
<StyledMainContainerWrapper>
<StyledMainContainer>{children}</StyledMainContainer>
</StyledMainContainerWrapper>
</>
);
};
export default MainView;

View File

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

View File

@ -0,0 +1,296 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
changeViewStyle as changeViewStyleAction,
filterByField as filterByFieldAction,
groupByField as groupByFieldAction,
sortByField as sortByFieldAction,
} from '../../actions/entries';
import { components } from '../../components/UI/styles';
import { SortDirection } from '../../interface';
import { getNewEntryUrl } from '../../lib/urlHelper';
import {
selectSortableFields,
selectViewFilters,
selectViewGroups,
} from '../../lib/util/collection.util';
import {
selectEntriesFilter,
selectEntriesGroup,
selectEntriesSort,
selectViewStyle,
} from '../../reducers/entries';
import CollectionControls from './CollectionControls';
import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import Sidebar from './Sidebar';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { Collection, TranslatedProps, ViewFilter, ViewGroup } from '../../interface';
import type { RootState } from '../../store';
const CollectionMain = styled('main')`
width: 100%;
`;
const SearchResultContainer = styled('div')`
${components.cardTop};
margin-bottom: 22px;
`;
const SearchResultHeading = styled('h1')`
${components.cardTopHeading};
`;
const CollectionView = ({
collection,
collections,
collectionName,
isSearchEnabled,
isSearchResults,
isSingleSearchResult,
searchTerm,
sortableFields,
sortByField,
sort,
viewFilters,
viewGroups,
filterTerm,
t,
filterByField,
groupByField,
filter,
group,
changeViewStyle,
viewStyle,
}: TranslatedProps<CollectionViewProps>) => {
const [readyToLoad, setReadyToLoad] = useState(false);
const [prevCollection, setPrevCollection] = useState<Collection | null>();
useEffect(() => {
setPrevCollection(collection);
}, [collection]);
const newEntryUrl = useMemo(() => {
let url = collection.create ? getNewEntryUrl(collectionName) : '';
if (url && filterTerm) {
url = getNewEntryUrl(collectionName);
if (filterTerm) {
url = `${newEntryUrl}?path=${filterTerm}`;
}
}
return url;
}, [collection, collectionName, filterTerm]);
const searchResultKey = useMemo(
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
[isSingleSearchResult],
);
const entries = useMemo(() => {
if (isSearchResults) {
let searchCollections = collections;
if (isSingleSearchResult) {
const searchCollection = Object.values(collections).filter(c => c === collection);
if (searchCollection.length === 1) {
searchCollections = {
[searchCollection[0].name]: searchCollection[0],
};
}
}
return <EntriesSearch collections={searchCollections} searchTerm={searchTerm} />;
}
return (
<EntriesCollection
collection={collection}
viewStyle={viewStyle}
filterTerm={filterTerm}
readyToLoad={readyToLoad && collection === prevCollection}
/>
);
}, [
collection,
collections,
filterTerm,
isSearchResults,
isSingleSearchResult,
prevCollection,
readyToLoad,
searchTerm,
viewStyle,
]);
const onSortClick = useCallback(
async (key: string, direction?: SortDirection) => {
await sortByField(collection, key, direction);
},
[collection, sortByField],
);
const onFilterClick = useCallback(
async (filter: ViewFilter) => {
await filterByField(collection, filter);
},
[collection, filterByField],
);
const onGroupClick = useCallback(
async (group: ViewGroup) => {
await groupByField(collection, group);
},
[collection, groupByField],
);
useEffect(() => {
if (prevCollection === collection) {
if (!readyToLoad) {
setReadyToLoad(true);
}
return;
}
if (sort?.[0]?.key) {
if (!readyToLoad) {
setReadyToLoad(true);
}
return;
}
const defaultSort = collection.sortable_fields.default;
if (!defaultSort || !defaultSort.field) {
if (!readyToLoad) {
setReadyToLoad(true);
}
return;
}
setReadyToLoad(false);
let alive = true;
const sortEntries = () => {
setTimeout(async () => {
await onSortClick(defaultSort.field, defaultSort.direction ?? SortDirection.Ascending);
if (alive) {
setReadyToLoad(true);
}
});
};
sortEntries();
return () => {
alive = false;
};
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
return (
<>
<Sidebar
collections={collections}
collection={(!isSearchResults || isSingleSearchResult) && collection}
isSearchEnabled={isSearchEnabled}
searchTerm={searchTerm}
filterTerm={filterTerm}
/>
<CollectionMain>
<>
{isSearchResults ? (
<SearchResultContainer>
<SearchResultHeading>
{t(searchResultKey, { searchTerm, collection: collection.label })}
</SearchResultHeading>
</SearchResultContainer>
) : (
<>
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
<CollectionControls
viewStyle={viewStyle}
onChangeViewStyle={changeViewStyle}
sortableFields={sortableFields}
onSortClick={onSortClick}
sort={sort}
viewFilters={viewFilters}
viewGroups={viewGroups}
t={t}
onFilterClick={onFilterClick}
onGroupClick={onGroupClick}
filter={filter}
group={group}
/>
</>
)}
{entries}
</>
</CollectionMain>
</>
);
};
interface CollectionViewOwnProps {
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
name: string;
searchTerm?: string;
filterTerm?: string;
}
function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionViewOwnProps>) {
const { collections } = state;
const isSearchEnabled = state.config.config && state.config.config.search != false;
const {
isSearchResults,
isSingleSearchResult,
name,
searchTerm = '',
filterTerm = '',
t,
} = ownProps;
const collection: Collection = name ? collections[name] : collections[0];
const sort = selectEntriesSort(state.entries, collection.name);
const sortableFields = selectSortableFields(collection, t);
const viewFilters = selectViewFilters(collection);
const viewGroups = selectViewGroups(collection);
const filter = selectEntriesFilter(state.entries, collection.name);
const group = selectEntriesGroup(state.entries, collection.name);
const viewStyle = selectViewStyle(state.entries);
return {
isSearchResults,
isSingleSearchResult,
name,
searchTerm,
filterTerm,
collection,
collections,
collectionName: name,
isSearchEnabled,
sort,
sortableFields,
viewFilters,
viewGroups,
filter,
group,
viewStyle,
};
}
const mapDispatchToProps = {
sortByField: sortByFieldAction,
filterByField: filterByFieldAction,
changeViewStyle: changeViewStyleAction,
groupByField: groupByFieldAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type CollectionViewProps = ConnectedProps<typeof connector>;
export default translate()(connector(CollectionView)) as ComponentType<CollectionViewOwnProps>;

View File

@ -0,0 +1,82 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import FilterControl from './FilterControl';
import GroupControl from './GroupControl';
import SortControl from './SortControl';
import ViewStyleControl from './ViewStyleControl';
import type { CollectionViewStyle } from '../../constants/collectionViews';
import type {
FilterMap,
GroupMap,
SortableField,
SortDirection,
SortMap,
TranslatedProps,
ViewFilter,
ViewGroup,
} from '../../interface';
const CollectionControlsContainer = styled('div')`
display: flex;
align-items: center;
flex-direction: row-reverse;
margin-top: 22px;
max-width: 100%;
& > div {
margin-left: 6px;
}
`;
interface CollectionControlsProps {
viewStyle: CollectionViewStyle;
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
sortableFields: SortableField[];
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
sort: SortMap | undefined;
filter: Record<string, FilterMap>;
viewFilters: ViewFilter[];
onFilterClick: (filter: ViewFilter) => void;
group: Record<string, GroupMap>;
viewGroups: ViewGroup[];
onGroupClick: (filter: ViewGroup) => void;
}
const CollectionControls = ({
viewStyle,
onChangeViewStyle,
sortableFields,
onSortClick,
sort,
viewFilters,
viewGroups,
onFilterClick,
onGroupClick,
t,
filter,
group,
}: TranslatedProps<CollectionControlsProps>) => {
return (
<CollectionControlsContainer>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{viewGroups.length > 0 && (
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
)}
{viewFilters.length > 0 && (
<FilterControl
viewFilters={viewFilters}
onFilterClick={onFilterClick}
t={t}
filter={filter}
/>
)}
{sortableFields.length > 0 && (
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
)}
</CollectionControlsContainer>
);
};
export default CollectionControls;

View File

@ -0,0 +1,60 @@
import React, { useMemo } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import MainView from '../App/MainView';
import Collection from './Collection';
import type { Collections } from '../../interface';
function getDefaultPath(collections: Collections) {
const first = Object.values(collections).filter(collection => collection.hide !== true)[0];
if (first) {
return `/collections/${first.name}`;
} else {
throw new Error('Could not find a non hidden collection');
}
}
interface CollectionRouteProps {
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
collections: Collections;
}
const CollectionRoute = ({
isSearchResults,
isSingleSearchResult,
collections,
}: CollectionRouteProps) => {
const { name, searchTerm, filterTerm } = useParams();
const collection = useMemo(() => {
if (!name) {
return false;
}
return collections[name];
}, [collections, name]);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
if (!name || !collection) {
return <Navigate to={defaultPath} />;
}
if ('files' in collection && collection.files?.length === 1) {
return <Navigate to={`/collections/${collection.name}/entries/${collection.files[0].name}`} />;
}
return (
<MainView>
<Collection
name={name}
searchTerm={searchTerm}
filterTerm={filterTerm}
isSearchResults={isSearchResults}
isSingleSearchResult={isSingleSearchResult}
/>
</MainView>
);
};
export default CollectionRoute;

View File

@ -0,0 +1,229 @@
import { styled } from '@mui/material/styles';
import SearchIcon from '@mui/icons-material/Search';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useState } from 'react';
import { translate } from 'react-polyglot';
import { transientOptions } from '../../lib';
import { colors, colorsRaw, lengths, zIndex } from '../../components/UI/styles';
import type { KeyboardEvent, MouseEvent } from 'react';
import type { Collection, Collections, TranslatedProps } from '../../interface';
const SearchContainer = styled('div')`
position: relative;
`;
const SuggestionsContainer = styled('div')`
position: relative;
width: 100%;
`;
const Suggestions = styled('ul')`
position: absolute;
top: 0px;
left: 0;
right: 0;
padding: 10px 0;
margin: 0;
list-style: none;
background-color: #fff;
border-radius: ${lengths.borderRadius};
border: 1px solid ${colors.textFieldBorder};
z-index: ${zIndex.zIndex1};
`;
const SuggestionHeader = styled('li')`
padding: 0 6px 6px 32px;
font-size: 12px;
color: ${colors.text};
`;
interface SuggestionItemProps {
$isActive: boolean;
}
const SuggestionItem = styled(
'li',
transientOptions,
)<SuggestionItemProps>(
({ $isActive }) => `
color: ${$isActive ? colors.active : colorsRaw.grayDark};
background-color: ${$isActive ? colors.activeBackground : 'inherit'};
padding: 6px 6px 6px 32px;
cursor: pointer;
position: relative;
&:hover {
color: ${colors.active};
background-color: ${colors.activeBackground};
}
`,
);
const SuggestionDivider = styled('div')`
width: 100%;
`;
interface CollectionSearchProps {
collections: Collections;
collection?: Collection;
searchTerm: string;
onSubmit: (query: string, collection?: string) => void;
}
const CollectionSearch = ({
collections,
collection,
searchTerm,
onSubmit,
t,
}: TranslatedProps<CollectionSearchProps>) => {
const [query, setQuery] = useState(searchTerm);
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const getSelectedSelectionBasedOnProps = useCallback(() => {
return collection ? Object.keys(collections).indexOf(collection.name) : -1;
}, [collection, collections]);
const [selectedCollectionIdx, setSelectedCollectionIdx] = useState(
getSelectedSelectionBasedOnProps(),
);
const [prevCollection, setPrevCollection] = useState(collection);
useEffect(() => {
if (prevCollection !== collection) {
setSelectedCollectionIdx(getSelectedSelectionBasedOnProps());
}
setPrevCollection(collection);
}, [collection, getSelectedSelectionBasedOnProps, prevCollection]);
const toggleSuggestions = useCallback((visible: boolean) => {
setSuggestionsVisible(visible);
}, []);
const selectNextSuggestion = useCallback(() => {
setSelectedCollectionIdx(
Math.min(selectedCollectionIdx + 1, Object.keys(collections).length - 1),
);
}, [collections, selectedCollectionIdx]);
const selectPreviousSuggestion = useCallback(() => {
setSelectedCollectionIdx(Math.max(selectedCollectionIdx - 1, -1));
}, [selectedCollectionIdx]);
const resetSelectedSuggestion = useCallback(() => {
setSelectedCollectionIdx(-1);
}, []);
const submitSearch = useCallback(() => {
toggleSuggestions(false);
if (selectedCollectionIdx !== -1) {
onSubmit(query, Object.values(collections)[selectedCollectionIdx]?.name);
} else {
onSubmit(query);
}
}, [collections, onSubmit, query, selectedCollectionIdx, toggleSuggestions]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
submitSearch();
}
if (suggestionsVisible) {
// allow closing of suggestions with escape key
if (event.key === 'Escape') {
toggleSuggestions(false);
}
if (event.key === 'ArrowDown') {
selectNextSuggestion();
event.preventDefault();
} else if (event.key === 'ArrowUp') {
selectPreviousSuggestion();
event.preventDefault();
}
}
},
[
selectNextSuggestion,
selectPreviousSuggestion,
submitSearch,
suggestionsVisible,
toggleSuggestions,
],
);
const handleQueryChange = useCallback(
(newQuery: string) => {
setQuery(newQuery);
toggleSuggestions(newQuery !== '');
if (newQuery === '') {
resetSelectedSuggestion();
}
},
[resetSelectedSuggestion, toggleSuggestions],
);
const handleSuggestionClick = useCallback(
(event: MouseEvent, idx: number) => {
setSelectedCollectionIdx(idx);
submitSearch();
event.preventDefault();
},
[submitSearch],
);
return (
<SearchContainer>
<TextField
onKeyDown={handleKeyDown}
onClick={() => toggleSuggestions(true)}
placeholder={t('collection.sidebar.searchAll')}
onBlur={() => toggleSuggestions(false)}
onFocus={() => toggleSuggestions(query !== '')}
value={query}
onChange={e => handleQueryChange(e.target.value)}
variant="outlined"
size="small"
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
{suggestionsVisible && (
<SuggestionsContainer>
<Suggestions>
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
<SuggestionItem
$isActive={selectedCollectionIdx === -1}
onClick={e => handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
{t('collection.sidebar.allCollections')}
</SuggestionItem>
<SuggestionDivider />
{Object.values(collections).map((collection, idx) => (
<SuggestionItem
key={idx}
$isActive={idx === selectedCollectionIdx}
onClick={e => handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
>
{collection.label}
</SuggestionItem>
))}
</Suggestions>
</SuggestionsContainer>
)}
</SearchContainer>
);
};
export default translate()(CollectionSearch);

View File

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

View File

@ -0,0 +1,93 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import { translate } from 'react-polyglot';
import Loader from '../../UI/Loader';
import EntryListing from './EntryListing';
import type { CollectionViewStyle } from '../../../constants/collectionViews';
import type { Collection, Collections, Entry, TranslatedProps } from '../../../interface';
import type Cursor from '../../../lib/util/Cursor';
const PaginationMessage = styled('div')`
padding: 16px;
text-align: center;
`;
const NoEntriesMessage = styled(PaginationMessage)`
margin-top: 16px;
`;
export interface BaseEntriesProps {
entries: Entry[];
page?: number;
isFetching: boolean;
viewStyle: CollectionViewStyle;
cursor: Cursor;
handleCursorActions: (action: string) => void;
}
export interface SingleCollectionEntriesProps extends BaseEntriesProps {
collection: Collection;
}
export interface MultipleCollectionEntriesProps extends BaseEntriesProps {
collections: Collections;
}
export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps;
const Entries = ({
entries,
isFetching,
viewStyle,
cursor,
handleCursorActions,
t,
page,
...otherProps
}: TranslatedProps<EntriesProps>) => {
const loadingMessages = [
t('collection.entries.loadingEntries'),
t('collection.entries.cachingEntries'),
t('collection.entries.longerLoading'),
];
if (isFetching && page === undefined) {
return <Loader>{loadingMessages}</Loader>;
}
const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next');
if (hasEntries) {
return (
<>
{'collection' in otherProps ? (
<EntryListing
collection={otherProps.collection}
entries={entries}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
/>
) : (
<EntryListing
collections={otherProps.collections}
entries={entries}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
/>
)}
{isFetching && page !== undefined && entries.length > 0 ? (
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
) : null}
</>
);
}
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
};
export default translate()(Entries);

View File

@ -0,0 +1,191 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useEffect, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
loadEntries as loadEntriesAction,
traverseCollectionCursor as traverseCollectionCursorAction,
} from '../../../actions/entries';
import { colors } from '../../../components/UI/styles';
import { Cursor } from '../../../lib/util';
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
import {
selectEntries,
selectEntriesLoaded,
selectGroups,
selectIsFetching,
} from '../../../reducers/entries';
import Entries from './Entries';
import type { ComponentType } from 'react';
import type { t } from 'react-polyglot';
import type { ConnectedProps } from 'react-redux';
import type { CollectionViewStyle } from '../../../constants/collectionViews';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '../../../interface';
import type { RootState } from '../../../store';
const GroupHeading = styled('h2')`
font-size: 23px;
font-weight: 600;
color: ${colors.textLead};
`;
const GroupContainer = styled('div')``;
function getGroupEntries(entries: Entry[], paths: Set<string>) {
return entries.filter(entry => paths.has(entry.path));
}
function getGroupTitle(group: GroupOfEntries, t: t) {
const { label, value } = group;
if (value === undefined) {
return t('collection.groups.other');
}
if (typeof value === 'boolean') {
return value ? label : t('collection.groups.negateLabel', { label });
}
return `${label} ${value}`.trim();
}
function withGroups(
groups: GroupOfEntries[],
entries: Entry[],
EntriesToRender: ComponentType<EntriesToRenderProps>,
t: t,
) {
return groups.map(group => {
const title = getGroupTitle(group, t);
return (
<GroupContainer key={group.id} id={group.id}>
<GroupHeading>{title}</GroupHeading>
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
</GroupContainer>
);
});
}
interface EntriesToRenderProps {
entries: Entry[];
}
const EntriesCollection = ({
collection,
entries,
groups,
isFetching,
viewStyle,
cursor,
page,
traverseCollectionCursor,
t,
entriesLoaded,
readyToLoad,
loadEntries,
}: TranslatedProps<EntriesCollectionProps>) => {
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
const [prevCollection, setPrevCollection] = useState(collection);
useEffect(() => {
if (
collection &&
!entriesLoaded &&
readyToLoad &&
(!prevReadyToLoad || prevCollection !== collection)
) {
loadEntries(collection);
}
setPrevReadyToLoad(readyToLoad);
setPrevCollection(collection);
}, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]);
const handleCursorActions = useCallback(
(action: string) => {
traverseCollectionCursor(collection, action);
},
[collection, traverseCollectionCursor],
);
const EntriesToRender = useCallback(
({ entries }: EntriesToRenderProps) => {
return (
<Entries
collection={collection}
entries={entries}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
/>
);
},
[collection, cursor, handleCursorActions, isFetching, page, viewStyle],
);
if (groups && groups.length > 0) {
return <>{withGroups(groups, entries, EntriesToRender, t)}</>;
}
return <EntriesToRender entries={entries} />;
};
export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) {
const filtered = entries.filter(e => {
const entryPath = e.path.slice(collectionFolder.length + 1);
if (!entryPath.startsWith(path)) {
return false;
}
// only show immediate children
if (path) {
// non root path
const trimmed = entryPath.slice(path.length + 1);
return trimmed.split('/').length === 2;
} else {
// root path
return entryPath.split('/').length <= 2;
}
});
return filtered;
}
interface EntriesCollectionOwnProps {
collection: Collection;
viewStyle: CollectionViewStyle;
readyToLoad: boolean;
filterTerm: string;
}
function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps) {
const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.pages[collection.name]?.page;
let entries = selectEntries(state.entries, collection);
const groups = selectGroups(state.entries, collection);
if ('nested' in collection) {
const collectionFolder = collection.folder ?? '';
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
}
const entriesLoaded = selectEntriesLoaded(state.entries, collection.name);
const isFetching = selectIsFetching(state.entries, collection.name);
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.name);
const cursor = Cursor.create(rawCursor).clearData();
return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
}
const mapDispatchToProps = {
loadEntries: loadEntriesAction,
traverseCollectionCursor: traverseCollectionCursorAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EntriesCollectionProps = ConnectedProps<typeof connector>;
export default connector(translate()(EntriesCollection) as ComponentType<EntriesCollectionProps>);

View File

@ -0,0 +1,101 @@
import isEqual from 'lodash/isEqual';
import React, { useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import {
clearSearch as clearSearchAction,
searchEntries as searchEntriesAction,
} from '../../../actions/search';
import { Cursor } from '../../../lib/util';
import { selectSearchedEntries } from '../../../reducers';
import Entries from './Entries';
import type { ConnectedProps } from 'react-redux';
import type { Collections } from '../../../interface';
import type { RootState } from '../../../store';
const EntriesSearch = ({
collections,
entries,
isFetching,
page,
searchTerm,
searchEntries,
collectionNames,
clearSearch,
}: EntriesSearchProps) => {
const getCursor = useCallback(() => {
return Cursor.create({
actions: Number.isNaN(page) ? [] : ['append_next'],
});
}, [page]);
const handleCursorActions = useCallback(
(action: string) => {
if (action === 'append_next') {
const nextPage = page + 1;
searchEntries(searchTerm, collectionNames, nextPage);
}
},
[collectionNames, page, searchEntries, searchTerm],
);
useEffect(() => {
searchEntries(searchTerm, collectionNames);
}, [collectionNames, searchEntries, searchTerm]);
useEffect(() => {
return () => {
clearSearch();
};
}, [clearSearch]);
const [prevSearch, setPrevSearch] = useState('');
const [prevCollectionNames, setPrevCollectionNames] = useState<string[]>([]);
useEffect(() => {
// check if the search parameters are the same
if (prevSearch === searchTerm && isEqual(prevCollectionNames, collectionNames)) {
return;
}
setPrevSearch(searchTerm);
setPrevCollectionNames(collectionNames);
searchEntries(searchTerm, collectionNames);
}, [collectionNames, prevCollectionNames, prevSearch, searchEntries, searchTerm]);
return (
<Entries
cursor={getCursor()}
handleCursorActions={handleCursorActions}
collections={collections}
entries={entries}
isFetching={isFetching}
/>
);
};
interface EntriesSearchOwnProps {
searchTerm: string;
collections: Collections;
}
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
const { searchTerm } = ownProps;
const collections = Object.values(ownProps.collections);
const collectionNames = Object.keys(ownProps.collections);
const isFetching = state.search.isFetching;
const page = state.search.page;
const entries = selectSearchedEntries(state, collectionNames);
return { isFetching, page, collections, collectionNames, entries, searchTerm };
}
const mapDispatchToProps = {
searchEntries: searchEntriesAction,
clearSearch: clearSearchAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EntriesSearchProps = ConnectedProps<typeof connector>;
export default connector(EntriesSearch);

View File

@ -0,0 +1,102 @@
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 '../../../actions/media';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '../../../constants/collectionViews';
import { selectEntryCollectionTitle } from '../../../lib/util/collection.util';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import type { ConnectedProps } from 'react-redux';
import type { CollectionViewStyle } from '../../../constants/collectionViews';
import type { Field, Collection, Entry } from '../../../interface';
import type { RootState } from '../../../store';
const EntryCard = ({
collection,
entry,
path,
image,
imageField,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
getAsset,
}: NestedCollectionProps) => {
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
return (
<Card>
<CardActionArea component={Link} to={path}>
{viewStyle === VIEW_STYLE_GRID && image && imageField ? (
<CardMedia
component="img"
height="140"
image={getAsset(collection, entry, image, imageField).toString()}
/>
) : 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;
inferedFields: {
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, inferedFields, collection } = ownProps;
const entryData = entry.data;
let image = inferedFields.imageField
? (entryData?.[inferedFields.imageField] as string | undefined)
: undefined;
if (image) {
image = encodeURI(image);
}
const isLoadingAsset = selectIsLoadingAsset(state.medias);
return {
...ownProps,
path: `/collections/${collection.name}/entries/${entry.slug}`,
image,
imageField: collection.fields?.find(
f => f.name === inferedFields.imageField && f.widget === 'image',
),
isLoadingAsset,
};
}
const mapDispatchToProps = {
getAsset: getAssetAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type NestedCollectionProps = ConnectedProps<typeof connector>;
export default connector(EntryCard);

View File

@ -0,0 +1,147 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo } from 'react';
import { Waypoint } from 'react-waypoint';
import { VIEW_STYLE_LIST } from '../../../constants/collectionViews';
import { transientOptions } from '../../../lib';
import { selectFields, selectInferedField } from '../../../lib/util/collection.util';
import EntryCard from './EntryCard';
import type { CollectionViewStyle } from '../../../constants/collectionViews';
import type { Field, Collection, Collections, Entry } from '../../../interface';
import type Cursor from '../../../lib/util/Cursor';
interface CardsGridProps {
$layout: CollectionViewStyle;
}
const CardsGrid = styled(
'div',
transientOptions,
)<CardsGridProps>(
({ $layout }) => `
${
$layout === VIEW_STYLE_LIST
? `
display: flex;
flex-direction: column;
`
: `
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
`
}
width: 100%;
margin-top: 16px;
gap: 16px;
`,
);
export interface BaseEntryListingProps {
entries: Entry[];
viewStyle: CollectionViewStyle;
cursor?: Cursor;
handleCursorActions: (action: string) => void;
page?: number;
}
export interface SingleCollectionEntryListingProps extends BaseEntryListingProps {
collection: Collection;
}
export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps {
collections: Collections;
}
export type EntryListingProps =
| SingleCollectionEntryListingProps
| MultipleCollectionEntryListingProps;
const EntryListing = ({
entries,
page,
cursor,
viewStyle,
handleCursorActions,
...otherProps
}: EntryListingProps) => {
const hasMore = useCallback(() => {
const hasMore = cursor?.actions?.has('append_next');
return hasMore;
}, [cursor?.actions]);
const handleLoadMore = useCallback(() => {
if (hasMore()) {
handleCursorActions('append_next');
}
}, [handleCursorActions, hasMore]);
const inferFields = useCallback(
(
collection?: Collection,
): {
titleField?: string | null;
descriptionField?: string | null;
imageField?: string | null;
remainingFields?: Field[];
} => {
if (!collection) {
return {};
}
const titleField = selectInferedField(collection, 'title');
const descriptionField = selectInferedField(collection, 'description');
const imageField = selectInferedField(collection, 'image');
const fields = selectFields(collection);
const inferedFields = [titleField, descriptionField, imageField];
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.name) === -1);
return { titleField, descriptionField, imageField, remainingFields };
},
[],
);
const renderedCards = useMemo(() => {
if ('collection' in otherProps) {
const inferedFields = inferFields(otherProps.collection);
return entries.map((entry, idx) => (
<EntryCard
collection={otherProps.collection}
inferedFields={inferedFields}
viewStyle={viewStyle}
entry={entry}
key={idx}
/>
));
}
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
return entries.map((entry, idx) => {
const collectionName = entry.collection;
const collection = Object.values(otherProps.collections).find(
coll => coll.name === collectionName,
);
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
const inferedFields = inferFields(collection);
return collection ? (
<EntryCard
collection={collection}
entry={entry}
inferedFields={inferedFields}
collectionLabel={collectionLabel}
key={idx}
/>
) : null;
});
}, [entries, inferFields, otherProps, viewStyle]);
return (
<div>
<CardsGrid $layout={viewStyle}>
{renderedCards}
{hasMore() && <Waypoint key={page} onEnter={handleLoadMore} />}
</CardsGrid>
</div>
);
};
export default EntryListing;

View File

@ -0,0 +1,85 @@
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 } from 'react';
import { translate } from 'react-polyglot';
import type { FilterMap, TranslatedProps, ViewFilter } from '../../interface';
interface FilterControlProps {
filter: Record<string, FilterMap>;
viewFilters: ViewFilter[];
onFilterClick: (viewFilter: ViewFilter) => void;
}
const FilterControl = ({
viewFilters,
t,
onFilterClick,
filter,
}: TranslatedProps<FilterControlProps>) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: React.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 = filter[viewFilter.id]?.active ?? false;
const labelId = `filter-list-label-${viewFilter.label}`;
return (
<MenuItem
key={viewFilter.id}
onClick={() => onFilterClick(viewFilter)}
sx={{ height: '36px' }}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={viewFilter.label} />
</MenuItem>
);
})}
</Menu>
</div>
);
};
export default translate()(FilterControl);

View File

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

View File

@ -0,0 +1,354 @@
import { styled } from '@mui/material/styles';
import ArticleIcon from '@mui/icons-material/Article';
import sortBy from 'lodash/sortBy';
import { dirname, sep } from 'path';
import React, { useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { colors, components } from '../../components/UI/styles';
import { transientOptions } from '../../lib';
import { selectEntryCollectionTitle } from '../../lib/util/collection.util';
import { stringTemplate } from '../../lib/widgets';
import { selectEntries } from '../../reducers/entries';
import type { ConnectedProps } from 'react-redux';
import type { Collection, Entry } from '../../interface';
import type { RootState } from '../../store';
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 (
<React.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}
/>
)}
</React.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 = 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.entries, collection) ?? [];
return { ...ownProps, entries };
}
const connector = connect(mapStateToProps, {});
export type NestedCollectionProps = ConnectedProps<typeof connector>;
export default connector(NestedCollection);

View File

@ -0,0 +1,177 @@
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 { searchCollections } from '../../actions/collections';
import { colors } from '../../components/UI/styles';
import { getAdditionalLinks, getIcon } from '../../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 '../../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 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: { iconName } = {} }) => {
let icon: ReactNode = <ArticleIcon />;
if (iconName) {
const storedIcon = getIcon(iconName);
if (storedIcon) {
icon = storedIcon();
}
}
const content = (
<>
<StyledListItemIcon>{icon}</StyledListItemIcon>
<ListItemText primary={title} />
</>
);
return typeof data === 'string' ? (
<ListItem
key={title}
href={data}
component="a"
disablePadding
target="_blank"
rel="noopener"
sx={{
color: colors.inactive,
'&:hover': {
color: colors.active,
'.MuiListItemIcon-root': {
color: colors.active,
},
},
}}
>
<ListItemButton>{content}</ListItemButton>
</ListItem>
) : (
<ListItem
key={title}
to={`/page/${id}`}
component={NavLink}
disablePadding
activeClassName="sidebar-active"
>
<ListItemButton>{content}</ListItemButton>
</ListItem>
);
}),
[additionalLinks],
);
return (
<StyledSidebar>
<Card sx={{ minWidth: 275 }}>
<CardContent sx={{ paddingBottom: 0 }}>
<Typography gutterBottom variant="h5" component="div">
{t('collection.sidebar.collections')}
</Typography>
{isSearchEnabled && (
<CollectionSearch
searchTerm={searchTerm}
collections={collections}
collection={collection}
onSubmit={(query: string, collection?: string) =>
searchCollections(query, collection)
}
/>
)}
</CardContent>
<List>
{collectionLinks}
{links}
</List>
</Card>
</StyledSidebar>
);
};
export default translate()(Sidebar);

View File

@ -0,0 +1,112 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { styled } from '@mui/material';
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 React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { SortDirection } from '../../interface';
import type { SortableField, SortMap, TranslatedProps } from '../../interface';
const StyledMenuIconWrapper = styled('div')`
width: 32px;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-end;
`;
function nextSortDirection(direction: SortDirection) {
switch (direction) {
case SortDirection.Ascending:
return SortDirection.Descending;
case SortDirection.Descending:
return SortDirection.None;
default:
return SortDirection.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] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: React.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 === SortDirection.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 ?? SortDirection.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 === SortDirection.Ascending ? (
<KeyboardArrowUpIcon fontSize="small" />
) : (
<KeyboardArrowDownIcon fontSize="small" />
)
) : null}
</StyledMenuIconWrapper>
</MenuItem>
);
})}
</Menu>
</div>
);
};
export default translate()(SortControl);

View File

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

View File

@ -0,0 +1,402 @@
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { logoutUser as logoutUserAction } from '../../actions/auth';
import {
changeDraftFieldValidation as changeDraftFieldValidationAction,
createDraftDuplicateFromEntry as createDraftDuplicateFromEntryAction,
createEmptyDraft as createEmptyDraftAction,
deleteDraftLocalBackup as deleteDraftLocalBackupAction,
deleteEntry as deleteEntryAction,
deleteLocalBackup as deleteLocalBackupAction,
discardDraft as discardDraftAction,
loadEntries as loadEntriesAction,
loadEntry as loadEntryAction,
loadLocalBackup as loadLocalBackupAction,
persistEntry as persistEntryAction,
persistLocalBackup as persistLocalBackupAction,
retrieveLocalBackup as retrieveLocalBackupAction,
} from '../../actions/entries';
import {
loadScroll as loadScrollAction,
toggleScroll as toggleScrollAction,
} from '../../actions/scroll';
import { selectFields } from '../../lib/util/collection.util';
import { useWindowEvent } from '../../lib/util/window.util';
import { selectEntry } from '../../reducers';
import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history';
import confirm from '../UI/Confirm';
import Loader from '../UI/Loader';
import EditorInterface from './EditorInterface';
import type { TransitionPromptHook } from 'history';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { Collection, EditorPersistOptions, Entry, TranslatedProps } from '../../interface';
import type { RootState } from '../../store';
const Editor = ({
entry,
entryDraft,
fields,
collection,
user,
hasChanged,
displayUrl,
isModification,
logoutUser,
draftKey,
t,
editorBackLink,
toggleScroll,
scrollSyncEnabled,
loadScroll,
showDelete,
slug,
localBackup,
persistLocalBackup,
loadEntry,
persistEntry,
deleteEntry,
loadLocalBackup,
retrieveLocalBackup,
deleteLocalBackup,
deleteDraftLocalBackup,
createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
}: TranslatedProps<EditorProps>) => {
const [version, setVersion] = useState(0);
const createBackup = useMemo(
() =>
debounce(function (entry: Entry, collection: Collection) {
persistLocalBackup(entry, collection);
}, 2000),
[persistLocalBackup],
);
const deleteBackup = useCallback(() => {
createBackup.cancel();
if (slug) {
deleteLocalBackup(collection, slug);
}
deleteDraftLocalBackup();
}, [collection, createBackup, deleteDraftLocalBackup, deleteLocalBackup, slug]);
const [submitted, setSubmitted] = useState(false);
const handlePersistEntry = useCallback(
async (opts: EditorPersistOptions = {}) => {
const { createNew = false, duplicate = false } = opts;
if (!entryDraft.entry) {
return;
}
try {
await persistEntry(collection);
setVersion(version + 1);
deleteBackup();
if (createNew) {
navigateToNewEntry(collection.name);
if (duplicate && entryDraft.entry) {
createDraftDuplicateFromEntry(entryDraft.entry);
}
}
// eslint-disable-next-line no-empty
} catch (e) {}
setSubmitted(true);
},
[
collection,
createDraftDuplicateFromEntry,
deleteBackup,
entryDraft.entry,
persistEntry,
version,
],
);
const handleDuplicateEntry = useCallback(() => {
if (!entryDraft.entry) {
return;
}
navigateToNewEntry(collection.name);
createDraftDuplicateFromEntry(entryDraft.entry);
}, [collection.name, createDraftDuplicateFromEntry, entryDraft.entry]);
const handleDeleteEntry = useCallback(async () => {
if (entryDraft.hasChanged) {
if (
!(await confirm({
title: 'editor.editor.onDeleteWithUnsavedChangesTitle',
body: 'editor.editor.onDeleteWithUnsavedChangesBody',
color: 'error',
}))
) {
return;
}
} else if (
!(await confirm({
title: 'editor.editor.onDeletePublishedEntryTitle',
body: 'editor.editor.onDeletePublishedEntryBody',
color: 'error',
}))
) {
return;
}
if (!slug) {
return navigateToCollection(collection.name);
}
setTimeout(async () => {
await deleteEntry(collection, slug);
deleteBackup();
return navigateToCollection(collection.name);
}, 0);
}, [collection, deleteBackup, deleteEntry, entryDraft.hasChanged, slug]);
const [prevLocalBackup, setPrevLocalBackup] = useState<
| {
entry: Entry;
}
| undefined
>();
useEffect(() => {
if (!prevLocalBackup && localBackup) {
const updateLocalBackup = async () => {
const confirmLoadBackup = await confirm({
title: 'editor.editor.confirmLoadBackupTitle',
body: 'editor.editor.confirmLoadBackupBody',
});
if (confirmLoadBackup) {
loadLocalBackup();
setVersion(version + 1);
} else {
deleteBackup();
}
};
updateLocalBackup();
}
setPrevLocalBackup(localBackup);
}, [deleteBackup, loadLocalBackup, localBackup, prevLocalBackup, version]);
useEffect(() => {
if (hasChanged && entryDraft.entry) {
createBackup(entryDraft.entry, collection);
} else if (localBackup) {
deleteBackup();
}
return () => {
createBackup.flush();
};
}, [collection, createBackup, deleteBackup, entryDraft.entry, hasChanged, localBackup]);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [preSlug, setPrevSlug] = useState<string | undefined | null>(null);
useEffect(() => {
if (!slug && preSlug !== slug) {
setTimeout(() => {
createEmptyDraft(collection, location.search);
});
} else if (slug && (prevCollection !== collection || preSlug !== slug)) {
setTimeout(() => {
retrieveLocalBackup(collection, slug);
loadEntry(collection, slug);
});
}
setPrevCollection(collection);
setPrevSlug(slug);
}, [
collection,
createEmptyDraft,
discardDraft,
entryDraft.entry,
loadEntry,
preSlug,
prevCollection,
retrieveLocalBackup,
slug,
]);
const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]);
const exitBlocker = useCallback(
(event: BeforeUnloadEvent) => {
if (entryDraft.hasChanged) {
// This message is ignored in most browsers, but its presence triggers the confirmation dialog
event.returnValue = leaveMessage;
return leaveMessage;
}
},
[entryDraft.hasChanged, leaveMessage],
);
useWindowEvent('beforeunload', exitBlocker);
const navigationBlocker: TransitionPromptHook = useCallback(
(location, action) => {
/**
* New entry being saved and redirected to it's new slug based url.
*/
const isPersisting = entryDraft.entry?.isPersisting;
const newRecord = entryDraft.entry?.newRecord;
const newEntryPath = `/collections/${collection.name}/new`;
if (isPersisting && newRecord && location.pathname === newEntryPath && action === 'PUSH') {
return;
}
if (hasChanged) {
return leaveMessage;
}
},
[
collection.name,
entryDraft.entry?.isPersisting,
entryDraft.entry?.newRecord,
hasChanged,
leaveMessage,
],
);
useEffect(() => {
const unblock = history.block(navigationBlocker);
return () => {
unblock();
};
}, [collection.name, deleteBackup, discardDraft, navigationBlocker]);
// TODO Is this needed?
// if (!collectionEntriesLoaded) {
// loadEntries(collection);
// }
if (entry && entry.error) {
return (
<div>
<h3>{entry.error}</h3>
</div>
);
} else if (entryDraft == null || entryDraft.entry === undefined || (entry && entry.isFetching)) {
return <Loader>{t('editor.editor.loadingEntry')}</Loader>;
}
return (
<EditorInterface
key={`editor-${version}`}
draftKey={draftKey}
entry={entryDraft.entry}
collection={collection}
fields={fields}
fieldsErrors={entryDraft.fieldsErrors}
onPersist={handlePersistEntry}
onDelete={handleDeleteEntry}
onDuplicate={handleDuplicateEntry}
showDelete={showDelete ?? true}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
isNewEntry={!slug}
isModification={isModification}
onLogoutClick={logoutUser}
editorBackLink={editorBackLink}
toggleScroll={toggleScroll}
scrollSyncEnabled={scrollSyncEnabled}
loadScroll={loadScroll}
submitted={submitted}
t={t}
/>
);
};
interface CollectionViewOwnProps {
name: string;
slug?: string;
newRecord: boolean;
showDelete?: boolean;
}
function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) {
const { collections, entryDraft, auth, config, entries, scroll } = state;
const { name, slug } = ownProps;
const collection = collections[name];
const collectionName = collection.name;
const fields = selectFields(collection, slug);
const entry = !slug ? null : selectEntry(state, collectionName, slug);
const user = auth.user;
const hasChanged = entryDraft.hasChanged;
const displayUrl = config.config?.display_url;
const isModification = entryDraft.entry?.isModification ?? false;
const collectionEntriesLoaded = Boolean(entries.pages[collectionName]);
const localBackup = entryDraft.localBackup;
const draftKey = entryDraft.key;
let editorBackLink = `/collections/${collectionName}`;
if ('files' in collection && collection.files?.length === 1) {
editorBackLink = '/';
}
if ('nested' in collection && collection.nested && slug) {
const pathParts = slug.split('/');
if (pathParts.length > 2) {
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
}
}
const scrollSyncEnabled = scroll.isScrolling;
return {
...ownProps,
collection,
collections,
entryDraft,
fields,
entry,
user,
hasChanged,
displayUrl,
isModification,
collectionEntriesLoaded,
localBackup,
draftKey,
editorBackLink,
scrollSyncEnabled,
};
}
const mapDispatchToProps = {
loadEntry: loadEntryAction,
loadEntries: loadEntriesAction,
loadLocalBackup: loadLocalBackupAction,
deleteDraftLocalBackup: deleteDraftLocalBackupAction,
retrieveLocalBackup: retrieveLocalBackupAction,
persistLocalBackup: persistLocalBackupAction,
deleteLocalBackup: deleteLocalBackupAction,
changeDraftFieldValidation: changeDraftFieldValidationAction,
createDraftDuplicateFromEntry: createDraftDuplicateFromEntryAction,
createEmptyDraft: createEmptyDraftAction,
discardDraft: discardDraftAction,
persistEntry: persistEntryAction,
deleteEntry: deleteEntryAction,
logoutUser: logoutUserAction,
toggleScroll: toggleScrollAction,
loadScroll: loadScrollAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EditorProps = ConnectedProps<typeof connector>;
export default connector(translate()(Editor) as ComponentType<EditorProps>);

View File

@ -0,0 +1,329 @@
import { styled } from '@mui/material/styles';
import isEmpty from 'lodash/isEmpty';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
changeDraftField as changeDraftFieldAction,
changeDraftFieldValidation as changeDraftFieldValidationAction,
clearFieldErrors as clearFieldErrorsAction,
tryLoadEntry,
} from '../../../actions/entries';
import { getAsset as getAssetAction } from '../../../actions/media';
import {
clearMediaControl as clearMediaControlAction,
openMediaLibrary as openMediaLibraryAction,
removeInsertedMedia as removeInsertedMediaAction,
removeMediaControl as removeMediaControlAction,
} from '../../../actions/mediaLibrary';
import { clearSearch as clearSearchAction, query as queryAction } from '../../../actions/search';
import { borders, colors, lengths, transitions } from '../../../components/UI/styles';
import { transientOptions } from '../../../lib';
import { resolveWidget } from '../../../lib/registry';
import { getFieldLabel } from '../../../lib/util/field.util';
import { validate } from '../../../lib/util/validation.util';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type {
Collection,
Entry,
Field,
FieldsErrors,
GetAssetFunction,
I18nSettings,
TranslatedProps,
ValueOrNestedValue,
Widget,
} from '../../../interface';
import type { RootState } from '../../../store';
import type { EditorControlPaneProps } from './EditorControlPane';
/**
* 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;
}
export 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 = ({
className,
clearFieldErrors,
clearMediaControl,
clearSearch,
collection,
config: configState,
entry,
field,
fieldsErrors,
submitted,
getAsset,
isDisabled,
isEditorComponent,
isFetching,
isFieldDuplicate,
isFieldHidden,
isHidden = false,
isNewEditorComponent,
loadEntry,
locale,
mediaPaths,
changeDraftFieldValidation,
openMediaLibrary,
parentPath,
query,
removeInsertedMedia,
removeMediaControl,
t,
value,
forList = false,
changeDraftField,
i18n,
}: TranslatedProps<EditorControlProps>) => {
const widgetName = field.widget;
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue>;
const fieldHint = field.hint;
const path = useMemo(
() => (parentPath.length > 0 ? `${parentPath}.${field.name}` : field.name),
[field.name, parentPath],
);
const [dirty, setDirty] = useState(!isEmpty(value));
const errors = useMemo(() => fieldsErrors[path] ?? [], [fieldsErrors, path]);
const hasErrors = (submitted || dirty) && Boolean(errors.length);
const handleGetAsset = useCallback(
(collection: Collection, entry: Entry): GetAssetFunction =>
(path: string, field?: Field) => {
return getAsset(collection, entry, path, field);
},
[getAsset],
);
useEffect(() => {
const validateValue = async () => {
await validate(path, field, value, widget, changeDraftFieldValidation, t);
};
validateValue();
}, [field, value, changeDraftFieldValidation, path, t, widget, dirty]);
const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => {
setDirty(true);
changeDraftField({ path, field, value, entry, i18n });
},
[changeDraftField, entry, field, i18n, path],
);
const config = useMemo(() => configState.config, [configState.config]);
if (!collection || !entry || !config) {
return null;
}
return (
<ControlContainer className={className} $isHidden={isHidden}>
<>
{React.createElement(widget.control, {
clearFieldErrors,
clearSearch,
collection,
config,
entry,
field,
fieldsErrors,
submitted,
getAsset: handleGetAsset(collection, entry),
isDisabled: isDisabled ?? false,
isEditorComponent: isEditorComponent ?? false,
isFetching,
isFieldDuplicate,
isFieldHidden,
isNewEditorComponent: isNewEditorComponent ?? false,
label: getFieldLabel(field, t),
loadEntry,
locale,
mediaPaths,
onChange: handleChangeDraftField,
clearMediaControl,
openMediaLibrary,
removeInsertedMedia,
removeMediaControl,
path,
query,
t,
value,
forList,
i18n,
hasErrors,
})}
{fieldHint && <ControlHint $error={hasErrors}>{fieldHint}</ControlHint>}
{hasErrors ? (
<ControlErrorsList>
{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>
);
};
interface EditorControlOwnProps {
className?: string;
clearFieldErrors: EditorControlPaneProps['clearFieldErrors'];
field: Field;
fieldsErrors: FieldsErrors;
submitted: boolean;
isDisabled?: boolean;
isEditorComponent?: boolean;
isFieldDuplicate?: (field: Field) => boolean;
isFieldHidden?: (field: Field) => boolean;
isHidden?: boolean;
isNewEditorComponent?: boolean;
locale?: string;
parentPath: string;
value: ValueOrNestedValue;
forList?: boolean;
i18n: I18nSettings | undefined;
}
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.medias);
async function loadEntry(collectionName: string, slug: string) {
const targetCollection = collections[collectionName];
if (targetCollection) {
const loadedEntry = await tryLoadEntry(state, targetCollection, slug);
return loadedEntry;
} else {
throw new Error(`Can't find collection '${collectionName}'`);
}
}
return {
...ownProps,
mediaPaths: state.mediaLibrary.controlMedia,
isFetching: state.search.isFetching,
config: state.config,
entry,
collection,
isLoadingAsset,
loadEntry,
};
}
const mapDispatchToProps = {
changeDraftField: changeDraftFieldAction,
changeDraftFieldValidation: changeDraftFieldValidationAction,
openMediaLibrary: openMediaLibraryAction,
clearMediaControl: clearMediaControlAction,
removeMediaControl: removeMediaControlAction,
removeInsertedMedia: removeInsertedMediaAction,
query: queryAction,
clearSearch: clearSearchAction,
clearFieldErrors: clearFieldErrorsAction,
getAsset: getAssetAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EditorControlProps = ConnectedProps<typeof connector>;
export default connector(translate()(EditorControl) as ComponentType<EditorControlProps>);

View File

@ -0,0 +1,248 @@
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 } from 'react';
import { connect } from 'react-redux';
import {
changeDraftField as changeDraftFieldAction,
clearFieldErrors as clearFieldErrorsAction,
} from '../../../actions/entries';
import confirm from '../../../components/UI/Confirm';
import {
getI18nInfo,
getLocaleDataPath,
hasI18n,
isFieldDuplicate,
isFieldHidden,
isFieldTranslatable,
} from '../../../lib/i18n';
import EditorControl from './EditorControl';
import type { ConnectedProps } from 'react-redux';
import type {
Collection,
Entry,
Field,
FieldsErrors,
I18nSettings,
TranslatedProps,
ValueOrNestedValue,
} from '../../../interface';
import type { RootState } from '../../../store';
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;
`;
interface LocaleDropdownProps {
locales: string[];
dropdownText: string;
onLocaleChange: (locale: string) => void;
}
const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdownProps) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
return (
<div>
<Button
id="basic-button"
aria-controls={open ? 'basic-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
>
{dropdownText}
</Button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
{locales.map(locale => (
<MenuItem key={locale} onClick={() => onLocaleChange(locale)}>
{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,
changeDraftField,
locale,
onLocaleChange,
clearFieldErrors,
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]);
const copyFromOtherLocale = useCallback(
({ targetLocale }: { targetLocale?: string }) =>
async (sourceLocale: string) => {
if (!targetLocale) {
return;
}
if (
!(await confirm({
title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle',
body: {
key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody',
options: { locale: sourceLocale.toUpperCase() },
},
}))
) {
return;
}
fields.forEach(field => {
if (isFieldTranslatable(field, targetLocale, sourceLocale)) {
const copyValue = getFieldValue(
field,
entry,
sourceLocale !== i18n?.defaultLocale,
sourceLocale,
);
changeDraftField({ path: field.name, field, value: copyValue, entry, i18n });
}
});
},
[fields, entry, i18n, changeDraftField],
);
if (!collection || !fields) {
return null;
}
if (!entry || entry.partial === true) {
return null;
}
return (
<ControlPaneContainer>
{i18n?.locales && locale ? (
<LocaleRowWrapper>
<LocaleDropdown
locales={i18n.locales}
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
locale: locale?.toUpperCase(),
})}
onLocaleChange={onLocaleChange}
/>
<LocaleDropdown
locales={i18n.locales.filter(l => l !== locale)}
dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')}
onLocaleChange={copyFromOtherLocale({ targetLocale: locale })}
/>
</LocaleRowWrapper>
) : null}
{fields
.filter(f => f.widget !== 'hidden')
.map((field, i) => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);
const key = i18n ? `${locale}_${i}` : i;
return (
<EditorControl
key={key}
field={field}
value={getFieldValue(field, entry, isTranslatable, locale)}
fieldsErrors={fieldsErrors}
submitted={submitted}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
locale={locale}
clearFieldErrors={clearFieldErrors}
parentPath=""
i18n={i18n}
/>
);
})}
</ControlPaneContainer>
);
};
export interface EditorControlPaneOwnProps {
collection: Collection;
entry: Entry;
fields: Field[];
fieldsErrors: FieldsErrors;
submitted: boolean;
locale?: string;
onLocaleChange: (locale: string) => void;
}
function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) {
return {
...ownProps,
};
}
const mapDispatchToProps = {
changeDraftField: changeDraftFieldAction,
clearFieldErrors: clearFieldErrorsAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EditorControlPaneProps = ConnectedProps<typeof connector>;
export default connector(EditorControlPane);

View File

@ -0,0 +1,363 @@
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 '../../components/UI/styles';
import { FILES } from '../../constants/collectionTypes';
import { transientOptions } from '../../lib';
import { getI18nInfo, getPreviewEntry, hasI18n } from '../../lib/i18n';
import { getFileFromSlug } from '../../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 '../../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);
> div:nth-of-type(2)::before {
content: '';
width: 2px;
height: calc(100vh - 64px);
position: relative;
background-color: rgb(223, 223, 227);
display: block;
}
`;
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;
$overFlow?: boolean;
}
const PreviewPaneContainer = styled(
'div',
transientOptions,
)<PreviewPaneContainerProps>(
({ $blockEntry, $overFlow }) => `
height: 100%;
pointer-events: ${$blockEntry ? 'none' : 'auto'};
overflow-y: ${$overFlow ? 'auto' : 'hidden'};
`,
);
const ControlPaneContainer = styled(PreviewPaneContainer)`
padding: 24px 16px 16px;
position: relative;
overflow-x: hidden;
display: 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>;
}
};
function isPreviewEnabled(collection: Collection, entry: Entry) {
if (collection.type === FILES) {
const file = getFileFromSlug(collection, entry.slug);
const previewEnabled = file?.editor?.preview ?? false;
if (previewEnabled) {
return previewEnabled;
}
}
return collection.editor?.preview ?? true;
}
interface EditorInterfaceProps {
draftKey: string;
entry: Entry;
collection: Collection;
fields: Field[] | undefined;
fieldsErrors: FieldsErrors;
onPersist: (opts?: EditorPersistOptions) => Promise<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<{ readonly type: 'TOGGLE_SCROLL' }>;
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(locales?.[0]);
const switchToDefaultLocale = useCallback(() => {
if (hasI18n(collection)) {
const { defaultLocale } = getI18nInfo(collection);
setSelectedLocale(defaultLocale);
}
}, [collection]);
const handleOnPersist = useCallback(
async (opts: EditorPersistOptions = {}) => {
const { createNew = false, duplicate = false } = opts;
await switchToDefaultLocale();
// TODO Trigger field validation on persist
// this.controlPaneRef.validate();
onPersist({ createNew, duplicate });
},
[onPersist, switchToDefaultLocale],
);
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 = isPreviewEnabled(collection, entry);
const collectionI18nEnabled = hasI18n(collection);
const editor = (
<ControlPaneContainer id="control-pane" $overFlow>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={selectedLocale}
onLocaleChange={handleLocaleChange}
submitted={submitted}
t={t}
/>
</ControlPaneContainer>
);
const editorLocale = (
<ControlPaneContainer $overFlow={!scrollSyncEnabled}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={locales?.[1]}
onLocaleChange={handleLocaleChange}
submitted={submitted}
t={t}
/>
</ControlPaneContainer>
);
const previewEntry = collectionI18nEnabled
? getPreviewEntry(entry, selectedLocale, defaultLocale)
: entry;
const editorWithPreview = (
<>
<StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<PreviewPaneContainer>
<EditorPreviewPane collection={collection} entry={previewEntry} fields={fields} />
</PreviewPaneContainer>
</StyledSplitPane>
</>
);
const editorSideBySideLocale = (
<ScrollSync enabled={scrollSyncEnabled}>
<div>
<StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<ScrollSyncPane>{editorLocale}</ScrollSyncPane>
</StyledSplitPane>
</div>
</ScrollSync>
);
const finalI18nVisible = collectionI18nEnabled && i18nVisible;
const finalPreviewVisible = previewEnabled && previewVisible;
const scrollSyncVisible = finalI18nVisible || finalPreviewVisible;
return (
<EditorContainer>
<EditorToolbar
isPersisting={entry.isPersisting}
isDeleting={entry.isDeleting}
onPersist={handleOnPersist}
onPersistAndNew={() => handleOnPersist({ createNew: true })}
onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })}
onDelete={onDelete}
showDelete={showDelete}
onDuplicate={onDuplicate}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
collection={collection}
isNewEntry={isNewEntry}
isModification={isModification}
onLogoutClick={onLogoutClick}
editorBackLink={editorBackLink}
/>
<Editor key={draftKey}>
<StyledViewControls>
{collectionI18nEnabled && (
<Fab
size="small"
color={finalI18nVisible ? 'primary' : 'default'}
aria-label="add"
onClick={handleToggleI18n}
title={t('editor.editorInterface.toggleI18n')}
>
<LanguageIcon />
</Fab>
)}
{previewEnabled && (
<Fab
size="small"
color={finalPreviewVisible ? 'primary' : 'default'}
aria-label="add"
onClick={handleTogglePreview}
title={t('editor.editorInterface.togglePreview')}
>
<VisibilityIcon />
</Fab>
)}
{scrollSyncVisible && (
<Fab
size="small"
color={scrollSyncEnabled ? 'primary' : 'default'}
aria-label="add"
onClick={handleToggleScrollSync}
title={t('editor.editorInterface.toggleScrollSync')}
>
<HeightIcon />
</Fab>
)}
</StyledViewControls>
<EditorContent
i18nVisible={finalI18nVisible}
previewVisible={finalPreviewVisible}
editor={editor}
editorSideBySideLocale={editorSideBySideLocale}
editorWithPreview={editorWithPreview}
/>
</Editor>
</EditorContainer>
);
};
export default EditorInterface;

View File

@ -0,0 +1,31 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import type { Field, TemplatePreviewProps } from '../../../interface';
function isVisible(field: Field) {
return field.widget !== 'hidden';
}
const PreviewContainer = styled('div')`
overflow-y: auto;
height: 100%;
padding: 24px;
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
`;
const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps) => {
if (!collection || !fields) {
return null;
}
return (
<PreviewContainer>
{fields.filter(isVisible).map(field => (
<div key={field.name}>{widgetFor(field.name)}</div>
))}
</PreviewContainer>
);
};
export default Preview;

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