Feature/docs (#67)
This commit is contained in:
parent
7a1ec55a5c
commit
81ca566b5e
@ -1,471 +0,0 @@
|
||||
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
|
@ -1,12 +0,0 @@
|
||||
<!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>
|
@ -497,6 +497,8 @@ collections:
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
editor:
|
||||
preview: true
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
@ -526,6 +528,8 @@ collections:
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
editor:
|
||||
preview: true
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
|
@ -1,98 +1,104 @@
|
||||
// Register all the things
|
||||
CMS.init();
|
||||
|
||||
const PostPreview = createClass({
|
||||
render: function () {
|
||||
var entry = this.props.entry;
|
||||
return h(
|
||||
'div',
|
||||
const PostPreview = ({ entry, widgetFor }) => {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('div', { className: 'cover' }, h('h1', {}, entry.data.title), widgetFor('image')),
|
||||
h('p', {}, h('small', {}, 'Written ' + entry.data.date)),
|
||||
h('div', { className: 'text' }, widgetFor('body')),
|
||||
);
|
||||
};
|
||||
|
||||
const GeneralPreview = ({ widgetsFor, getAsset, entry }) => {
|
||||
const title = entry.data.site_title;
|
||||
const posts = entry.data.posts;
|
||||
const thumb = posts && posts.thumb;
|
||||
|
||||
const [thumbUrl, setThumbUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const loadThumb = async () => {
|
||||
const thumbAsset = await getAsset(thumb);
|
||||
if (alive) {
|
||||
setThumbUrl(thumbAsset.toString());
|
||||
}
|
||||
};
|
||||
|
||||
loadThumb();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [thumb]);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, title),
|
||||
h(
|
||||
'dl',
|
||||
{},
|
||||
h(
|
||||
h('dt', {}, 'Posts on Frontpage'),
|
||||
h('dd', {}, widgetsFor('posts').widgets.front_limit ?? 0),
|
||||
|
||||
h('dt', {}, 'Default Author'),
|
||||
h('dd', {}, widgetsFor('posts').data?.author ?? 'None'),
|
||||
|
||||
h('dt', {}, 'Default Thumbnail'),
|
||||
h('dd', {}, thumb && h('img', { src: thumbUrl })),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const AuthorsPreview = ({ widgetsFor }) => {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, 'Authors'),
|
||||
widgetsFor('authors').map(function (author, index) {
|
||||
return h(
|
||||
'div',
|
||||
{ className: 'cover' },
|
||||
h('h1', {}, entry.data.title),
|
||||
this.props.widgetFor('image'),
|
||||
),
|
||||
h('p', {}, h('small', {}, 'Written ' + entry.data.date)),
|
||||
h('div', { className: 'text' }, this.props.widgetFor('body')),
|
||||
);
|
||||
},
|
||||
});
|
||||
{ key: index },
|
||||
h('hr', {}),
|
||||
h('strong', {}, author.data.name),
|
||||
author.widgets.description,
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// TODO Hook this back up, getAsset returns a promise now
|
||||
const GeneralPreview = createClass({
|
||||
render: function () {
|
||||
const entry = this.props.entry;
|
||||
const title = entry.data.site_title;
|
||||
const posts = entry.data.posts;
|
||||
const thumb = posts && posts.thumb;
|
||||
const RelationKitchenSinkPostPreview = ({ fieldsMetaData }) => {
|
||||
// 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 post = fieldsMetaData && fieldsMetaData.posts.value;
|
||||
const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' };
|
||||
return post
|
||||
? h(
|
||||
'div',
|
||||
{ style: style },
|
||||
h('h2', {}, 'Related Post'),
|
||||
h('h3', {}, post.title),
|
||||
h('img', { src: post.image }),
|
||||
h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, title),
|
||||
h(
|
||||
'dl',
|
||||
{},
|
||||
h('dt', {}, 'Posts on Frontpage'),
|
||||
h('dd', {}, this.props.widgetsFor('posts').widgets.front_limit || 0),
|
||||
|
||||
h('dt', {}, 'Default Author'),
|
||||
h('dd', {}, this.props.widgetsFor('posts').data.author || 'None'),
|
||||
|
||||
h('dt', {}, 'Default Thumbnail'),
|
||||
h('dd', {}, thumb && h('img', { src: this.props.getAsset(thumb).toString() })),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const AuthorsPreview = createClass({
|
||||
render: function () {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, 'Authors'),
|
||||
this.props.widgetsFor('authors').map(function (author, index) {
|
||||
return h(
|
||||
'div',
|
||||
{ key: index },
|
||||
h('hr', {}),
|
||||
h('strong', {}, author.data.name),
|
||||
author.widgets.description,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const RelationKitchenSinkPostPreview = 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
|
||||
? h(
|
||||
'div',
|
||||
{ style: style },
|
||||
h('h2', {}, 'Related Post'),
|
||||
h('h3', {}, post.title),
|
||||
h('img', { src: post.image }),
|
||||
h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
|
||||
)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const CustomPage = () => {
|
||||
return h('div', {}, 'I am a custom page!');
|
||||
};
|
||||
|
||||
CMS.registerPreviewStyle('.toastui-editor-contents h1 { color: blue }', { raw: true });
|
||||
CMS.registerPreviewTemplate('posts', PostPreview);
|
||||
// CMS.registerPreviewTemplate('general', GeneralPreview);
|
||||
CMS.registerPreviewTemplate('general', GeneralPreview);
|
||||
CMS.registerPreviewTemplate('authors', AuthorsPreview);
|
||||
// Pass the name of a registered control to reuse with a new widget preview.
|
||||
CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);
|
||||
@ -104,3 +110,11 @@ CMS.registerAdditionalLink({
|
||||
icon: 'page',
|
||||
},
|
||||
});
|
||||
CMS.registerAdditionalLink({
|
||||
id: 'custom-page',
|
||||
title: 'Custom Page',
|
||||
data: CustomPage,
|
||||
options: {
|
||||
icon: 'page',
|
||||
},
|
||||
});
|
||||
|
@ -68,7 +68,6 @@ export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
|
||||
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
|
||||
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
|
||||
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
|
||||
export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS';
|
||||
export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED';
|
||||
export const DRAFT_LOCAL_BACKUP_DELETE = 'DRAFT_LOCAL_BACKUP_DELETE';
|
||||
export const DRAFT_CREATE_FROM_LOCAL_BACKUP = 'DRAFT_CREATE_FROM_LOCAL_BACKUP';
|
||||
@ -279,7 +278,7 @@ async function getAllEntries(state: RootState, collection: Collection) {
|
||||
const backend = currentBackend(configState.config);
|
||||
const integration = selectIntegration(state, collection.name, 'listEntries');
|
||||
const provider = integration
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)
|
||||
? getSearchIntegrationProvider(state.integrations, integration)
|
||||
: backend;
|
||||
|
||||
if (!provider) {
|
||||
@ -497,10 +496,6 @@ export function changeDraftFieldValidation(path: string, errors: FieldError[]) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function clearFieldErrors() {
|
||||
return { type: DRAFT_CLEAR_ERRORS } as const;
|
||||
}
|
||||
|
||||
export function localBackupRetrieved(entry: Entry) {
|
||||
return {
|
||||
type: DRAFT_LOCAL_BACKUP_RETRIEVED,
|
||||
@ -688,7 +683,7 @@ export function loadEntries(collection: Collection, page = 0) {
|
||||
const backend = currentBackend(configState.config);
|
||||
const integration = selectIntegration(state, collection.name, 'listEntries');
|
||||
const provider = integration
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)
|
||||
? getSearchIntegrationProvider(state.integrations, integration)
|
||||
: backend;
|
||||
|
||||
if (!provider) {
|
||||
@ -1142,7 +1137,6 @@ export type EntriesAction = ReturnType<
|
||||
| typeof discardDraft
|
||||
| typeof changeDraftField
|
||||
| typeof changeDraftFieldValidation
|
||||
| typeof clearFieldErrors
|
||||
| typeof localBackupRetrieved
|
||||
| typeof loadLocalBackup
|
||||
| typeof deleteDraftLocalBackup
|
||||
|
@ -1,9 +1,8 @@
|
||||
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 { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util';
|
||||
import { selectEditingDraft } from '../reducers/entries';
|
||||
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
|
||||
import { addSnackbar } from '../store/slices/snackbars';
|
||||
@ -11,13 +10,12 @@ 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,
|
||||
Field,
|
||||
ImplementationMediaFile,
|
||||
MediaFile,
|
||||
MediaLibraryInstance,
|
||||
@ -78,7 +76,6 @@ export function openMediaLibrary(
|
||||
payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
privateUpload?: boolean;
|
||||
value?: string | string[];
|
||||
allowMultiple?: boolean;
|
||||
replaceIndex?: number;
|
||||
@ -134,10 +131,8 @@ 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;
|
||||
export function loadMedia(opts: { delay?: number; query?: string; page?: number } = {}) {
|
||||
const { delay = 0, page = 1 } = opts;
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
@ -146,32 +141,6 @@ export function loadMedia(
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -225,7 +194,7 @@ function createMediaFileFromAsset({
|
||||
}
|
||||
|
||||
export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
const { privateUpload, field } = opts;
|
||||
const { field } = opts;
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
@ -234,7 +203,6 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -247,7 +215,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
* 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 (existingFile) {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'mediaLibrary.mediaLibrary.alreadyExistsTitle',
|
||||
@ -260,60 +228,28 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
await dispatch(deleteMedia(existingFile, { privateUpload }));
|
||||
await dispatch(deleteMedia(existingFile));
|
||||
}
|
||||
}
|
||||
|
||||
if (integration || !editingDraft) {
|
||||
if (!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) {
|
||||
console.error('The Private Upload option is only available for Asset Store Integration')
|
||||
throw new Error('The Private Upload option is only available for Asset Store Integration');
|
||||
} else {
|
||||
const entry = state.entryDraft.entry;
|
||||
const collection = entry?.collection ? state.collections[entry.collection] : null;
|
||||
const path = selectMediaFilePath(config, collection, entry, fileName, field);
|
||||
assetProxy = createAssetProxy({
|
||||
file,
|
||||
path,
|
||||
field,
|
||||
});
|
||||
}
|
||||
const entry = state.entryDraft.entry;
|
||||
const collection = entry?.collection ? state.collections[entry.collection] : null;
|
||||
const path = selectMediaFilePath(config, collection, entry, fileName, field);
|
||||
const 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) {
|
||||
if (editingDraft) {
|
||||
const id = await getBlobSHA(file);
|
||||
mediaFile = createMediaFileFromAsset({
|
||||
id,
|
||||
@ -326,7 +262,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
mediaFile = await backend.persistMedia(config, assetProxy);
|
||||
}
|
||||
|
||||
return dispatch(mediaPersisted(mediaFile, { privateUpload }));
|
||||
return dispatch(mediaPersisted(mediaFile));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
@ -340,13 +276,12 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaPersistFailed({ privateUpload }));
|
||||
return dispatch(mediaPersistFailed());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
export function deleteMedia(file: MediaFile) {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
@ -355,42 +290,6 @@ export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
|
||||
}
|
||||
|
||||
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',
|
||||
options: {
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(mediaDeleteFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.draft) {
|
||||
@ -490,7 +389,6 @@ export function loadMediaDisplayURL(file: MediaFile) {
|
||||
function mediaLibraryOpened(payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
privateUpload?: boolean;
|
||||
value?: string | string[];
|
||||
replaceIndex?: number;
|
||||
allowMultiple?: boolean;
|
||||
@ -516,7 +414,6 @@ export function mediaLoading(page: number) {
|
||||
}
|
||||
|
||||
export interface MediaOptions {
|
||||
privateUpload?: boolean;
|
||||
field?: Field;
|
||||
page?: number;
|
||||
canPaginate?: boolean;
|
||||
@ -531,43 +428,38 @@ export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaLoadFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } } as const;
|
||||
export function mediaLoadFailed() {
|
||||
return { type: MEDIA_LOAD_FAILURE } as const;
|
||||
}
|
||||
|
||||
export function mediaPersisting() {
|
||||
return { type: MEDIA_PERSIST_REQUEST } as const;
|
||||
}
|
||||
|
||||
export function mediaPersisted(file: ImplementationMediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
export function mediaPersisted(file: ImplementationMediaFile) {
|
||||
return {
|
||||
type: MEDIA_PERSIST_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
payload: { file },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaPersistFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } } as const;
|
||||
export function mediaPersistFailed() {
|
||||
return { type: MEDIA_PERSIST_FAILURE } as const;
|
||||
}
|
||||
|
||||
export function mediaDeleting() {
|
||||
return { type: MEDIA_DELETE_REQUEST } as const;
|
||||
}
|
||||
|
||||
export function mediaDeleted(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
export function mediaDeleted(file: MediaFile) {
|
||||
return {
|
||||
type: MEDIA_DELETE_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
payload: { file },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaDeleteFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } } as const;
|
||||
export function mediaDeleteFailed() {
|
||||
return { type: MEDIA_DELETE_FAILURE } as const;
|
||||
}
|
||||
|
||||
export function mediaDisplayURLRequest(key: string) {
|
||||
|
@ -122,7 +122,7 @@ export function searchEntries(searchTerm: string, searchCollections: string[], p
|
||||
dispatch(searchingEntries(searchTerm, allCollections, page));
|
||||
|
||||
const searchPromise = integration
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.search(
|
||||
? getSearchIntegrationProvider(state.integrations, integration)?.search(
|
||||
collections,
|
||||
searchTerm,
|
||||
page,
|
||||
@ -179,7 +179,7 @@ export function query(
|
||||
}
|
||||
|
||||
const queryPromise = integration
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.searchBy(
|
||||
? getSearchIntegrationProvider(state.integrations, integration)?.searchBy(
|
||||
JSON.stringify(searchFields.map(f => `data.${f}`)),
|
||||
collectionName,
|
||||
searchTerm,
|
||||
|
@ -41,9 +41,8 @@ import {
|
||||
} from './lib/util/collection.util';
|
||||
import { selectMediaFilePath } from './lib/util/media.util';
|
||||
import { set } from './lib/util/object.util';
|
||||
import { selectIntegration } from './reducers/integrations';
|
||||
import { createEntry } from './valueObjects/Entry';
|
||||
import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
|
||||
import { createEntry } from './valueObjects/Entry';
|
||||
|
||||
import type {
|
||||
BackendClass,
|
||||
@ -778,9 +777,8 @@ export class Backend {
|
||||
throw new Error('Config not loaded');
|
||||
}
|
||||
|
||||
const integration = selectIntegration(state.integrations, null, 'assetStore');
|
||||
const mediaFolders = selectMediaFolders(configState.config, collection, entry);
|
||||
if (mediaFolders.length > 0 && !integration) {
|
||||
if (mediaFolders.length > 0) {
|
||||
const files = await Promise.all(
|
||||
mediaFolders.map(folder => this.implementation.getMedia(folder)),
|
||||
);
|
||||
|
@ -1,425 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
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.toString() } },
|
||||
});
|
||||
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;
|
@ -1,265 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export { default as AzureBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
@ -1,4 +1,3 @@
|
||||
export { AzureBackend } from './azure';
|
||||
export { BitbucketBackend } from './bitbucket';
|
||||
export { GitGatewayBackend } from './git-gateway';
|
||||
export { GitHubBackend } from './github';
|
||||
|
@ -16,6 +16,7 @@ import { history } from '../../routing/history';
|
||||
import CollectionRoute from '../Collection/CollectionRoute';
|
||||
import EditorRoute from '../Editor/EditorRoute';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Page from '../page/Page';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
@ -212,6 +213,7 @@ const App = ({
|
||||
element={<CollectionRoute collections={collections} isSearchResults />}
|
||||
/>
|
||||
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
|
||||
<Route path="/page/:id" element={<Page />} />
|
||||
<Route element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
|
@ -57,9 +57,9 @@ const Sidebar = ({
|
||||
const iconName = collection.icon;
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon();
|
||||
const StoredIcon = getIcon(iconName);
|
||||
if (StoredIcon) {
|
||||
icon = <StoredIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,9 +99,9 @@ const Sidebar = ({
|
||||
Object.values(additionalLinks).map(({ id, title, data, options: { iconName } = {} }) => {
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon();
|
||||
const StoredIcon = getIcon(iconName);
|
||||
if (StoredIcon) {
|
||||
icon = <StoredIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7,8 +7,6 @@ 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 {
|
||||
@ -17,7 +15,7 @@ import {
|
||||
removeInsertedMedia as removeInsertedMediaAction,
|
||||
removeMediaControl as removeMediaControlAction,
|
||||
} from '../../../actions/mediaLibrary';
|
||||
import { clearSearch as clearSearchAction, query as queryAction } from '../../../actions/search';
|
||||
import { query as queryAction } from '../../../actions/search';
|
||||
import { borders, colors, lengths, transitions } from '../../../components/UI/styles';
|
||||
import { transientOptions } from '../../../lib';
|
||||
import { resolveWidget } from '../../../lib/registry';
|
||||
@ -37,7 +35,6 @@ import type {
|
||||
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
|
||||
@ -133,9 +130,7 @@ const ControlHint = styled(
|
||||
|
||||
const EditorControl = ({
|
||||
className,
|
||||
clearFieldErrors,
|
||||
clearMediaControl,
|
||||
clearSearch,
|
||||
collection,
|
||||
config: configState,
|
||||
entry,
|
||||
@ -144,11 +139,9 @@ const EditorControl = ({
|
||||
submitted,
|
||||
getAsset,
|
||||
isDisabled,
|
||||
isFetching,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isHidden = false,
|
||||
loadEntry,
|
||||
locale,
|
||||
mediaPaths,
|
||||
changeDraftFieldValidation,
|
||||
@ -210,8 +203,6 @@ const EditorControl = ({
|
||||
<>
|
||||
{React.createElement(widget.control, {
|
||||
key: `field_${path}`,
|
||||
clearFieldErrors,
|
||||
clearSearch,
|
||||
collection,
|
||||
config,
|
||||
entry,
|
||||
@ -220,11 +211,9 @@ const EditorControl = ({
|
||||
submitted,
|
||||
getAsset: handleGetAsset,
|
||||
isDisabled: isDisabled ?? false,
|
||||
isFetching,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
label: getFieldLabel(field, t),
|
||||
loadEntry,
|
||||
locale,
|
||||
mediaPaths,
|
||||
onChange: handleChangeDraftField,
|
||||
@ -264,7 +253,6 @@ const EditorControl = ({
|
||||
|
||||
interface EditorControlOwnProps {
|
||||
className?: string;
|
||||
clearFieldErrors: EditorControlPaneProps['clearFieldErrors'];
|
||||
field: Field;
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
@ -285,25 +273,13 @@ function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -315,8 +291,6 @@ const mapDispatchToProps = {
|
||||
removeMediaControl: removeMediaControlAction,
|
||||
removeInsertedMedia: removeInsertedMediaAction,
|
||||
query: queryAction,
|
||||
clearSearch: clearSearchAction,
|
||||
clearFieldErrors: clearFieldErrorsAction,
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
|
@ -6,10 +6,7 @@ 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 { changeDraftField as changeDraftFieldAction } from '../../../actions/entries';
|
||||
import confirm from '../../../components/UI/Confirm';
|
||||
import {
|
||||
getI18nInfo,
|
||||
@ -115,7 +112,6 @@ const EditorControlPane = ({
|
||||
changeDraftField,
|
||||
locale,
|
||||
onLocaleChange,
|
||||
clearFieldErrors,
|
||||
t,
|
||||
}: TranslatedProps<EditorControlPaneProps>) => {
|
||||
const i18n = useMemo(() => {
|
||||
@ -211,7 +207,6 @@ const EditorControlPane = ({
|
||||
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
|
||||
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
|
||||
locale={locale}
|
||||
clearFieldErrors={clearFieldErrors}
|
||||
parentPath=""
|
||||
i18n={i18n}
|
||||
/>
|
||||
@ -239,7 +234,6 @@ function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps)
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
clearFieldErrors: clearFieldErrorsAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
@ -212,16 +212,11 @@ function getWidget(
|
||||
config={config}
|
||||
collection={collection}
|
||||
value={
|
||||
value &&
|
||||
!widget.allowMapValue &&
|
||||
typeof value === 'object' &&
|
||||
!isJsxElement(value) &&
|
||||
!isReactFragment(value)
|
||||
value && typeof value === 'object' && !isJsxElement(value) && !isReactFragment(value)
|
||||
? (value as Record<string, unknown>)[field.name]
|
||||
: value
|
||||
}
|
||||
entry={entry}
|
||||
resolveWidget={resolveWidget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,35 +1,21 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import { transientOptions } from '../../lib';
|
||||
|
||||
interface EmptyMessageContainerProps {
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const EmptyMessageContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<EmptyMessageContainerProps>(
|
||||
({ $isPrivate }) => `
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
${$isPrivate ? `color: ${colors.textFieldBorder};` : ''}
|
||||
`,
|
||||
);
|
||||
const EmptyMessageContainer = styled('div')`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
interface EmptyMessageProps {
|
||||
content: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
const EmptyMessage = ({ content, isPrivate = false }: EmptyMessageProps) => {
|
||||
const EmptyMessage = ({ content }: EmptyMessageProps) => {
|
||||
return (
|
||||
<EmptyMessageContainer $isPrivate={isPrivate}>
|
||||
<EmptyMessageContainer>
|
||||
<h1>{content}</h1>
|
||||
</EmptyMessageContainer>
|
||||
);
|
||||
|
@ -53,7 +53,6 @@ const MediaLibrary = ({
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload = false,
|
||||
config,
|
||||
loadMedia,
|
||||
dynamicSearchQuery,
|
||||
@ -69,7 +68,6 @@ const MediaLibrary = ({
|
||||
const [query, setQuery] = useState<string | undefined>(undefined);
|
||||
|
||||
const [prevIsVisible, setPrevIsVisible] = useState(false);
|
||||
const [prevPrivateUpload, setPrevPrivateUpload] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMedia();
|
||||
@ -85,14 +83,10 @@ const MediaLibrary = ({
|
||||
}, [isVisible, prevIsVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevPrivateUpload(privateUpload);
|
||||
}, [privateUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevIsVisible && isVisible && !prevPrivateUpload && privateUpload) {
|
||||
loadMedia({ privateUpload });
|
||||
if (!prevIsVisible && isVisible) {
|
||||
loadMedia();
|
||||
}
|
||||
}, [isVisible, loadMedia, prevIsVisible, prevPrivateUpload, privateUpload]);
|
||||
}, [isVisible, loadMedia, prevIsVisible]);
|
||||
|
||||
const loadDisplayURL = useCallback(
|
||||
(file: MediaFile) => {
|
||||
@ -208,7 +202,7 @@ const MediaLibrary = ({
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await persistMedia(file, { privateUpload, field });
|
||||
await persistMedia(file, { field });
|
||||
|
||||
setSelectedFile(files[0] as unknown as MediaFile);
|
||||
|
||||
@ -219,7 +213,7 @@ const MediaLibrary = ({
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
[config.max_file_size, field, persistMedia, privateUpload],
|
||||
[config.max_file_size, field, persistMedia],
|
||||
);
|
||||
|
||||
/**
|
||||
@ -251,11 +245,11 @@ const MediaLibrary = ({
|
||||
}
|
||||
const file = files.find(file => selectedFile?.key === file.key);
|
||||
if (file) {
|
||||
deleteMedia(file, { privateUpload }).then(() => {
|
||||
deleteMedia(file).then(() => {
|
||||
setSelectedFile(null);
|
||||
});
|
||||
}
|
||||
}, [deleteMedia, files, privateUpload, selectedFile?.key]);
|
||||
}, [deleteMedia, files, selectedFile?.key]);
|
||||
|
||||
/**
|
||||
* Downloads the selected file.
|
||||
@ -286,8 +280,8 @@ const MediaLibrary = ({
|
||||
}, [displayURLs, selectedFile]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
loadMedia({ query: dynamicSearchQuery, page: (page ?? 0) + 1, privateUpload });
|
||||
}, [dynamicSearchQuery, loadMedia, page, privateUpload]);
|
||||
loadMedia({ query: dynamicSearchQuery, page: (page ?? 0) + 1 });
|
||||
}, [dynamicSearchQuery, loadMedia, page]);
|
||||
|
||||
/**
|
||||
* Executes media library search for implementations that support dynamic
|
||||
@ -299,11 +293,11 @@ const MediaLibrary = ({
|
||||
const handleSearchKeyDown = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && dynamicSearch) {
|
||||
await loadMedia({ query, privateUpload });
|
||||
await loadMedia({ query });
|
||||
scrollToTop();
|
||||
}
|
||||
},
|
||||
[dynamicSearch, loadMedia, privateUpload, query],
|
||||
[dynamicSearch, loadMedia, query],
|
||||
);
|
||||
|
||||
/**
|
||||
@ -344,7 +338,6 @@ const MediaLibrary = ({
|
||||
isDeleting={isDeleting}
|
||||
hasNextPage={hasNextPage}
|
||||
isPaginating={isPaginating}
|
||||
privateUpload={privateUpload}
|
||||
query={query}
|
||||
selectedFile={selectedFile}
|
||||
handleFilter={filterImages}
|
||||
@ -382,7 +375,6 @@ function mapStateToProps(state: RootState) {
|
||||
isLoading: mediaLibrary.isLoading,
|
||||
isPersisting: mediaLibrary.isPersisting,
|
||||
isDeleting: mediaLibrary.isDeleting,
|
||||
privateUpload: mediaLibrary.privateUpload,
|
||||
config: mediaLibrary.config,
|
||||
page: mediaLibrary.page,
|
||||
hasNextPage: mediaLibrary.hasNextPage,
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { borders, colors, effects, lengths, shadows } from '../../components/UI/styles';
|
||||
import { transientOptions } from '../../lib';
|
||||
|
||||
import type { MediaLibraryDisplayURL } from '../../reducers/mediaLibrary';
|
||||
|
||||
@ -13,14 +13,13 @@ interface CardProps {
|
||||
$height: string;
|
||||
$margin: string;
|
||||
$isSelected: boolean;
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const Card = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<CardProps>(
|
||||
({ $width, $height, $margin, $isSelected, $isPrivate }) => `
|
||||
({ $width, $height, $margin, $isSelected }) => `
|
||||
width: ${$width};
|
||||
height: ${$height};
|
||||
margin: ${$margin};
|
||||
@ -29,7 +28,6 @@ const Card = styled(
|
||||
border-radius: ${lengths.borderRadius};
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
${$isPrivate ? `background-color: ${colors.textFieldBorder};` : ''}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
@ -86,7 +84,6 @@ interface MediaLibraryCardProps {
|
||||
width: string;
|
||||
height: string;
|
||||
margin: string;
|
||||
isPrivate?: boolean;
|
||||
type?: string;
|
||||
isViewableImage: boolean;
|
||||
loadDisplayURL: () => void;
|
||||
@ -102,7 +99,6 @@ const MediaLibraryCard = ({
|
||||
width,
|
||||
height,
|
||||
margin,
|
||||
isPrivate = false,
|
||||
type,
|
||||
isViewableImage,
|
||||
isDraft,
|
||||
@ -122,7 +118,6 @@ const MediaLibraryCard = ({
|
||||
$width={width}
|
||||
$height={height}
|
||||
$margin={margin}
|
||||
$isPrivate={isPrivate}
|
||||
onClick={onClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
@ -4,13 +4,11 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import MediaLibraryCard from './MediaLibraryCard';
|
||||
|
||||
import type { GridChildComponentProps } from 'react-window';
|
||||
import type { MediaLibraryDisplayURL, MediaLibraryState } from '../../reducers/mediaLibrary';
|
||||
import type { MediaFile } from '../../interface';
|
||||
import type { MediaLibraryDisplayURL, MediaLibraryState } from '../../reducers/mediaLibrary';
|
||||
|
||||
export interface MediaLibraryCardItem {
|
||||
displayURL?: MediaLibraryDisplayURL;
|
||||
@ -37,7 +35,6 @@ export interface MediaLibraryCardGridProps {
|
||||
cardHeight: string;
|
||||
cardMargin: string;
|
||||
loadDisplayURL: (asset: MediaFile) => void;
|
||||
isPrivate?: boolean;
|
||||
displayURLs: MediaLibraryState['displayURLs'];
|
||||
}
|
||||
|
||||
@ -57,7 +54,6 @@ const CardWrapper = ({
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
isPrivate,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
columnCount,
|
||||
@ -90,7 +86,6 @@ const CardWrapper = ({
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={'0px'}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
@ -168,19 +163,6 @@ const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface PaginatingMessageProps {
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const PaginatingMessage = styled(
|
||||
'h1',
|
||||
transientOptions,
|
||||
)<PaginatingMessageProps>(
|
||||
({ $isPrivate }) => `
|
||||
${$isPrivate ? `color: ${colors.textFieldBorder};` : ''}
|
||||
`,
|
||||
);
|
||||
|
||||
const PaginatedGrid = ({
|
||||
setScrollContainerRef,
|
||||
mediaItems,
|
||||
@ -190,7 +172,6 @@ const PaginatedGrid = ({
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
cardMargin,
|
||||
isPrivate = false,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
canLoadMore,
|
||||
@ -212,7 +193,6 @@ const PaginatedGrid = ({
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={cardMargin}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
@ -221,9 +201,7 @@ const PaginatedGrid = ({
|
||||
))}
|
||||
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
|
||||
</CardGrid>
|
||||
{!isPaginating ? null : (
|
||||
<PaginatingMessage $isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
|
||||
)}
|
||||
{!isPaginating ? null : <h1>{paginatingMessage}</h1>}
|
||||
</StyledCardGridContainer>
|
||||
);
|
||||
};
|
||||
|
@ -1,14 +1,12 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { colors, colorsRaw } from '../../components/UI/styles';
|
||||
import EmptyMessage from './EmptyMessage';
|
||||
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
||||
import MediaLibraryTop from './MediaLibraryTop';
|
||||
@ -37,60 +35,41 @@ const cardMargin = `10px`;
|
||||
*/
|
||||
const cardOutsideWidth = `300px`;
|
||||
|
||||
interface StyledModalProps {
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
const StyledModal = styled(Dialog)`
|
||||
.MuiDialog-paper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
height: 80%;
|
||||
width: calc(${cardOutsideWidth} + 20px);
|
||||
max-width: calc(${cardOutsideWidth} + 20px);
|
||||
|
||||
const StyledModal = styled(
|
||||
Dialog,
|
||||
transientOptions,
|
||||
)<StyledModalProps>(
|
||||
({ $isPrivate }) => `
|
||||
.MuiDialog-paper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
height: 80%;
|
||||
width: calc(${cardOutsideWidth} + 20px);
|
||||
max-width: calc(${cardOutsideWidth} + 20px);
|
||||
${$isPrivate ? `background-color: ${colorsRaw.grayDark};` : ''}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1760px) {
|
||||
width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 2080px) {
|
||||
width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
${$isPrivate && `color: ${colors.textFieldBorder};`}
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
label[disabled] {
|
||||
${$isPrivate ? 'background-color: rgba(217, 217, 217, 0.15);' : ''}
|
||||
}
|
||||
@media (min-width: 800px) {
|
||||
width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1760px) {
|
||||
width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 2080px) {
|
||||
width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface MediaLibraryModalProps {
|
||||
isVisible?: boolean;
|
||||
@ -104,7 +83,6 @@ interface MediaLibraryModalProps {
|
||||
isDeleting?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
isPaginating?: boolean;
|
||||
privateUpload?: boolean;
|
||||
query?: string;
|
||||
selectedFile?: MediaFile;
|
||||
handleFilter: (files: MediaFile[]) => MediaFile[];
|
||||
@ -136,7 +114,6 @@ const MediaLibraryModal = ({
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload = false,
|
||||
query,
|
||||
selectedFile,
|
||||
handleFilter,
|
||||
@ -175,14 +152,13 @@ const MediaLibraryModal = ({
|
||||
const hasSelection = hasMedia && !isEmpty(selectedFile);
|
||||
|
||||
return (
|
||||
<StyledModal open={isVisible} onClose={handleClose} $isPrivate={privateUpload}>
|
||||
<StyledModal open={isVisible} onClose={handleClose}>
|
||||
<StyledFab color="default" aria-label="add" onClick={handleClose} size="small">
|
||||
<CloseIcon />
|
||||
</StyledFab>
|
||||
<MediaLibraryTop
|
||||
t={t}
|
||||
onClose={handleClose}
|
||||
privateUpload={privateUpload}
|
||||
forImage={forImage}
|
||||
onDownload={handleDownload}
|
||||
onUpload={handlePersist}
|
||||
@ -199,9 +175,7 @@ const MediaLibraryModal = ({
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
<DialogContent>
|
||||
{!shouldShowEmptyMessage ? null : (
|
||||
<EmptyMessage content={emptyMessage} isPrivate={privateUpload} />
|
||||
)}
|
||||
{!shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} />}
|
||||
<MediaLibraryCardGrid
|
||||
setScrollContainerRef={setScrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
@ -215,7 +189,6 @@ const MediaLibraryModal = ({
|
||||
cardWidth={cardWidth}
|
||||
cardHeight={cardHeight}
|
||||
cardMargin={cardMargin}
|
||||
isPrivate={privateUpload}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
displayURLs={displayURLs}
|
||||
/>
|
||||
|
@ -29,7 +29,6 @@ const StyledDialogTitle = styled(DialogTitle)`
|
||||
|
||||
export interface MediaLibraryTopProps {
|
||||
onClose: () => void;
|
||||
privateUpload?: boolean;
|
||||
forImage?: boolean;
|
||||
onDownload: () => void;
|
||||
onUpload: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
|
||||
@ -62,7 +61,6 @@ const MediaLibraryTop = ({
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
selectedFile,
|
||||
privateUpload,
|
||||
}: TranslatedProps<MediaLibraryTopProps>) => {
|
||||
const shouldShowButtonLoader = isPersisting || isDeleting;
|
||||
const uploadEnabled = !shouldShowButtonLoader;
|
||||
@ -80,11 +78,9 @@ const MediaLibraryTop = ({
|
||||
return (
|
||||
<LibraryTop>
|
||||
<StyledDialogTitle>
|
||||
{`${privateUpload ? t('mediaLibrary.mediaLibraryModal.private') : ''}${
|
||||
forImage
|
||||
? t('mediaLibrary.mediaLibraryModal.images')
|
||||
: t('mediaLibrary.mediaLibraryModal.mediaAssets')
|
||||
}`}
|
||||
{forImage
|
||||
? t('mediaLibrary.mediaLibraryModal.images')
|
||||
: t('mediaLibrary.mediaLibraryModal.mediaAssets')}
|
||||
<StyledButtonsContainer>
|
||||
<CopyToClipBoardButton
|
||||
disabled={!hasSelection}
|
||||
|
@ -1,11 +1,9 @@
|
||||
import azure from './azure.svg';
|
||||
import bitbucket from './bitbucket.svg';
|
||||
import github from './github.svg';
|
||||
import gitlab from './gitlab.svg';
|
||||
import staticCms from './static-cms-logo.svg';
|
||||
|
||||
const images = {
|
||||
azure,
|
||||
bitbucket,
|
||||
github,
|
||||
gitlab,
|
||||
|
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 26 26"
|
||||
height="26px"
|
||||
width="26px">
|
||||
<path
|
||||
d="M 14.015456,4.2171913 7.0990002,9.9261887 1.5,19.751857 l 5.2698338,0.05491 z m 0.768596,1.2626133 -3.019209,8.0141944 5.599244,6.312735 L 6.6049864,21.727927 24.5,21.782809 Z" id="Shape" fill="#2684FF" fill-rule="nonzero" />
|
||||
</svg>
|
Before Width: | Height: | Size: 382 B |
@ -4,23 +4,19 @@ import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { lengths } from '../../components/UI/styles';
|
||||
import { getAdditionalLink } from '../../lib/registry';
|
||||
import MainView from '../App/MainView';
|
||||
import Sidebar from '../Collection/Sidebar';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const StylePage = styled('div')`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const StyledPageContent = styled('div')`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Page = ({ collections, isSearchEnabled, searchTerm, filterTerm }: PageProps) => {
|
||||
@ -51,7 +47,7 @@ const Page = ({ collections, isSearchEnabled, searchTerm, filterTerm }: PageProp
|
||||
}, [Content]);
|
||||
|
||||
return (
|
||||
<StylePage>
|
||||
<MainView>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={false}
|
||||
@ -60,7 +56,7 @@ const Page = ({ collections, isSearchEnabled, searchTerm, filterTerm }: PageProp
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
{pageContent}
|
||||
</StylePage>
|
||||
</MainView>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -202,8 +202,6 @@ function getConfigSchema() {
|
||||
label_singular: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
preview_path: { type: 'string' },
|
||||
preview_path_date_field: { type: 'string' },
|
||||
editor: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -220,8 +218,6 @@ function getConfigSchema() {
|
||||
summary: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
preview_path: { type: 'string' },
|
||||
preview_path_date_field: { type: 'string' },
|
||||
create: { type: 'boolean' },
|
||||
publish: { type: 'boolean' },
|
||||
hide: { type: 'boolean' },
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
AzureBackend,
|
||||
BitbucketBackend,
|
||||
GitGatewayBackend,
|
||||
GitHubBackend,
|
||||
@ -30,7 +29,6 @@ import {
|
||||
export function addExtensions() {
|
||||
// Register all the things
|
||||
registerBackend('git-gateway', GitGatewayBackend);
|
||||
registerBackend('azure', AzureBackend);
|
||||
registerBackend('github', GitHubBackend);
|
||||
registerBackend('gitlab', GitLabBackend);
|
||||
registerBackend('bitbucket', BitbucketBackend);
|
||||
|
@ -18,6 +18,10 @@ export const CMS = {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CMS = CMS;
|
||||
window.createClass = window.createClass || createReactClass;
|
||||
window.useState = window.useState || React.useState;
|
||||
window.useMemo = window.useMemo || React.useMemo;
|
||||
window.useEffect = window.useEffect || React.useEffect;
|
||||
window.useCallback = window.useCallback || React.useCallback;
|
||||
window.h = window.h || React.createElement;
|
||||
}
|
||||
|
||||
|
@ -1,28 +1,19 @@
|
||||
import Algolia from './providers/algolia/implementation';
|
||||
import AssetStore from './providers/assetStore/implementation';
|
||||
|
||||
import type {
|
||||
AlgoliaConfig,
|
||||
AssetStoreConfig,
|
||||
MediaIntegrationProvider,
|
||||
SearchIntegrationProvider,
|
||||
} from '../interface';
|
||||
import type { AlgoliaConfig, SearchIntegrationProvider } from '../interface';
|
||||
|
||||
interface IntegrationsConfig {
|
||||
providers?: {
|
||||
algolia?: AlgoliaConfig;
|
||||
assetStore?: AssetStoreConfig;
|
||||
};
|
||||
}
|
||||
|
||||
interface Integrations {
|
||||
algolia?: Algolia;
|
||||
assetStore?: AssetStore;
|
||||
}
|
||||
|
||||
export function resolveIntegrations(
|
||||
config: IntegrationsConfig | undefined,
|
||||
getToken: () => Promise<string | null>,
|
||||
) {
|
||||
const integrationInstances: Integrations = {};
|
||||
|
||||
@ -30,10 +21,6 @@ export function resolveIntegrations(
|
||||
integrationInstances.algolia = new Algolia(config.providers.algolia);
|
||||
}
|
||||
|
||||
if (config?.providers?.['assetStore']) {
|
||||
integrationInstances['assetStore'] = new AssetStore(config.providers['assetStore'], getToken);
|
||||
}
|
||||
|
||||
return integrationInstances;
|
||||
}
|
||||
|
||||
@ -42,32 +29,13 @@ export const getSearchIntegrationProvider = (function () {
|
||||
|
||||
return (
|
||||
config: IntegrationsConfig | undefined,
|
||||
getToken: () => Promise<string | null>,
|
||||
provider: SearchIntegrationProvider,
|
||||
) => {
|
||||
if (provider in (config?.providers ?? {}))
|
||||
if (integrations) {
|
||||
return integrations[provider];
|
||||
} else {
|
||||
integrations = resolveIntegrations(config, getToken);
|
||||
return integrations[provider];
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
export const getMediaIntegrationProvider = (function () {
|
||||
let integrations: Integrations = {};
|
||||
|
||||
return (
|
||||
config: IntegrationsConfig | undefined,
|
||||
getToken: () => Promise<string | null>,
|
||||
provider: MediaIntegrationProvider,
|
||||
) => {
|
||||
if (provider in (config?.providers ?? {}))
|
||||
if (integrations) {
|
||||
return integrations[provider];
|
||||
} else {
|
||||
integrations = resolveIntegrations(config, getToken);
|
||||
integrations = resolveIntegrations(config);
|
||||
return integrations[provider];
|
||||
}
|
||||
};
|
||||
|
@ -1,168 +0,0 @@
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import trimEnd from 'lodash/trimEnd';
|
||||
|
||||
import { unsentRequest } from '../../../lib/util';
|
||||
import { addParams } from '../../../lib/urlHelper';
|
||||
|
||||
import type { AssetStoreConfig } from '../../../interface';
|
||||
|
||||
const { fetchWithTimeout: fetch } = unsentRequest;
|
||||
|
||||
interface AssetStoreResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default class AssetStore {
|
||||
private shouldConfirmUpload: boolean;
|
||||
private getSignedFormURL: string;
|
||||
private getToken: () => Promise<string | null>;
|
||||
|
||||
constructor(config: AssetStoreConfig, getToken: () => Promise<string | null>) {
|
||||
if (config.getSignedFormURL == null) {
|
||||
throw 'The AssetStore integration needs the getSignedFormURL in the integration configuration.';
|
||||
}
|
||||
this.getToken = getToken;
|
||||
|
||||
this.shouldConfirmUpload = config.shouldConfirmUpload ?? false;
|
||||
this.getSignedFormURL = trimEnd(config.getSignedFormURL, '/');
|
||||
}
|
||||
|
||||
parseJsonResponse(response: Response) {
|
||||
return response.json().then(json => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
urlFor(path: string, optionParams: Record<string, string> = {}) {
|
||||
const params = [];
|
||||
for (const key in optionParams) {
|
||||
params.push(`${key}=${encodeURIComponent(optionParams[key])}`);
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
...headers,
|
||||
};
|
||||
}
|
||||
|
||||
confirmRequest(assetID: string) {
|
||||
this.getToken().then(token =>
|
||||
this.request(`${this.getSignedFormURL}/${assetID}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ state: 'uploaded' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async request(
|
||||
path: string,
|
||||
options: RequestInit & {
|
||||
params?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options.params);
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
const isJson = contentType && contentType.match(/json/);
|
||||
const content = isJson ? await this.parseJsonResponse(response) : response.text();
|
||||
return content;
|
||||
}
|
||||
|
||||
async retrieve(query: string, page: number, privateUpload: boolean) {
|
||||
const params = pickBy(
|
||||
{ search: query, page: `${page}`, filter: privateUpload ? 'private' : 'public' },
|
||||
val => !!val,
|
||||
);
|
||||
const url = addParams(this.getSignedFormURL, params);
|
||||
const token = await this.getToken();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const response: AssetStoreResponse[] = await this.request(url, { headers });
|
||||
const files = response.map(({ id, name, size, url }) => {
|
||||
return { id, name, size, displayURL: url, url, path: url };
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
delete(assetID: string) {
|
||||
const url = `${this.getSignedFormURL}/${assetID}`;
|
||||
return this.getToken().then(token =>
|
||||
this.request(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async upload(file: File, privateUpload = false) {
|
||||
const fileData: {
|
||||
name: string;
|
||||
size: number;
|
||||
content_type?: string;
|
||||
visibility?: 'private';
|
||||
} = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
if (file.type) {
|
||||
fileData.content_type = file.type;
|
||||
}
|
||||
|
||||
if (privateUpload) {
|
||||
fileData.visibility = 'private';
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await this.request(this.getSignedFormURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(fileData),
|
||||
});
|
||||
const formURL = response.form.url;
|
||||
const formFields = response.form.fields;
|
||||
const { id, name, size, url } = response.asset;
|
||||
|
||||
const formData = new FormData();
|
||||
Object.keys(formFields).forEach(key => formData.append(key, formFields[key]));
|
||||
formData.append('file', file, file.name);
|
||||
|
||||
await this.request(formURL, { method: 'POST', body: formData });
|
||||
|
||||
if (this.shouldConfirmUpload) {
|
||||
await this.confirmRequest(id);
|
||||
}
|
||||
|
||||
const asset = { id, name, size, displayURL: url, url, path: url };
|
||||
return { success: true, asset };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ import type {
|
||||
} from '@toast-ui/editor/types/editor';
|
||||
import type { ToolbarItemOptions as MarkdownToolbarItemOptions } from '@toast-ui/editor/types/ui';
|
||||
import type { PropertiesSchema } from 'ajv/dist/types/json-schema';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { FunctionComponent, ComponentType, ReactNode } from 'react';
|
||||
import type { t, TranslateProps as ReactPolyglotTranslateProps } from 'react-polyglot';
|
||||
import type { MediaFile as BackendMediaFile } from './backend';
|
||||
import type { EditorControlProps } from './components/Editor/EditorControlPane/EditorControl';
|
||||
@ -15,12 +15,6 @@ import type Cursor from './lib/util/Cursor';
|
||||
import type AssetProxy from './valueObjects/AssetProxy';
|
||||
import type { MediaHolder } from './widgets/markdown/hooks/useMedia';
|
||||
|
||||
export interface SlugConfig {
|
||||
encoding: string;
|
||||
clean_accents: boolean;
|
||||
sanitize_replacement: string;
|
||||
}
|
||||
|
||||
export interface Pages {
|
||||
[collection: string]: { isFetching?: boolean; page?: number; ids: string[] };
|
||||
}
|
||||
@ -68,6 +62,7 @@ export type ValueOrNestedValue =
|
||||
| number
|
||||
| boolean
|
||||
| string[]
|
||||
| (string | number)[]
|
||||
| null
|
||||
| undefined
|
||||
| ObjectValue
|
||||
@ -176,8 +171,6 @@ export interface Collection {
|
||||
isFetching?: boolean;
|
||||
media_folder?: string;
|
||||
public_folder?: string;
|
||||
preview_path?: string;
|
||||
preview_path_date_field?: string;
|
||||
summary?: string;
|
||||
filter?: FilterRule;
|
||||
type: 'file_based_collection' | 'folder_based_collection';
|
||||
@ -224,15 +217,11 @@ export interface DisplayURLState {
|
||||
err?: Error;
|
||||
}
|
||||
|
||||
export type Hook = string | boolean;
|
||||
|
||||
export type TranslatedProps<T> = T & ReactPolyglotTranslateProps;
|
||||
|
||||
export type GetAssetFunction = (path: string, field?: Field) => Promise<AssetProxy>;
|
||||
|
||||
export interface WidgetControlProps<T, F extends Field = Field> {
|
||||
clearFieldErrors: EditorControlProps['clearFieldErrors'];
|
||||
clearSearch: EditorControlProps['clearSearch'];
|
||||
collection: Collection;
|
||||
config: Config;
|
||||
entry: Entry;
|
||||
@ -242,11 +231,9 @@ export interface WidgetControlProps<T, F extends Field = Field> {
|
||||
forList: boolean;
|
||||
getAsset: GetAssetFunction;
|
||||
isDisabled: boolean;
|
||||
isFetching: boolean;
|
||||
isFieldDuplicate: EditorControlProps['isFieldDuplicate'];
|
||||
isFieldHidden: EditorControlProps['isFieldHidden'];
|
||||
label: string;
|
||||
loadEntry: EditorControlProps['loadEntry'];
|
||||
locale: string | undefined;
|
||||
mediaPaths: Record<string, string | string[]>;
|
||||
onChange: (value: T | null | undefined) => void;
|
||||
@ -268,7 +255,6 @@ export interface WidgetPreviewProps<T = unknown, F extends Field = Field> {
|
||||
entry: Entry;
|
||||
field: RenderedField<F>;
|
||||
getAsset: GetAssetFunction;
|
||||
resolveWidget: <W = unknown, WF extends Field = Field>(name: string) => Widget<W, WF>;
|
||||
value: T | undefined | null;
|
||||
}
|
||||
|
||||
@ -305,7 +291,6 @@ export interface WidgetOptions<T = unknown, F extends Field = Field> {
|
||||
validator?: Widget<T, F>['validator'];
|
||||
getValidValue?: Widget<T, F>['getValidValue'];
|
||||
schema?: Widget<T, F>['schema'];
|
||||
allowMapValue?: boolean;
|
||||
}
|
||||
|
||||
export interface Widget<T = unknown, F extends Field = Field> {
|
||||
@ -314,7 +299,6 @@ export interface Widget<T = unknown, F extends Field = Field> {
|
||||
validator: FieldValidationMethod<T, F>;
|
||||
getValidValue: (value: T | undefined | null) => T | undefined | null;
|
||||
schema?: PropertiesSchema<unknown>;
|
||||
allowMapValue?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetParam<T = unknown, F extends Field = Field> {
|
||||
@ -367,8 +351,6 @@ export interface BackendEntry {
|
||||
assets: AssetProxy[];
|
||||
}
|
||||
|
||||
export type DeleteOptions = {};
|
||||
|
||||
export interface Credentials {
|
||||
token: string | {};
|
||||
refresh_token?: string;
|
||||
@ -446,7 +428,7 @@ export interface LocalePhrasesRoot {
|
||||
}
|
||||
export type LocalePhrases = string | { [property: string]: LocalePhrases };
|
||||
|
||||
export type CustomIcon = () => JSX.Element;
|
||||
export type CustomIcon = FunctionComponent;
|
||||
|
||||
export type WidgetValueSerializer = {
|
||||
serialize: (value: ValueOrNestedValue) => ValueOrNestedValue;
|
||||
@ -473,33 +455,10 @@ export interface MediaLibraryInternalOptions {
|
||||
|
||||
export type MediaLibrary = MediaLibraryExternalLibrary | MediaLibraryInternalOptions;
|
||||
|
||||
export type BackendType =
|
||||
| 'azure'
|
||||
| 'git-gateway'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'bitbucket'
|
||||
| 'test-repo'
|
||||
| 'proxy';
|
||||
export type BackendType = 'git-gateway' | 'github' | 'gitlab' | 'bitbucket' | 'test-repo' | 'proxy';
|
||||
|
||||
export type MapWidgetType = 'Point' | 'LineString' | 'Polygon';
|
||||
|
||||
export type MarkdownWidgetButton =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'code'
|
||||
| 'link'
|
||||
| 'heading-one'
|
||||
| 'heading-two'
|
||||
| 'heading-three'
|
||||
| 'heading-four'
|
||||
| 'heading-five'
|
||||
| 'heading-six'
|
||||
| 'quote'
|
||||
| 'code-block'
|
||||
| 'bulleted-list'
|
||||
| 'numbered-list';
|
||||
|
||||
export interface SelectWidgetOptionObject {
|
||||
label: string;
|
||||
value: string;
|
||||
@ -567,12 +526,10 @@ export interface FileOrImageField extends BaseField {
|
||||
media_library?: MediaLibrary;
|
||||
media_folder?: string;
|
||||
public_folder?: string;
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
export interface ObjectField extends BaseField {
|
||||
widget: 'object';
|
||||
default?: ObjectValue;
|
||||
|
||||
collapsed?: boolean;
|
||||
summary?: string;
|
||||
@ -626,9 +583,9 @@ export interface NumberField extends BaseField {
|
||||
|
||||
export interface SelectField extends BaseField {
|
||||
widget: 'select';
|
||||
default?: string | string[];
|
||||
default?: string | number | (string | number)[];
|
||||
|
||||
options: string[] | SelectWidgetOptionObject[];
|
||||
options: (string | number)[] | SelectWidgetOptionObject[];
|
||||
multiple?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
@ -708,12 +665,9 @@ export interface SortableFields {
|
||||
|
||||
export interface Backend {
|
||||
name: BackendType;
|
||||
auth_scope?: AuthScope;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
api_root?: string;
|
||||
api_version?: string;
|
||||
tenant_id?: string;
|
||||
site_domain?: string;
|
||||
base_url?: string;
|
||||
auth_endpoint?: string;
|
||||
@ -725,6 +679,7 @@ export interface Backend {
|
||||
use_large_media_transforms_in_media_library?: boolean;
|
||||
identity_url?: string;
|
||||
gateway_url?: string;
|
||||
auth_scope?: AuthScope;
|
||||
commit_messages?: {
|
||||
create?: string;
|
||||
update?: string;
|
||||
@ -784,23 +739,27 @@ export interface EventData {
|
||||
author: { login: string | undefined; name: string };
|
||||
}
|
||||
|
||||
export type EventListenerOptions = Record<string, unknown>;
|
||||
|
||||
export type EventListenerHandler = (
|
||||
data: EventData,
|
||||
options: EventListenerOptions,
|
||||
) => Promise<EntryData | undefined | null | void>;
|
||||
|
||||
export interface EventListener {
|
||||
name: AllowedEvent;
|
||||
handler: (
|
||||
data: EventData,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<EntryData | undefined | null | void>;
|
||||
handler: EventListenerHandler;
|
||||
}
|
||||
|
||||
export type EventListenerOptions = Record<string, unknown>;
|
||||
export interface AdditionalLinkOptions {
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export interface AdditionalLink {
|
||||
id: string;
|
||||
title: string;
|
||||
data: string | (() => JSX.Element);
|
||||
options?: {
|
||||
iconName?: string;
|
||||
};
|
||||
data: string | FunctionComponent;
|
||||
options?: AdditionalLinkOptions;
|
||||
}
|
||||
|
||||
export interface AuthenticationPageProps {
|
||||
@ -816,11 +775,9 @@ export interface AuthenticationPageProps {
|
||||
|
||||
export type Integration = {
|
||||
collections?: '*' | string[];
|
||||
} & (AlgoliaIntegration | AssetStoreIntegration);
|
||||
} & AlgoliaIntegration;
|
||||
|
||||
export type IntegrationProvider = Integration['provider'];
|
||||
export type SearchIntegrationProvider = 'algolia';
|
||||
export type MediaIntegrationProvider = 'assetStore';
|
||||
|
||||
export interface AlgoliaIntegration extends AlgoliaConfig {
|
||||
provider: 'algolia';
|
||||
@ -833,16 +790,6 @@ export interface AlgoliaConfig {
|
||||
indexPrefix?: string;
|
||||
}
|
||||
|
||||
export interface AssetStoreIntegration extends AssetStoreConfig {
|
||||
provider: 'assetStore';
|
||||
}
|
||||
|
||||
export interface AssetStoreConfig {
|
||||
hooks: ['assetStore'];
|
||||
shouldConfirmUpload?: boolean;
|
||||
getSignedFormURL: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
entries: Entry[];
|
||||
pagination: number;
|
||||
|
@ -171,7 +171,6 @@ export function registerWidget<T = unknown>(
|
||||
validator = () => false,
|
||||
getValidValue = (value: T | undefined | null) => value,
|
||||
schema,
|
||||
allowMapValue,
|
||||
} = {},
|
||||
} = name;
|
||||
if (registry.widgets[widgetName]) {
|
||||
@ -189,7 +188,6 @@ export function registerWidget<T = unknown>(
|
||||
validator: validator as Widget['validator'],
|
||||
getValidValue: getValidValue as Widget['getValidValue'],
|
||||
schema,
|
||||
allowMapValue,
|
||||
};
|
||||
} else {
|
||||
console.error('`registerWidget` failed, called with incorrect arguments.');
|
||||
@ -360,6 +358,7 @@ export function getAdditionalLinks(): Record<string, AdditionalLink> {
|
||||
}
|
||||
|
||||
export function getAdditionalLink(id: string): AdditionalLink | undefined {
|
||||
console.log('additionalLinks', registry.additionalLinks);
|
||||
return registry.additionalLinks[id];
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,6 @@ const bg: LocalePhrasesRoot = {
|
||||
login: 'Вход',
|
||||
loggingIn: 'Влизане...',
|
||||
loginWithNetlifyIdentity: 'Вход с Netlify Identity',
|
||||
loginWithAzure: 'Вход с Azure',
|
||||
loginWithBitbucket: 'Вход с Bitbucket',
|
||||
loginWithGitHub: 'Вход с GitHub',
|
||||
loginWithGitLab: 'Вход с GitLab',
|
||||
@ -208,7 +207,6 @@ const bg: LocalePhrasesRoot = {
|
||||
noResults: 'Няма резултати.',
|
||||
noAssetsFound: 'Няма намерени ресурси.',
|
||||
noImagesFound: 'Няма намерени изображения.',
|
||||
private: 'Частен ',
|
||||
images: 'Изображения',
|
||||
mediaAssets: 'Медийни ресурси',
|
||||
search: 'Търсене...',
|
||||
|
@ -206,7 +206,6 @@ const ca: LocalePhrasesRoot = {
|
||||
noResults: 'Sense resultats.',
|
||||
noAssetsFound: 'Arxius no trobats.',
|
||||
noImagesFound: 'Imatges no trobades.',
|
||||
private: 'Privat',
|
||||
images: 'Imatges',
|
||||
mediaAssets: 'Arxius multimèdia',
|
||||
search: 'Buscar...',
|
||||
|
@ -5,7 +5,6 @@ const cs: LocalePhrasesRoot = {
|
||||
login: 'Přihlásit',
|
||||
loggingIn: 'Přihlašování…',
|
||||
loginWithNetlifyIdentity: 'Přihlásit pomocí Netlify Identity',
|
||||
loginWithAzure: 'Přihlásit pomocí Azure',
|
||||
loginWithBitbucket: 'Přihlásit pomocí Bitbucket',
|
||||
loginWithGitHub: 'Přihlásit pomocí GitHub',
|
||||
loginWithGitLab: 'Přihlásit pomocí GitLab',
|
||||
@ -206,7 +205,6 @@ const cs: LocalePhrasesRoot = {
|
||||
noResults: 'Nic nenalezeno.',
|
||||
noAssetsFound: 'Média nenalezena.',
|
||||
noImagesFound: 'Obrázky nenalezeny.',
|
||||
private: 'Soukromé ',
|
||||
images: 'Obrázky',
|
||||
mediaAssets: 'Média',
|
||||
search: 'Hledat…',
|
||||
|
@ -5,7 +5,6 @@ const da: LocalePhrasesRoot = {
|
||||
login: 'Log ind',
|
||||
loggingIn: 'Logger ind...',
|
||||
loginWithNetlifyIdentity: 'Log ind med Netlify Identity',
|
||||
loginWithAzure: 'Log ing med Azure',
|
||||
loginWithBitbucket: 'Log ind med Bitbucket',
|
||||
loginWithGitHub: 'Log ind med GitHub',
|
||||
loginWithGitLab: 'Log ind med GitLab',
|
||||
@ -192,7 +191,6 @@ const da: LocalePhrasesRoot = {
|
||||
noResults: 'Ingen resultater.',
|
||||
noAssetsFound: 'Ingen elementer fundet.',
|
||||
noImagesFound: 'Ingen billeder fundet.',
|
||||
private: 'Privat ',
|
||||
images: 'Billeder',
|
||||
mediaAssets: 'Medie elementer',
|
||||
search: 'Søg...',
|
||||
|
@ -5,7 +5,6 @@ const de: LocalePhrasesRoot = {
|
||||
login: 'Login',
|
||||
loggingIn: 'Sie werden eingeloggt...',
|
||||
loginWithNetlifyIdentity: 'Mit Netlify Identity einloggen',
|
||||
loginWithAzure: 'Mit Azure einloggen',
|
||||
loginWithBitbucket: 'Mit Bitbucket einloggen',
|
||||
loginWithGitHub: 'Mit GitHub einloggen',
|
||||
loginWithGitLab: 'Mit GitLab einloggen',
|
||||
@ -220,7 +219,6 @@ const de: LocalePhrasesRoot = {
|
||||
noResults: 'Keine Egebnisse.',
|
||||
noAssetsFound: 'Keine Medien gefunden.',
|
||||
noImagesFound: 'Keine Bilder gefunden.',
|
||||
private: 'Privat ',
|
||||
images: 'Bilder',
|
||||
mediaAssets: 'Medien',
|
||||
search: 'Suchen...',
|
||||
|
@ -5,7 +5,6 @@ const en: LocalePhrasesRoot = {
|
||||
login: 'Login',
|
||||
loggingIn: 'Logging in...',
|
||||
loginWithNetlifyIdentity: 'Login with Netlify Identity',
|
||||
loginWithAzure: 'Login with Azure',
|
||||
loginWithBitbucket: 'Login with Bitbucket',
|
||||
loginWithGitHub: 'Login with GitHub',
|
||||
loginWithGitLab: 'Login with GitLab',
|
||||
@ -240,7 +239,6 @@ const en: LocalePhrasesRoot = {
|
||||
noResults: 'No results.',
|
||||
noAssetsFound: 'No assets found.',
|
||||
noImagesFound: 'No images found.',
|
||||
private: 'Private ',
|
||||
images: 'Images',
|
||||
mediaAssets: 'Media assets',
|
||||
search: 'Search...',
|
||||
|
@ -170,7 +170,6 @@ const es: LocalePhrasesRoot = {
|
||||
noResults: 'Sin resultados.',
|
||||
noAssetsFound: 'Archivos no encontrados.',
|
||||
noImagesFound: 'Imágenes no encontradas.',
|
||||
private: 'Privado ',
|
||||
images: 'Imágenes',
|
||||
mediaAssets: 'Archivos multimedia',
|
||||
search: 'Buscar...',
|
||||
|
@ -5,7 +5,6 @@ const fr: LocalePhrasesRoot = {
|
||||
login: 'Se connecter',
|
||||
loggingIn: 'Connexion en cours...',
|
||||
loginWithNetlifyIdentity: 'Se connecter avec Netlify Identity',
|
||||
loginWithAzure: 'Se connecter avec Azure',
|
||||
loginWithBitbucket: 'Se connecter avec Bitbucket',
|
||||
loginWithGitHub: 'Se connecter avec GitHub',
|
||||
loginWithGitLab: 'Se connecter avec GitLab',
|
||||
@ -216,7 +215,6 @@ const fr: LocalePhrasesRoot = {
|
||||
noResults: 'Aucun résultat.',
|
||||
noAssetsFound: 'Aucune ressource trouvée.',
|
||||
noImagesFound: 'Aucune image trouvée.',
|
||||
private: 'Privé ',
|
||||
images: 'Images',
|
||||
mediaAssets: 'Ressources',
|
||||
search: 'Recherche...',
|
||||
|
@ -149,7 +149,6 @@ const gr: LocalePhrasesRoot = {
|
||||
noResults: 'Χωρίς αποτελέσματα.',
|
||||
noAssetsFound: 'Δεν βρέθηκαν αρχεία.',
|
||||
noImagesFound: 'Δεν βρέθηκαν εικόνες.',
|
||||
private: 'Ιδιωτικό',
|
||||
images: 'Εικόνες',
|
||||
mediaAssets: 'Αρχεία πολυμέσων',
|
||||
search: 'Αναζήτηση...',
|
||||
|
@ -5,7 +5,6 @@ const he: LocalePhrasesRoot = {
|
||||
login: 'התחברות',
|
||||
loggingIn: 'התחברות...',
|
||||
loginWithNetlifyIdentity: 'התחברות עם Netlify Identity',
|
||||
loginWithAzure: 'התחברות עם Azure',
|
||||
loginWithBitbucket: 'התחברות עם Bitbucket',
|
||||
loginWithGitHub: 'התחברות עם GitHub',
|
||||
loginWithGitLab: 'התחברות עם GitLab',
|
||||
@ -216,7 +215,6 @@ const he: LocalePhrasesRoot = {
|
||||
noResults: 'לא נמצאו תוצאות.',
|
||||
noAssetsFound: 'לא נמצאו קבצים.',
|
||||
noImagesFound: 'לא נמצאו תמונות.',
|
||||
private: 'פרטי ',
|
||||
images: 'תמונות',
|
||||
mediaAssets: 'קבצי מדיה',
|
||||
search: 'חיפוש...',
|
||||
|
@ -5,7 +5,6 @@ const hr: LocalePhrasesRoot = {
|
||||
login: 'Prijava',
|
||||
loggingIn: 'Prijava u tijeku...',
|
||||
loginWithNetlifyIdentity: 'Prijava sa Netlify računom',
|
||||
loginWithAzure: 'Prijava za Azure računom',
|
||||
loginWithBitbucket: 'Prijava sa Bitbucket računom',
|
||||
loginWithGitHub: 'Prijava sa GitHub računom',
|
||||
loginWithGitLab: 'Prijava sa GitLab računom',
|
||||
@ -195,7 +194,6 @@ const hr: LocalePhrasesRoot = {
|
||||
noResults: 'Nema rezultata.',
|
||||
noAssetsFound: 'Sredstva nisu pronađena.',
|
||||
noImagesFound: 'Slike nisu pronađene.',
|
||||
private: 'Privatno ',
|
||||
images: 'Slike',
|
||||
mediaAssets: 'Medijska sredstva',
|
||||
search: 'Pretraži...',
|
||||
|
@ -135,7 +135,6 @@ const hu: LocalePhrasesRoot = {
|
||||
noResults: 'Nincs találat.',
|
||||
noAssetsFound: 'Nem található tartalom.',
|
||||
noImagesFound: 'Nem található kép.',
|
||||
private: 'Privát ',
|
||||
images: 'Képek',
|
||||
mediaAssets: 'Média tartalmak',
|
||||
search: 'Keresés...',
|
||||
|
@ -146,7 +146,6 @@ const it: LocalePhrasesRoot = {
|
||||
noResults: 'Nessun risultato.',
|
||||
noAssetsFound: 'Nessun assets trovato.',
|
||||
noImagesFound: 'Nessuna immagine trovata.',
|
||||
private: 'Privato ',
|
||||
images: 'Immagini',
|
||||
mediaAssets: 'Media assets',
|
||||
search: 'Cerca...',
|
||||
|
@ -5,7 +5,6 @@ const ja: LocalePhrasesRoot = {
|
||||
login: 'ログイン',
|
||||
loggingIn: 'ログインしています...',
|
||||
loginWithNetlifyIdentity: 'Netlify Identity でログインする',
|
||||
loginWithAzure: 'Azure でログインする',
|
||||
loginWithBitbucket: 'Bitbucket でログインする',
|
||||
loginWithGitHub: 'GitHub でログインする',
|
||||
loginWithGitLab: 'GitLab でログインする',
|
||||
@ -213,7 +212,6 @@ const ja: LocalePhrasesRoot = {
|
||||
noResults: 'データがありません。',
|
||||
noAssetsFound: 'データがありません。',
|
||||
noImagesFound: 'データがありません。',
|
||||
private: 'プライベート',
|
||||
images: '画像',
|
||||
mediaAssets: 'メディア',
|
||||
search: '検索',
|
||||
|
@ -177,7 +177,6 @@ const ko: LocalePhrasesRoot = {
|
||||
noResults: '일치 항목 없음.',
|
||||
noAssetsFound: '발견된 에셋 없음.',
|
||||
noImagesFound: '발견된 이미지 없음.',
|
||||
private: '개인 ',
|
||||
images: '이미지',
|
||||
mediaAssets: '미디어 에셋',
|
||||
search: '검색...',
|
||||
|
@ -5,7 +5,6 @@ const lt: LocalePhrasesRoot = {
|
||||
login: 'Prisijungti',
|
||||
loggingIn: 'Prisijungiama...',
|
||||
loginWithNetlifyIdentity: 'Prisijungti su Netlify Identity',
|
||||
loginWithAzure: 'Prisijungti su Azure',
|
||||
loginWithBitbucket: 'Prisijungti su Bitbucket',
|
||||
loginWithGitHub: 'Prisijungti su GitHub',
|
||||
loginWithGitLab: 'Prisijungti su GitLab',
|
||||
@ -197,7 +196,6 @@ const lt: LocalePhrasesRoot = {
|
||||
noResults: 'Nėra rezultatų.',
|
||||
noAssetsFound: 'Turinio nerasta.',
|
||||
noImagesFound: 'Vaizdų nerasta.',
|
||||
private: 'Privatu ',
|
||||
images: 'Vaizdai',
|
||||
mediaAssets: 'Medijos turinys',
|
||||
search: 'Paieška...',
|
||||
|
@ -166,7 +166,6 @@ const nb_no: LocalePhrasesRoot = {
|
||||
noResults: 'Ingen resultater.',
|
||||
noAssetsFound: 'Ingen elementer funnet.',
|
||||
noImagesFound: 'Ingen bilder funnet.',
|
||||
private: 'Privat ',
|
||||
images: 'Bilder',
|
||||
mediaAssets: 'Mediebibliotek',
|
||||
search: 'Søk...',
|
||||
|
@ -5,7 +5,6 @@ const nl: LocalePhrasesRoot = {
|
||||
login: 'Inloggen',
|
||||
loggingIn: 'Inloggen...',
|
||||
loginWithNetlifyIdentity: 'Inloggen met Netlify Identity',
|
||||
loginWithAzure: 'Inloggen met Azure',
|
||||
loginWithBitbucket: 'Inloggen met Bitbucket',
|
||||
loginWithGitHub: 'Inloggen met GitHub',
|
||||
loginWithGitLab: 'Inloggen met GitLab',
|
||||
@ -212,7 +211,6 @@ const nl: LocalePhrasesRoot = {
|
||||
noResults: 'Geen resultaten.',
|
||||
noAssetsFound: 'Geen media gevonden.',
|
||||
noImagesFound: 'Geen afbeeldingen gevonden.',
|
||||
private: 'Privé',
|
||||
images: 'Afbeeldingen',
|
||||
mediaAssets: 'Media',
|
||||
search: 'Zoeken...',
|
||||
|
@ -167,7 +167,6 @@ const nn_no: LocalePhrasesRoot = {
|
||||
noResults: 'Ingen resultat.',
|
||||
noAssetsFound: 'Ingen elementer funne.',
|
||||
noImagesFound: 'Ingen bilete funne.',
|
||||
private: 'Privat ',
|
||||
images: 'Bileter',
|
||||
mediaAssets: 'Mediebibliotek',
|
||||
search: 'Søk...',
|
||||
|
@ -5,7 +5,6 @@ const pl: LocalePhrasesRoot = {
|
||||
login: 'Zaloguj się',
|
||||
loggingIn: 'Logowanie...',
|
||||
loginWithNetlifyIdentity: 'Zaloguj przez konto Netlify',
|
||||
loginWithAzure: 'Zaloguj przez konto Azure',
|
||||
loginWithBitbucket: 'Zaloguj przez Bitbucket',
|
||||
loginWithGitHub: 'Zaloguj przez GitHub',
|
||||
loginWithGitLab: 'Zaloguj przez GitLab',
|
||||
@ -217,7 +216,6 @@ const pl: LocalePhrasesRoot = {
|
||||
noResults: 'Brak wyników.',
|
||||
noAssetsFound: 'Nie znaleziono żadnych zasobów.',
|
||||
noImagesFound: 'Nie znaleziono żadnych obrazów.',
|
||||
private: 'Prywatne ',
|
||||
images: 'Obrazy',
|
||||
mediaAssets: 'Zasoby multimedialne',
|
||||
search: 'Szukaj...',
|
||||
|
@ -5,7 +5,6 @@ const pt: LocalePhrasesRoot = {
|
||||
login: 'Entrar',
|
||||
loggingIn: 'Entrando...',
|
||||
loginWithNetlifyIdentity: 'Entrar com o Netlify Identity',
|
||||
loginWithAzure: 'Entrar com o Azure',
|
||||
loginWithBitbucket: 'Entrar com o Bitbucket',
|
||||
loginWithGitHub: 'Entrar com o GitHub',
|
||||
loginWithGitLab: 'Entrar com o GitLab',
|
||||
@ -219,7 +218,6 @@ const pt: LocalePhrasesRoot = {
|
||||
noResults: 'Nenhum resultado.',
|
||||
noAssetsFound: 'Nenhum recurso encontrado.',
|
||||
noImagesFound: 'Nenhuma imagem encontrada.',
|
||||
private: 'Privado ',
|
||||
images: 'Imagens',
|
||||
mediaAssets: 'Recursos de mídia',
|
||||
search: 'Pesquisar...',
|
||||
|
@ -5,7 +5,6 @@ const ro: LocalePhrasesRoot = {
|
||||
login: 'Autentifică-te',
|
||||
loggingIn: 'Te autentificăm...',
|
||||
loginWithNetlifyIdentity: 'Autentifică-te cu Netlify Identity',
|
||||
loginWithAzure: 'Autentifică-te cu Azure',
|
||||
loginWithBitbucket: 'Autentifică-te cu Bitbucket',
|
||||
loginWithGitHub: 'Autentifică-te cu GitHub',
|
||||
loginWithGitLab: 'Autentifică-te cu GitLab',
|
||||
@ -211,7 +210,6 @@ const ro: LocalePhrasesRoot = {
|
||||
noResults: 'Nu sunt rezultate.',
|
||||
noAssetsFound: 'Nu s-au găsit fișiere.',
|
||||
noImagesFound: 'Nu s-au găsit imagini.',
|
||||
private: 'Privat ',
|
||||
images: 'Imagini',
|
||||
mediaAssets: 'Fișiere media',
|
||||
search: 'Caută...',
|
||||
|
@ -5,7 +5,6 @@ const ru: LocalePhrasesRoot = {
|
||||
login: 'Войти',
|
||||
loggingIn: 'Вхожу...',
|
||||
loginWithNetlifyIdentity: 'Войти через Netlify Identity',
|
||||
loginWithAzure: 'Войти через Azure',
|
||||
loginWithBitbucket: 'Войти через Bitbucket',
|
||||
loginWithGitHub: 'Войти через GitHub',
|
||||
loginWithGitLab: 'Войти через GitLab',
|
||||
@ -207,7 +206,6 @@ const ru: LocalePhrasesRoot = {
|
||||
noResults: 'Нет результатов.',
|
||||
noAssetsFound: 'Ресурсы не найдены.',
|
||||
noImagesFound: 'Изображения не найдены.',
|
||||
private: 'Приватные ',
|
||||
images: 'Изображения',
|
||||
mediaAssets: 'Медиаресурсы',
|
||||
search: 'Идёт поиск…',
|
||||
|
@ -5,7 +5,6 @@ const sv: LocalePhrasesRoot = {
|
||||
login: 'Logga in',
|
||||
loggingIn: 'Loggar in...',
|
||||
loginWithNetlifyIdentity: 'Logga in med Netlify Identity',
|
||||
loginWithAzure: 'Logga in med Azure',
|
||||
loginWithBitbucket: 'Logga in med Bitbucket',
|
||||
loginWithGitHub: 'Logga in med GitHub',
|
||||
loginWithGitLab: 'Logga in med GitLab',
|
||||
@ -211,7 +210,6 @@ const sv: LocalePhrasesRoot = {
|
||||
noResults: 'Inga resultat.',
|
||||
noAssetsFound: 'Hittade inga mediaobjekt.',
|
||||
noImagesFound: 'Hittade inga bilder.',
|
||||
private: 'Privat ',
|
||||
images: 'Bilder',
|
||||
mediaAssets: 'Mediaobjekt',
|
||||
search: 'Sök...',
|
||||
|
@ -178,7 +178,6 @@ const th: LocalePhrasesRoot = {
|
||||
noResults: 'ไม่มีผลลัพธ์',
|
||||
noAssetsFound: 'ไม่พบข้อมูล',
|
||||
noImagesFound: 'ไม่พบรูปภาพ',
|
||||
private: 'ส่วนตัว ',
|
||||
images: 'รูปภาพ',
|
||||
mediaAssets: 'ข้อมูลมีเดีย',
|
||||
search: 'ค้นหา...',
|
||||
|
@ -5,7 +5,6 @@ const tr: LocalePhrasesRoot = {
|
||||
login: 'Giriş',
|
||||
loggingIn: 'Giriş yapılıyor..',
|
||||
loginWithNetlifyIdentity: 'Netlify Identity ile Giriş',
|
||||
loginWithAzure: 'Azure ile Giriş',
|
||||
loginWithBitbucket: 'Bitbucket ile Giriş',
|
||||
loginWithGitHub: 'GitHub ile Giriş',
|
||||
loginWithGitLab: 'GitLab ile Giriş',
|
||||
@ -223,7 +222,6 @@ const tr: LocalePhrasesRoot = {
|
||||
noResults: 'Sonuç yok.',
|
||||
noAssetsFound: 'Hiçbir dosya bulunamadı.',
|
||||
noImagesFound: 'Resim bulunamadı.',
|
||||
private: 'Özel ',
|
||||
images: 'Görseller',
|
||||
mediaAssets: 'Medya dosyaları',
|
||||
search: 'Ara...',
|
||||
|
@ -125,7 +125,6 @@ const uk: LocalePhrasesRoot = {
|
||||
noResults: 'Результати відсутні.',
|
||||
noAssetsFound: 'Матеріали відсутні.',
|
||||
noImagesFound: 'Зображення відсутні.',
|
||||
private: 'Private ',
|
||||
images: 'Зображення',
|
||||
mediaAssets: 'Медіа матеріали',
|
||||
search: 'Пошук...',
|
||||
|
@ -175,7 +175,6 @@ const vi: LocalePhrasesRoot = {
|
||||
noResults: 'Không có kết quả.',
|
||||
noAssetsFound: 'Không tìm thấy tập tin nào.',
|
||||
noImagesFound: 'Không tìm thấy hình nào.',
|
||||
private: 'Riêng tư ',
|
||||
images: 'Hình ảnh',
|
||||
mediaAssets: 'Tập tin',
|
||||
search: 'Tìm kiếm...',
|
||||
|
@ -5,7 +5,6 @@ const zh_Hans: LocalePhrasesRoot = {
|
||||
login: '登录',
|
||||
loggingIn: '正在登录...',
|
||||
loginWithNetlifyIdentity: '使用 Netlify Identity 登录',
|
||||
loginWithAzure: '使用 Azure 登录',
|
||||
loginWithBitbucket: '使用 Bitbucket 登录',
|
||||
loginWithGitHub: '使用 GitHub 登录',
|
||||
loginWithGitLab: '使用 GitLab 登录',
|
||||
@ -207,7 +206,6 @@ const zh_Hans: LocalePhrasesRoot = {
|
||||
noResults: '暂无结果',
|
||||
noAssetsFound: '未找到资源',
|
||||
noImagesFound: '未找到图片',
|
||||
private: '私有',
|
||||
images: '图片',
|
||||
mediaAssets: '媒体资源',
|
||||
search: '搜索...',
|
||||
|
@ -185,7 +185,6 @@ const zh_Hant: LocalePhrasesRoot = {
|
||||
noResults: '沒有結果',
|
||||
noAssetsFound: '沒有發現媒體資產。',
|
||||
noImagesFound: '沒有發現影像。',
|
||||
private: '私人',
|
||||
images: '影像',
|
||||
mediaAssets: '媒體資產',
|
||||
search: '搜尋中...',
|
||||
|
@ -5,7 +5,6 @@ import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
ADD_DRAFT_ENTRY_MEDIA_FILE,
|
||||
DRAFT_CHANGE_FIELD,
|
||||
DRAFT_CLEAR_ERRORS,
|
||||
DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
|
||||
DRAFT_CREATE_EMPTY,
|
||||
DRAFT_CREATE_FROM_ENTRY,
|
||||
@ -176,13 +175,6 @@ function entryDraftReducer(
|
||||
};
|
||||
}
|
||||
|
||||
case DRAFT_CLEAR_ERRORS: {
|
||||
return {
|
||||
...state,
|
||||
fieldsErrors: {},
|
||||
};
|
||||
}
|
||||
|
||||
case ENTRY_PERSIST_REQUEST: {
|
||||
if (!state.entry) {
|
||||
return state;
|
||||
|
@ -18,18 +18,18 @@ import type { IntegrationHooks } from './integrations';
|
||||
|
||||
const reducers = {
|
||||
auth,
|
||||
config,
|
||||
collections,
|
||||
search,
|
||||
integrations,
|
||||
entries,
|
||||
config,
|
||||
cursors,
|
||||
entries,
|
||||
entryDraft,
|
||||
medias,
|
||||
mediaLibrary,
|
||||
globalUI,
|
||||
status,
|
||||
integrations,
|
||||
mediaLibrary,
|
||||
medias,
|
||||
scroll,
|
||||
search,
|
||||
status,
|
||||
};
|
||||
|
||||
export default reducers;
|
||||
|
@ -5,22 +5,18 @@ import { CONFIG_SUCCESS } from '../actions/config';
|
||||
import type { ConfigAction } from '../actions/config';
|
||||
import type {
|
||||
AlgoliaConfig,
|
||||
AssetStoreConfig,
|
||||
Config,
|
||||
MediaIntegrationProvider,
|
||||
SearchIntegrationProvider,
|
||||
} from '../interface';
|
||||
|
||||
export interface IntegrationHooks {
|
||||
search?: SearchIntegrationProvider;
|
||||
listEntries?: SearchIntegrationProvider;
|
||||
assetStore?: MediaIntegrationProvider;
|
||||
}
|
||||
|
||||
export interface IntegrationsState {
|
||||
providers: {
|
||||
algolia?: AlgoliaConfig;
|
||||
assetStore?: AssetStoreConfig;
|
||||
};
|
||||
hooks: IntegrationHooks;
|
||||
collectionHooks: Record<string, IntegrationHooks>;
|
||||
@ -47,8 +43,6 @@ export function getIntegrations(config: Config): IntegrationsState {
|
||||
hook => (acc.collectionHooks[collection][hook] = providerData.provider),
|
||||
);
|
||||
});
|
||||
} else if (providerData.provider === 'assetStore') {
|
||||
acc.providers[providerData.provider] = providerData;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
|
@ -21,12 +21,11 @@ import {
|
||||
MEDIA_PERSIST_SUCCESS,
|
||||
MEDIA_REMOVE_INSERTED,
|
||||
} from '../actions/mediaLibrary';
|
||||
import { selectIntegration } from './';
|
||||
import { selectEditingDraft } from './entries';
|
||||
import { selectMediaFolder } from '../lib/util/media.util';
|
||||
import { selectEditingDraft } from './entries';
|
||||
|
||||
import type { MediaLibraryAction } from '../actions/mediaLibrary';
|
||||
import type { Field, DisplayURLState, MediaFile, MediaLibraryInstance } from '../interface';
|
||||
import type { DisplayURLState, Field, MediaFile, MediaLibraryInstance } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export interface MediaLibraryDisplayURL {
|
||||
@ -49,7 +48,6 @@ export type MediaLibraryState = {
|
||||
value?: string | string[];
|
||||
replaceIndex?: number;
|
||||
canInsert?: boolean;
|
||||
privateUpload?: boolean;
|
||||
isLoading?: boolean;
|
||||
dynamicSearch?: boolean;
|
||||
dynamicSearchActive?: boolean;
|
||||
@ -82,26 +80,8 @@ function mediaLibrary(
|
||||
};
|
||||
|
||||
case MEDIA_LIBRARY_OPEN: {
|
||||
const { controlID, forImage, privateUpload, config, field, value, replaceIndex } =
|
||||
action.payload;
|
||||
const { controlID, forImage, config, field, value, replaceIndex } = action.payload;
|
||||
const libConfig = config || {};
|
||||
const privateUploadChanged = state.privateUpload !== privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
return {
|
||||
...state,
|
||||
isVisible: true,
|
||||
forImage,
|
||||
controlID,
|
||||
canInsert: Boolean(controlID),
|
||||
privateUpload,
|
||||
config: libConfig,
|
||||
controlMedia: {},
|
||||
displayURLs: {},
|
||||
field,
|
||||
value,
|
||||
replaceIndex,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
@ -109,7 +89,6 @@ function mediaLibrary(
|
||||
forImage: Boolean(forImage),
|
||||
controlID,
|
||||
canInsert: !!controlID,
|
||||
privateUpload: Boolean(privateUpload),
|
||||
config: libConfig,
|
||||
field,
|
||||
value,
|
||||
@ -180,19 +159,7 @@ function mediaLibrary(
|
||||
};
|
||||
|
||||
case MEDIA_LOAD_SUCCESS: {
|
||||
const {
|
||||
files = [],
|
||||
page,
|
||||
canPaginate,
|
||||
dynamicSearch,
|
||||
dynamicSearchQuery,
|
||||
privateUpload,
|
||||
} = action.payload;
|
||||
const privateUploadChanged = state.privateUpload !== privateUpload;
|
||||
|
||||
if (privateUploadChanged) {
|
||||
return state;
|
||||
}
|
||||
const { files = [], page, canPaginate, dynamicSearch, dynamicSearchQuery } = action.payload;
|
||||
|
||||
const filesWithKeys = files.map(file => ({ ...file, key: uuid() }));
|
||||
return {
|
||||
@ -210,11 +177,6 @@ function mediaLibrary(
|
||||
}
|
||||
|
||||
case MEDIA_LOAD_FAILURE: {
|
||||
const privateUploadChanged = state.privateUpload !== action.payload.privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
@ -228,12 +190,7 @@ function mediaLibrary(
|
||||
};
|
||||
|
||||
case MEDIA_PERSIST_SUCCESS: {
|
||||
const { file, privateUpload } = action.payload;
|
||||
const privateUploadChanged = state.privateUpload !== privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { file } = action.payload;
|
||||
const fileWithKey = { ...file, key: uuid() };
|
||||
const files = state.files as MediaFile[];
|
||||
const updatedFiles = [fileWithKey, ...files];
|
||||
@ -245,11 +202,6 @@ function mediaLibrary(
|
||||
}
|
||||
|
||||
case MEDIA_PERSIST_FAILURE: {
|
||||
const privateUploadChanged = state.privateUpload !== action.payload.privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
isPersisting: false,
|
||||
@ -263,12 +215,8 @@ function mediaLibrary(
|
||||
};
|
||||
|
||||
case MEDIA_DELETE_SUCCESS: {
|
||||
const { file, privateUpload } = action.payload;
|
||||
const { file } = action.payload;
|
||||
const { key, id } = file;
|
||||
const privateUploadChanged = state.privateUpload !== privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const files = state.files as MediaFile[];
|
||||
const updatedFiles = files.filter(file => (key ? file.key !== key : file.id !== id));
|
||||
@ -288,11 +236,6 @@ function mediaLibrary(
|
||||
}
|
||||
|
||||
case MEDIA_DELETE_FAILURE: {
|
||||
const privateUploadChanged = state.privateUpload !== action.payload.privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
isDeleting: false,
|
||||
@ -347,10 +290,9 @@ function mediaLibrary(
|
||||
export function selectMediaFiles(state: RootState, field?: Field): MediaFile[] {
|
||||
const { mediaLibrary, entryDraft } = state;
|
||||
const editingDraft = selectEditingDraft(entryDraft);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
|
||||
let files: MediaFile[] = [];
|
||||
if (editingDraft && !integration) {
|
||||
if (editingDraft) {
|
||||
const entryFiles = entryDraft?.entry?.mediaFiles ?? [];
|
||||
const entry = entryDraft['entry'];
|
||||
const collection = entry?.collection ? state.collections[entry.collection] : null;
|
||||
|
9
core/src/types/global.d.ts
vendored
9
core/src/types/global.d.ts
vendored
@ -3,14 +3,21 @@ export {};
|
||||
import type { Config } from '../interface';
|
||||
import type CmsAPI from '../index';
|
||||
import type createReactClass from 'create-react-class';
|
||||
import type { createElement } from 'react';
|
||||
import type { createElement, useEffect, useState, useMemo, useCallback } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CMS?: CmsAPI;
|
||||
CMS_CONFIG?: Config;
|
||||
CMS_ENV?: string;
|
||||
/**
|
||||
* @deprecated Should use react functional components instead
|
||||
*/
|
||||
createClass: createReactClass;
|
||||
h: createElement;
|
||||
useState: useState;
|
||||
useMemo: useMemo;
|
||||
useEffect: useEffect;
|
||||
useCallback: useCallback;
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ const CodeWidget = (): WidgetParam<string | { [key: string]: string }, CodeField
|
||||
previewComponent,
|
||||
options: {
|
||||
schema,
|
||||
allowMapValue: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -11,7 +11,9 @@ import formatDate from 'date-fns/format';
|
||||
import formatISO from 'date-fns/formatISO';
|
||||
import parse from 'date-fns/parse';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { isNotEmpty } from '../../lib/util/string.util';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { DateTimeField, TranslatedProps, WidgetControlProps } from '../../interface';
|
||||
@ -58,8 +60,6 @@ const DateTimeControl = ({
|
||||
onChange,
|
||||
hasErrors,
|
||||
}: WidgetControlProps<string, DateTimeField>) => {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
|
||||
const { format, dateFormat, timeFormat } = useMemo(() => {
|
||||
const format = field.format;
|
||||
|
||||
@ -75,18 +75,7 @@ const DateTimeControl = ({
|
||||
};
|
||||
}, [field.date_format, field.format, field.time_format]);
|
||||
|
||||
const dateValue = useMemo(
|
||||
() => (format ? parse(internalValue, format, new Date()) : parseISO(internalValue)),
|
||||
[format, internalValue],
|
||||
);
|
||||
|
||||
const timezoneOffset = useMemo(() => dateValue.getTimezoneOffset() * 60000, [dateValue]);
|
||||
|
||||
const utcDate = useMemo(() => {
|
||||
const dateTime = new Date(dateValue);
|
||||
const utcFromLocal = new Date(dateTime.getTime() + timezoneOffset);
|
||||
return utcFromLocal;
|
||||
}, [dateValue, timezoneOffset]);
|
||||
const timezoneOffset = useMemo(() => new Date().getTimezoneOffset() * 60000, []);
|
||||
|
||||
const localToUTC = useCallback(
|
||||
(dateTime: Date) => {
|
||||
@ -105,6 +94,20 @@ const DateTimeControl = ({
|
||||
: field.default;
|
||||
}, [field.default, field.picker_utc, format, localToUTC]);
|
||||
|
||||
const [internalValue, setInternalValue] = useState(value ?? defaultValue);
|
||||
|
||||
const dateValue = useMemo(
|
||||
() =>
|
||||
format ? parse(internalValue, format, new Date()) ?? defaultValue : parseISO(internalValue),
|
||||
[defaultValue, format, internalValue],
|
||||
);
|
||||
|
||||
const utcDate = useMemo(() => {
|
||||
const dateTime = new Date(dateValue);
|
||||
const utcFromLocal = new Date(dateTime.getTime() + timezoneOffset) ?? defaultValue;
|
||||
return utcFromLocal;
|
||||
}, [dateValue, defaultValue, timezoneOffset]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(datetime: Date | null) => {
|
||||
if (datetime === null) {
|
||||
@ -127,27 +130,16 @@ const DateTimeControl = ({
|
||||
[defaultValue, field.picker_utc, format, localToUTC, onChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Set the current date as default value if no value is provided and default is absent. An
|
||||
* empty default string means the value is intentionally blank.
|
||||
*/
|
||||
if (internalValue === undefined) {
|
||||
setTimeout(() => {
|
||||
setInternalValue(defaultValue);
|
||||
onChange(defaultValue);
|
||||
}, 0);
|
||||
}
|
||||
}, [defaultValue, handleChange, internalValue, onChange]);
|
||||
|
||||
const dateTimePicker = useMemo(() => {
|
||||
if (dateFormat && !timeFormat) {
|
||||
const inputDateFormat = typeof dateFormat === 'string' ? dateFormat : 'MMM d, yyyy';
|
||||
|
||||
return (
|
||||
<MobileDatePicker
|
||||
key="mobile-date-picker"
|
||||
inputFormat={typeof dateFormat === 'string' ? dateFormat : 'MMM d, yyyy'}
|
||||
inputFormat={inputDateFormat}
|
||||
label={label}
|
||||
value={field.picker_utc ? utcDate : dateValue}
|
||||
value={formatDate(field.picker_utc ? utcDate : dateValue, inputDateFormat)}
|
||||
onChange={handleChange}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
@ -172,12 +164,14 @@ const DateTimeControl = ({
|
||||
}
|
||||
|
||||
if (!dateFormat && timeFormat) {
|
||||
const inputTimeFormat = typeof timeFormat === 'string' ? timeFormat : 'H:mm';
|
||||
|
||||
return (
|
||||
<TimePicker
|
||||
key="time-picker"
|
||||
label={label}
|
||||
inputFormat={typeof timeFormat === 'string' ? timeFormat : 'H:mm'}
|
||||
value={field.picker_utc ? utcDate : dateValue}
|
||||
inputFormat={inputTimeFormat}
|
||||
value={formatDate(field.picker_utc ? utcDate : dateValue, inputTimeFormat)}
|
||||
onChange={handleChange}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
@ -202,17 +196,19 @@ const DateTimeControl = ({
|
||||
}
|
||||
|
||||
let inputFormat = 'MMM d, yyyy H:mm';
|
||||
if (dateFormat || timeFormat) {
|
||||
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
|
||||
const formatParts: string[] = [];
|
||||
if (typeof dateFormat === 'string') {
|
||||
if (typeof dateFormat === 'string' && isNotEmpty(dateFormat)) {
|
||||
formatParts.push(dateFormat);
|
||||
}
|
||||
|
||||
if (typeof timeFormat === 'string') {
|
||||
if (typeof timeFormat === 'string' && isNotEmpty(timeFormat)) {
|
||||
formatParts.push(timeFormat);
|
||||
}
|
||||
|
||||
inputFormat = formatParts.join(' ');
|
||||
if (formatParts.length > 0) {
|
||||
inputFormat = formatParts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -220,7 +216,7 @@ const DateTimeControl = ({
|
||||
key="mobile-date-time-picker"
|
||||
inputFormat={inputFormat}
|
||||
label={label}
|
||||
value={field.picker_utc ? utcDate : dateValue}
|
||||
value={formatDate(field.picker_utc ? utcDate : dateValue, inputFormat)}
|
||||
onChange={handleChange}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
|
@ -306,7 +306,6 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
|
||||
return openMediaLibrary({
|
||||
controlID,
|
||||
forImage,
|
||||
privateUpload: field.private,
|
||||
value: internalValue,
|
||||
allowMultiple:
|
||||
'allow_multiple' in mediaLibraryFieldOptions
|
||||
@ -355,7 +354,6 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
|
||||
return openMediaLibrary({
|
||||
controlID,
|
||||
forImage,
|
||||
privateUpload: field.private,
|
||||
value: internalValue,
|
||||
replaceIndex: index,
|
||||
allowMultiple: false,
|
||||
|
@ -18,7 +18,7 @@ import type {
|
||||
ListField,
|
||||
ObjectValue,
|
||||
ValueOrNestedValue,
|
||||
WidgetControlProps,
|
||||
WidgetControlProps
|
||||
} from '../../interface';
|
||||
|
||||
const StyledListWrapper = styled('div')`
|
||||
@ -96,7 +96,6 @@ function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): Obje
|
||||
}
|
||||
|
||||
const ListControl = ({
|
||||
clearFieldErrors,
|
||||
entry,
|
||||
field,
|
||||
fieldsErrors,
|
||||
@ -223,7 +222,6 @@ const ListControl = ({
|
||||
key={key}
|
||||
valueType={valueType}
|
||||
handleRemove={handleRemove}
|
||||
clearFieldErrors={clearFieldErrors}
|
||||
data-testid={`object-control-${index}`}
|
||||
entry={entry}
|
||||
field={field}
|
||||
@ -242,7 +240,6 @@ const ListControl = ({
|
||||
keys,
|
||||
valueType,
|
||||
handleRemove,
|
||||
clearFieldErrors,
|
||||
entry,
|
||||
field,
|
||||
fieldsErrors,
|
||||
|
@ -79,7 +79,6 @@ function validateItem(field: ListField, item: ObjectValue) {
|
||||
interface ListItemProps
|
||||
extends Pick<
|
||||
WidgetControlProps<ObjectValue, ListField>,
|
||||
| 'clearFieldErrors'
|
||||
| 'entry'
|
||||
| 'field'
|
||||
| 'fieldsErrors'
|
||||
@ -98,7 +97,6 @@ interface ListItemProps
|
||||
|
||||
const ListItem = ({
|
||||
index,
|
||||
clearFieldErrors,
|
||||
entry,
|
||||
field,
|
||||
fieldsErrors,
|
||||
@ -201,7 +199,6 @@ const ListItem = ({
|
||||
key={index}
|
||||
field={objectField}
|
||||
value={value}
|
||||
clearFieldErrors={clearFieldErrors}
|
||||
fieldsErrors={fieldsErrors}
|
||||
submitted={submitted}
|
||||
parentPath={path}
|
||||
|
@ -86,7 +86,6 @@ const MarkdownControl = ({
|
||||
openMediaLibrary({
|
||||
controlID,
|
||||
forImage,
|
||||
privateUpload: false,
|
||||
allowMultiple: false,
|
||||
field,
|
||||
config: 'config' in mediaLibraryFieldOptions ? mediaLibraryFieldOptions.config : undefined,
|
||||
|
@ -47,7 +47,6 @@ const StyledNoFieldsMessage = styled('div')`
|
||||
`;
|
||||
|
||||
const ObjectControl = ({
|
||||
clearFieldErrors,
|
||||
field,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
@ -89,7 +88,6 @@ const ObjectControl = ({
|
||||
key={index}
|
||||
field={field}
|
||||
value={fieldValue}
|
||||
clearFieldErrors={clearFieldErrors}
|
||||
fieldsErrors={fieldsErrors}
|
||||
submitted={submitted}
|
||||
parentPath={path}
|
||||
@ -104,7 +102,6 @@ const ObjectControl = ({
|
||||
}) ?? null
|
||||
);
|
||||
}, [
|
||||
clearFieldErrors,
|
||||
fieldsErrors,
|
||||
i18n,
|
||||
isFieldDuplicate,
|
||||
|
@ -34,7 +34,7 @@ const SelectControl = ({
|
||||
}: WidgetControlProps<string | number | (string | number)[], SelectField>) => {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
|
||||
const fieldOptions: (string | Option)[] = useMemo(() => field.options, [field.options]);
|
||||
const fieldOptions: (string | number | Option)[] = useMemo(() => field.options, [field.options]);
|
||||
const isMultiple = useMemo(() => field.multiple ?? false, [field.multiple]);
|
||||
|
||||
const options = useMemo(
|
||||
|
@ -40,7 +40,7 @@ For GitHub and GitLab repositories, you can start your Static CMS `config.yml` f
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
title: git-gateway
|
||||
name: git-gateway
|
||||
branch: main # Branch to update (optional; defaults to main)
|
||||
```
|
||||
|
||||
@ -92,18 +92,18 @@ Given this example, our `collections` settings would look like this in your Stat
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: 'blog' # Used in routes, e.g., /admin/collections/blog
|
||||
- name: 'blog' # Used in routes, e.g., /admin/collections/blog
|
||||
label: 'Blog' # Used in the UI
|
||||
folder: '_posts/blog' # The path to the folder where the documents are stored
|
||||
create: true # Allow users to create new documents in this collection
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}' # Filename template, e.g., YYYY-MM-DD-title.md
|
||||
fields: # The fields for each document, usually in front matter
|
||||
- { label: 'Layout', title: 'layout', widget: 'hidden', default: 'blog' }
|
||||
- { label: 'Title', title: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', title: 'date', widget: 'datetime' }
|
||||
- { label: 'Featured Image', title: 'thumbnail', widget: 'image' }
|
||||
- { label: 'Rating (scale of 1-5)', title: 'rating', widget: 'number' }
|
||||
- { label: 'Body', title: 'body', widget: 'markdown' }
|
||||
- { label: 'Layout', name: 'layout', widget: 'hidden', default: 'blog' }
|
||||
- { label: 'Title', name: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', name: 'date', widget: 'datetime' }
|
||||
- { label: 'Featured Image', name: 'thumbnail', widget: 'image' }
|
||||
- { label: 'Rating (scale of 1-5)', name: 'rating', widget: 'number' }
|
||||
- { label: 'Body', name: 'body', widget: 'markdown' }
|
||||
```
|
||||
|
||||
Let's break that down:
|
||||
@ -119,7 +119,7 @@ Let's break that down:
|
||||
|
||||
As described above, the `widget` property specifies a built-in or custom UI widget for a given field. When a content editor enters a value into a widget, that value is saved in the document front matter as the value for the `name` specified for that field. A full listing of available widgets can be found in the [Widgets doc](/docs/widgets).
|
||||
|
||||
Based on this example, you can go through the post types in your site and add the appropriate settings to your Static CMS `config.yml` file. Each post type should be listed as a separate node under the `collections` field. See the [Collections reference doc](/docs/configuration-options/#collections) for more configuration options.
|
||||
Based on this example, you can go through the post types in your site and add the appropriate settings to your Static CMS `config.yml` file. Each post type should be listed as a separate node under the `collections` field. See the [Collections reference doc](/docs/collection-overview) for more configuration options.
|
||||
|
||||
### Filter
|
||||
|
||||
@ -127,14 +127,14 @@ The entries for any collection can be filtered based on the value of a single fi
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: 'posts'
|
||||
- name: 'posts'
|
||||
label: 'Post'
|
||||
folder: '_posts'
|
||||
filter:
|
||||
field: language
|
||||
value: en
|
||||
fields:
|
||||
- { label: 'Language', title: 'language' }
|
||||
- { label: 'Language', name: 'language' }
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
@ -4,7 +4,7 @@ title: CDN Hosting
|
||||
weight: 4
|
||||
---
|
||||
|
||||
This tutorial guides you through the steps for adding Static CMS via a public CDN to a site that's built with a common [static site generator](https://www.staticgen.com/), like Jekyll, Nest, Hugo, Hexo, or Gatsby. Alternatively, you can [start from a template](/docs/start-with-a-template) or dive right into [configuration options](/docs/configuration-options).
|
||||
This tutorial guides you through the steps for adding Static CMS via a public CDN to a site that's built with a common [static site generator](https://www.staticgen.com/), like Jekyll, Next, Hugo, Hexo, or Gatsby. Alternatively, you can [start from a template](/docs/start-with-a-template) or dive right into [configuration options](/docs/configuration-options).
|
||||
|
||||
## App File Structure
|
||||
|
||||
@ -35,7 +35,7 @@ admin
|
||||
└ config.yml
|
||||
```
|
||||
|
||||
The first file, `admin/index.html`, is the entry point for the Static CMS admin interface. This means that users navigate to `yoursite.com/admin/` to access it. On the code side, it's a basic HTML starter page that loads the Static CMS JavaScript file from a public CDN. The second file, `admin/config.yml`, is the heart of your Static CMS installation, and a bit more complex. The [Configuration](#configuration) section covers the details.
|
||||
The first file, `admin/index.html`, is the entry point for the Static CMS admin interface. This means that users navigate to `yoursite.com/admin/` to access it. On the code side, it's a basic HTML starter page that loads the Static CMS JavaScript file from a public CDN and initializes it. The second file, `admin/config.yml`, is the heart of your Static CMS installation, and a bit more complex. The [Configuration](#configuration) section covers the details.
|
||||
|
||||
In this example, we pull the `admin/index.html` file from a public CDN.
|
||||
|
||||
@ -67,15 +67,15 @@ Configuration is different for every site, so we'll break it down into parts. Ad
|
||||
|
||||
We're using [Netlify](https://www.netlify.com) for our hosting and authentication in this tutorial, so backend configuration is fairly straightforward.
|
||||
|
||||
For GitHub and GitLab repositories, you can start your Static CMS `config.yml` file with these lines:
|
||||
For GitHub repositories, you can start your Static CMS `config.yml` file with these lines:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
title: git-gateway
|
||||
name: git-gateway
|
||||
branch: main # Branch to update (optional; defaults to main)
|
||||
```
|
||||
|
||||
_(For Bitbucket repositories, use the [Bitbucket backend](/docs/bitbucket-backend) instructions instead.)_
|
||||
_(For GitLab repositories, use [GitLab backend](/docs/gitlab-backend) and for Bitbucket repositories, use [Bitbucket backend](/docs/bitbucket-backend).)_
|
||||
|
||||
The configuration above specifies your backend protocol and your publication branch. Git Gateway is an open source API that acts as a proxy between authenticated users of your site and your site repo. (We'll get to the details of that in the [Authentication section](#authentication) below.) If you leave out the `branch` declaration, it defaults to `main`.
|
||||
|
||||
@ -123,18 +123,18 @@ Given this example, our `collections` settings would look like this in your Stat
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: 'blog' # Used in routes, e.g., /admin/collections/blog
|
||||
- name: 'blog' # Used in routes, e.g., /admin/collections/blog
|
||||
label: 'Blog' # Used in the UI
|
||||
folder: '_posts/blog' # The path to the folder where the documents are stored
|
||||
create: true # Allow users to create new documents in this collection
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}' # Filename template, e.g., YYYY-MM-DD-title.md
|
||||
fields: # The fields for each document, usually in front matter
|
||||
- { label: 'Layout', title: 'layout', widget: 'hidden', default: 'blog' }
|
||||
- { label: 'Title', title: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', title: 'date', widget: 'datetime' }
|
||||
- { label: 'Featured Image', title: 'thumbnail', widget: 'image' }
|
||||
- { label: 'Rating (scale of 1-5)', title: 'rating', widget: 'number' }
|
||||
- { label: 'Body', title: 'body', widget: 'markdown' }
|
||||
- { label: 'Layout', name: 'layout', widget: 'hidden', default: 'blog' }
|
||||
- { label: 'Title', name: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', name: 'date', widget: 'datetime' }
|
||||
- { label: 'Featured Image', name: 'thumbnail', widget: 'image' }
|
||||
- { label: 'Rating (scale of 1-5)', name: 'rating', widget: 'number' }
|
||||
- { label: 'Body', name: 'body', widget: 'markdown' }
|
||||
```
|
||||
|
||||
Let's break that down:
|
||||
@ -150,7 +150,7 @@ Let's break that down:
|
||||
|
||||
As described above, the `widget` property specifies a built-in or custom UI widget for a given field. When a content editor enters a value into a widget, that value is saved in the document front matter as the value for the `name` specified for that field. A full listing of available widgets can be found in the [Widgets doc](/docs/widgets).
|
||||
|
||||
Based on this example, you can go through the post types in your site and add the appropriate settings to your Static CMS `config.yml` file. Each post type should be listed as a separate node under the `collections` field. See the [Collections reference doc](/docs/configuration-options/#collections) for more configuration options.
|
||||
Based on this example, you can go through the post types in your site and add the appropriate settings to your Static CMS `config.yml` file. Each post type should be listed as a separate node under the `collections` field. See the [Collections reference doc](/docs/collection-overview) for more configuration options.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
@ -4,6 +4,9 @@ title: Add to Your Site
|
||||
weight: 3
|
||||
---
|
||||
|
||||
You can adapt Static CMS to a wide variety of projects. It works with any content written in markdown, JSON, YAML, or TOML files, stored in a repo on [GitHub](https://github.com/), [GitLab](https://gitlab.com/), [Bitbucket](https://bitbucket.org) or [Azure](https://azure.microsoft.com/en-us/products/devops/repos/). You can also create your own custom backend.
|
||||
You can adapt Static CMS to a wide variety of projects. It works with any content written in markdown, JSON, YAML, or TOML files, stored in a repo on [GitHub](https://github.com/), [GitLab](https://gitlab.com/) or [Bitbucket](https://bitbucket.org). You can also create your own custom backend.
|
||||
|
||||
You can add Static CMS to your site in two different ways: [CDN hosting](/docs/add-to-your-site-cdn) or [bundling directly into your app](/docs/add-to-your-site-bundling).
|
||||
You can add Static CMS to your site in two different ways:
|
||||
|
||||
- [CDN hosting](/docs/add-to-your-site-cdn)
|
||||
- [bundling directly into your app](/docs/add-to-your-site-bundling)
|
||||
|
66
website/content/docs/additional-links.mdx
Normal file
66
website/content/docs/additional-links.mdx
Normal file
@ -0,0 +1,66 @@
|
||||
---
|
||||
group: Customization
|
||||
title: Custom Links & Pages
|
||||
weight: 60
|
||||
---
|
||||
|
||||
The Static CMS exposes a `window.CMS` global object that you can use to register external links or links custom pages, via `registerAdditionalLink`. The links are displayed at the bottom of the navigation menu in the order they are registered.
|
||||
|
||||
### React Components Inline
|
||||
|
||||
The `registerPreviewTemplate` requires you to provide a React component. If you have a build process in place for your project, it is possible to integrate with this build process.
|
||||
|
||||
However, although possible, it may be cumbersome or even impractical to add a React build phase. For this reason, Static CMS exposes some constructs globally to allow you to create components inline: `h` (alias for React.createElement) as well some basic hooks (`useState`, `useMemo`, `useEffect`, `useCallback`).
|
||||
|
||||
**NOTE**: `createClass` is still provided, allowing for the creation of react class components. However it has now been deprecated and will be removed in `v2.0.0`.
|
||||
|
||||
## Params
|
||||
|
||||
`registerAdditionalLink` takes an `AdditionalLink` object with the following properties:
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | string | Unique identifier for the link |
|
||||
| title | string | Display text for the link |
|
||||
| data | string<br />\| [React Function Component](https://reactjs.org/docs/components-and-props.html) | <ul><li>`string` - The href for the link</li><li>`React Function Component` - A react component to render on the route `/page/[id]`</li></ul> |
|
||||
| options | object | _Optional_. See [Options](#options) |
|
||||
|
||||
### Options
|
||||
|
||||
Available options for each additional link are:
|
||||
|
||||
| Param | Type | Description |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------- |
|
||||
| iconName | string | The name of a custom registered icon to display. See [Custom Icons](/docs/custom-icons) |
|
||||
|
||||
## Examples
|
||||
|
||||
### External Links
|
||||
|
||||
```js
|
||||
CMS.registerAdditionalLink({
|
||||
id: 'example',
|
||||
title: 'Example.com',
|
||||
data: 'https://example.com',
|
||||
options: {
|
||||
icon: 'page',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Page
|
||||
|
||||
```js
|
||||
const CustomPage = () => {
|
||||
return h('div', {}, 'I am a custom page!');
|
||||
};
|
||||
|
||||
CMS.registerAdditionalLink({
|
||||
id: 'custom-page',
|
||||
title: 'Custom Page',
|
||||
data: CustomPage,
|
||||
options: {
|
||||
icon: 'page',
|
||||
},
|
||||
});
|
||||
```
|
@ -1,64 +0,0 @@
|
||||
---
|
||||
group: Contributing
|
||||
title: Architecture
|
||||
weight: 200
|
||||
---
|
||||
|
||||
Static CMS is a React application, using Redux for state management with immutable data structures (immutable.js).
|
||||
|
||||
The core abstractions for content editing are `collections`, `entries`, and `widgets`.
|
||||
|
||||
Each `collection` represents a collection of entries. This can either be a collection of similar entries with the same structure, or a set of entries where each has its own structure.
|
||||
|
||||
The structure of an entry is defined as a series of fields, each with a `name`, a `label`, and a `widget`.
|
||||
|
||||
The `widget` determines the UI widget that the content editor will use when editing this field of an entry, as well as how the content of the field is presented in the editing preview.
|
||||
|
||||
Entries are loaded and persisted through a `backend` that will typically represent a `git` repository.
|
||||
|
||||
## State shape / reducers
|
||||
**Auth:** Keeps track of the logged state and the current user.
|
||||
|
||||
**Config:** Holds the environment configuration (backend type, available collections and fields).
|
||||
|
||||
**Collections:** List of available collections and their fields information.
|
||||
|
||||
**Entries:** Entries for each field.
|
||||
|
||||
**EntryDraft:** Reused for each entry that is edited or created. It holds the entry's temporary data until it's persisted on the backend.
|
||||
|
||||
## Selectors
|
||||
Selectors are functions defined within reducers used to compute derived data from the Redux store. The available selectors are:
|
||||
|
||||
**selectEntry:** Selects a single entry, given the collection and a slug.
|
||||
|
||||
**selectEntries:** Selects all entries for a given collection.
|
||||
|
||||
**getAsset:** Selects a single AssetProxy object for the given path.
|
||||
|
||||
## Value Objects
|
||||
**AssetProxy:** AssetProxy is a Value Object that holds information regarding an asset file (for example, an image), whether it's persisted online or held locally in cache.
|
||||
|
||||
For a file persisted online, the AssetProxy only keeps information about its URI. For local files, the AssetProxy will keep a reference to the actual File object while generating the expected final URIs and on-demand blobs for local preview.
|
||||
|
||||
The AssetProxy object can be used directly inside a media tag (such as `<img>`), as it will always return something that can be used by the media tag to render correctly (either the URI for the online file or a single-use blob).
|
||||
|
||||
## Components structure and Workflows
|
||||
Components are separated into two main categories: Container components and Presentational components.
|
||||
|
||||
### Entry Editing
|
||||
For either updating an existing entry or creating a new one, the `EntryEditor` is used and the flow is the same:
|
||||
|
||||
* When mounted, the `EntryPage` container component dispatches the `createDraft` action, setting the `entryDraft` state to a blank state (in case of a new entry) or to a copy of the selected entry (in case of an edit).
|
||||
* The `EntryPage` will also render widgets for each field type in the given entry.
|
||||
* Widgets are used for editing entry fields. There are different widgets for different field types, and they are always defined in a pair containing a `control` component and a `preview` component. The control component is responsible for presenting the user with the appropriate interface for manipulating the current field value. The preview component is responsible for displaying the value with the appropriate styling.
|
||||
|
||||
#### Widget components implementation
|
||||
The control component receives one (1) callback as a prop: `onChange`.
|
||||
|
||||
* onChange (required): Should be called when the users changes the current value. It will ultimately end up updating the EntryDraft object in the Redux Store, thus updating the preview component.
|
||||
* addAsset & onRemoveAsset (optionals): Should be invoked with an `AssetProxy` value object if the field accepts file uploads for media (images, for example). `addAsset` will get the current media stored in the Redux state tree while `onRemoveAsset` will remove it. AssetProxy objects are stored in the `Medias` object and referenced in the `EntryDraft` object on the state tree.
|
||||
|
||||
Both control and preview widgets receive a `getAsset` selector via props. Displaying the media (or its URI) for the user should always be done via `getAsset`, as it returns an AssetProxy that can return the correct value for both medias already persisted on the server and cached media not yet uploaded.
|
||||
|
||||
The actual persistence of the content and medias inserted into the control component is delegated to the backend implementation. The backend will be called with the updated values and a list of assetProxy objects for each field of the entry, and should return a promise that can resolve into the persisted entry object and the list of the persisted media URIs.
|
@ -1,33 +0,0 @@
|
||||
---
|
||||
group: Accounts
|
||||
title: Azure
|
||||
weight: 20
|
||||
---
|
||||
For repositories stored on Azure, the `azure` backend allows CMS users to log in directly with their Azure account. Note that all users must have write access to your content repository for this to work.
|
||||
|
||||
## Authentication
|
||||
|
||||
In order to get Static CMS working with Azure DevOps, you need a Tenant Id and an Application Id.
|
||||
|
||||
1. If you do not have an Azure account, [create one here](https://azure.microsoft.com/en-us/free/?WT.mc_id=A261C142F) and make sure to have a credit card linked to the account.
|
||||
2. If you do not have an Azure Active Directory Tenant Id, [set one up here](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-create-new-tenant).
|
||||
3. [Register an application with Azure AD](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app). Configure it as a Single tenant Web application and add a redirect URI (e.g. `http://localhost:8080/`)
|
||||
4. Add the `Azure DevOps->user_impersonation` [permission](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis#add-permissions-to-access-your-web-api) for the created application.
|
||||
5. [Grant admin consent](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis#admin-consent-button) for the application.
|
||||
6. Under `Authentication->Implicit grant` enable [Access tokens](https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens) for the application and click `Save`.
|
||||
7. Verify your Azure DevOps organization is connected to the same directory as your tenant under: `https://dev.azure.com/<organization>/_settings/organizationAad`
|
||||
8. Add the following lines to your Static CMS `config.yml` file:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
title: azure
|
||||
repo: organization/project/repo # replace with actual path
|
||||
tenant_id: tenantId # replace with your tenantId
|
||||
app_id: appId # replace with your appId
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
1. Pagination is not supported so some endpoints might return missing data
|
||||
|
||||
2. Nested collection are partially supported as Azure doesn't allow [renaming and editing](https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pushes/create?view=azure-devops-rest-6.1&source=docs#rename-a-file) in a single operation
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
group: Accounts
|
||||
group: Backends
|
||||
title: Overview
|
||||
weight: 1
|
||||
---
|
||||
@ -8,17 +8,18 @@ A backend is JavaScript code that allows Static CMS to communicate with a servic
|
||||
|
||||
## Backend Configuration
|
||||
|
||||
Individual backends should provide their own configuration documentation, but there are some configuration options that are common to multiple backends. A full reference is below. Note that these are properties of the `backend` field, and should be nested under that field.
|
||||
Individual backends provide their own configuration documentation, but there are some configuration options that are common to multiple backends. A full reference is below. Note that these are properties of the `backend` field, and should be nested under that field.
|
||||
|
||||
| Field | Default | Description |
|
||||
| --------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `repo` | none | **Required** for `github`, `gitlab`, and `bitbucket` backends; ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]`. |
|
||||
| `branch` | `main` | The branch where published content is stored. All CMS commits and PRs are made to this branch. |
|
||||
| `api_root` | `https://api.github.com` (GitHub), `https://gitlab.com/api/v4` (GitLab), or `https://api.bitbucket.org/2.0` (Bitbucket) | The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab. |
|
||||
| `site_domain` | `location.hostname` (or `cms.netlify.com` when on `localhost`) | Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly. |
|
||||
| `base_url` | `https://api.netlify.com` (GitHub, Bitbucket) or `https://gitlab.com` (GitLab) | OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab. |
|
||||
| `auth_endpoint` | `auth` (GitHub, Bitbucket) or `oauth/authorize` (GitLab) | Path to append to `base_url` for authentication requests. Optional. |
|
||||
| Name | Type | Default | Description |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| name | 'git-gateway'<br />\| 'github'<br />\| 'gitlab'<br />\| 'bitbucket'<br />\| 'test-repo'<br />\| 'proxy' | | The backend git provider |
|
||||
| repo | string | | Required for `github`, `gitlab`, and `bitbucket` backends. Ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]` |
|
||||
| branch | string | `main` | _Optional_. The branch where published content is stored. All CMS commits and PRs are made to this branch |
|
||||
| api_root | string | GitHub<br />`https://api.github.com`<br /><br />GitLab<br/>`https://gitlab.com/api/v4`<br /><br />Bitbucket<br />`https://api.bitbucket.org/2.0` | _Optional_. The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab |
|
||||
| site_domain | string | `location.hostname`<br /><br />On `localhost`<br />`cms.netlify.com` | _Optional_. Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly |
|
||||
| base_url | string | GitHub or Bitbucket<br />`https://api.netlify.com`<br /><br />GitLab<br />`https://gitlab.com` | _Optional_. OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab |
|
||||
| auth_endpoint | string | GitHub or Bitbucket<br />`auth`<br /><br />GitLab<br />`oauth/authorize` | _Optional_. Path to append to `base_url` for authentication requests. |
|
||||
|
||||
## Creating a New Backend
|
||||
|
||||
Anyone can write a backend, but we don't yet have a finalized and documented API. If you would like to write your own backend for a service that does not have one currently, we recommend using the [GitHub backend](https://github.com/StaticJsCMS/static-cms/tree/main/src/backends/github) as a reference for API and best practices.
|
||||
Anyone can write a backend, but the API is not yet finalized and documented. If you would like to write your own backend for a service that does not have one currently, Static CMS recommends using the [GitHub backend](https://github.com/StaticJsCMS/static-cms/tree/main/core/src/backends/github) as a reference for API and best practices.
|
||||
|
@ -1,56 +1,13 @@
|
||||
---
|
||||
group: Configuration
|
||||
title: Beta Features!
|
||||
group: Intro
|
||||
title: Beta Features
|
||||
weight: 200
|
||||
---
|
||||
We run new functionality in an open beta format from time to time. That means that this functionality is totally available for use, and we *think* it might be ready for primetime, but it could break or change without notice.
|
||||
|
||||
Static CMS runs new functionality in an open beta format from time to time. That means that this functionality is totally available for use, an it might be ready for primetime, but it could break or change without notice.
|
||||
|
||||
**Use these features at your own risk.**
|
||||
|
||||
## Working with a Local Git Repository
|
||||
|
||||
You can connect Static CMS to a local Git repository, instead of working with a live repo.
|
||||
|
||||
1. Navigate to a local Git repository configured with the CMS.
|
||||
2. Add the top-level property `local_backend` configuration to your `config.yml`:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
title: git-gateway
|
||||
|
||||
# when using the default proxy server port
|
||||
local_backend: true
|
||||
```
|
||||
|
||||
3. Run `npx @staticcms/proxy-server` from the root directory of the above repository.
|
||||
|
||||
* If the default port (8081) is in use, the proxy server won't start and you will see an error message. In this case, follow [these steps](#configure-the-@staticcms/proxy-server-port-number) before proceeding.
|
||||
4. Start your local development server (e.g. run `gatsby develop`).
|
||||
5. Open `http://localhost:<port>/admin` to verify that your can administer your content locally. Replace `<port>` with the port of your local development server. For example Gatsby's default port is `8000`
|
||||
|
||||
**Note:** `@staticcms/proxy-server` runs an unauthenticated express server. As any client can send requests to the server, it should only be used for local development.
|
||||
|
||||
### Configure the Static CMS proxy server port number
|
||||
|
||||
1. Create a `.env` file in the project's root folder and define the PORT you'd like the proxy server to use
|
||||
|
||||
```ini
|
||||
PORT=8082
|
||||
```
|
||||
|
||||
2. Update the `local_backend` object in `config.yml` and specify a `url` property to use your custom port number
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
title: git-gateway
|
||||
|
||||
local_backend:
|
||||
# when using a custom proxy server port
|
||||
url: http://localhost:8082/api/v1
|
||||
# when accessing the local site from a host other than 'localhost' or '127.0.0.1'
|
||||
allowed_hosts: ['192.168.0.1']
|
||||
```
|
||||
|
||||
## i18n Support
|
||||
|
||||
The CMS can provide a side by side interface for authoring content in multiple languages.
|
||||
@ -78,7 +35,7 @@ i18n:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: i18n_content
|
||||
- name: i18n_content
|
||||
# same as the top level, but all fields are optional and defaults to the top level
|
||||
# can also be a boolean to accept the top level defaults
|
||||
i18n: true
|
||||
@ -88,20 +45,20 @@ When using a file collection, you must also enable i18n for each individual file
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: pages
|
||||
- name: pages
|
||||
label: Pages
|
||||
# Configure i18n for this collection.
|
||||
i18n:
|
||||
structure: single_file
|
||||
locales: [en, de, fr]
|
||||
files:
|
||||
- title: about
|
||||
- name: about
|
||||
label: About Page
|
||||
file: site/content/about.yml
|
||||
# Enable i18n for this file.
|
||||
i18n: true
|
||||
fields:
|
||||
- { label: Title, title: title, widget: string, i18n: true }
|
||||
- { label: Title, name: title, widget: string, i18n: true }
|
||||
```
|
||||
|
||||
### Field level configuration
|
||||
@ -109,17 +66,17 @@ collections:
|
||||
```yaml
|
||||
fields:
|
||||
- label: Title
|
||||
title: title
|
||||
name: title
|
||||
widget: string
|
||||
# same as 'i18n: translate'. Allows translation of the title field
|
||||
i18n: true
|
||||
- label: Date
|
||||
title: date
|
||||
name: date
|
||||
widget: datetime
|
||||
# The date field will be duplicated from the default locale.
|
||||
i18n: duplicate
|
||||
- label: Body
|
||||
title: body
|
||||
name: body
|
||||
# The markdown field will be omitted from the translation.
|
||||
widget: markdown
|
||||
```
|
||||
@ -132,22 +89,22 @@ i18n:
|
||||
locales: [en, de, fr]
|
||||
|
||||
collections:
|
||||
- title: posts
|
||||
- name: posts
|
||||
label: Posts
|
||||
folder: content/posts
|
||||
create: true
|
||||
i18n: true
|
||||
fields:
|
||||
- label: Title
|
||||
title: title
|
||||
name: title
|
||||
widget: string
|
||||
i18n: true
|
||||
- label: Date
|
||||
title: date
|
||||
name: date
|
||||
widget: datetime
|
||||
i18n: duplicate
|
||||
- label: Body
|
||||
title: body
|
||||
name: body
|
||||
widget: markdown
|
||||
```
|
||||
|
||||
@ -159,96 +116,29 @@ collections:
|
||||
|
||||
```yaml
|
||||
- label: 'Object'
|
||||
title: 'object'
|
||||
name: 'object'
|
||||
widget: 'object'
|
||||
i18n: true
|
||||
fields:
|
||||
- { label: 'String', title: 'string', widget: 'string', i18n: true }
|
||||
- { label: 'Date', title: 'date', widget: 'datetime', i18n: duplicate }
|
||||
- { label: 'Boolean', title: 'boolean', widget: 'boolean', i18n: duplicate }
|
||||
- { label: 'String', name: 'string', widget: 'string', i18n: true }
|
||||
- { label: 'Date', name: 'date', widget: 'datetime', i18n: duplicate }
|
||||
- { label: 'Boolean', name: 'boolean', widget: 'boolean', i18n: duplicate }
|
||||
- {
|
||||
label: 'Object',
|
||||
title: 'object',
|
||||
name: 'object',
|
||||
widget: 'object',
|
||||
i18n: true,
|
||||
field: { label: 'String', title: 'string', widget: 'string', i18n: duplicate },
|
||||
field: { label: 'String', name: 'string', widget: 'string', i18n: duplicate },
|
||||
}
|
||||
```
|
||||
|
||||
## Folder Collections Path
|
||||
|
||||
By default the CMS stores folder collection content under the folder specified in the collection setting.
|
||||
See [Folder Collections Path](/docs/collection-types#folder-collections-path).
|
||||
|
||||
For example configuring `folder: posts` for a collection will save the content under `posts/post-title.md`.
|
||||
## Nested Collections
|
||||
|
||||
You can now specify an additional `path` template (similar to the `slug` template) to control the content destination.
|
||||
|
||||
This allows saving content in subfolders, e.g. configuring `path: '{{year}}/{{slug}}'` will save the content under `posts/2019/post-title.md`.
|
||||
|
||||
## Folder Collections Media and Public Folder
|
||||
|
||||
By default the CMS stores media files for all collections under a global `media_folder` directory as specified in the configuration.
|
||||
|
||||
When using the global `media_folder` directory any entry field that points to a media file will use the absolute path to the published file as designated by the `public_folder` configuration.
|
||||
|
||||
For example configuring:
|
||||
|
||||
```yaml
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
```
|
||||
|
||||
And saving an entry with an image named `image.png` will result in the image being saved under `static/media/image.png` and relevant entry fields populated with the value of `/media/image.png`.
|
||||
|
||||
Some static site generators (e.g. Gatsby) work best when using relative image paths.
|
||||
|
||||
This can now be achieved using a per collection `media_folder` configuration which specifies a relative media folder for the collection.
|
||||
|
||||
For example, the following configuration will result in media files being saved in the same directory as the entry, and the image field being populated with the relative path to the image.
|
||||
|
||||
```yaml
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- title: posts
|
||||
label: Posts
|
||||
label_singular: 'Post'
|
||||
folder: content/posts
|
||||
path: '{{slug}}/index'
|
||||
media_folder: ''
|
||||
public_folder: ''
|
||||
fields:
|
||||
- label: Title
|
||||
title: title
|
||||
widget: string
|
||||
- label: 'Cover Image'
|
||||
title: 'image'
|
||||
widget: 'image'
|
||||
```
|
||||
|
||||
More specifically, saving an entry with a title of `example post` with an image named `image.png` will result in a directory structure of:
|
||||
|
||||
```bash
|
||||
content
|
||||
posts
|
||||
example-post
|
||||
index.md
|
||||
image.png
|
||||
```
|
||||
|
||||
And for the image field being populated with a value of `image.png`.
|
||||
|
||||
**Note: When specifying a `path` on a folder collection, `media_folder` defaults to an empty string.**
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
Supports all of the [`slug` templates](/docs/configuration-options#slug) and:
|
||||
|
||||
* `{{dirname}}` The path to the file's parent directory, relative to the collection's `folder`.
|
||||
* `{{filename}}` The file name without the extension part.
|
||||
* `{{extension}}` The file extension.
|
||||
* `{{media_folder}}` The global `media_folder`.
|
||||
* `{{public_folder}}` The global `public_folder`.
|
||||
Seed [Nested Collections](/docs/collection-types#nested-collections).
|
||||
|
||||
## List Widget: Variable Types
|
||||
|
||||
@ -264,9 +154,9 @@ To use variable types in the list widget, update your field configuration as fol
|
||||
|
||||
### Additional list widget options
|
||||
|
||||
* `types`: a nested list of object widgets. All widgets must be of type `object`. Every object widget may define different set of fields.
|
||||
* `typeKey`: the name of the field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined. Default is `type`.
|
||||
* `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
|
||||
- `types`: a nested list of object widgets. All widgets must be of type `object`. Every object widget may define different set of fields.
|
||||
- `typeKey`: the name of the field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined. Default is `type`.
|
||||
- `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
|
||||
|
||||
### Example Configuration
|
||||
|
||||
@ -275,27 +165,27 @@ either a "carousel" or a "spotlight". Each type has a unique name and set of fie
|
||||
|
||||
```yaml
|
||||
- label: 'Home Section'
|
||||
title: 'sections'
|
||||
name: 'sections'
|
||||
widget: 'list'
|
||||
types:
|
||||
- label: 'Carousel'
|
||||
title: 'carousel'
|
||||
name: 'carousel'
|
||||
widget: object
|
||||
summary: '{{fields.header}}'
|
||||
fields:
|
||||
- { label: Header, title: header, widget: string, default: 'Image Gallery' }
|
||||
- { label: Template, title: template, widget: string, default: 'carousel.html' }
|
||||
- { label: Header, name: header, widget: string, default: 'Image Gallery' }
|
||||
- { label: Template, name: template, widget: string, default: 'carousel.html' }
|
||||
- label: Images
|
||||
title: images
|
||||
name: images
|
||||
widget: list
|
||||
field: { label: Image, title: image, widget: image }
|
||||
field: { label: Image, name: image, widget: image }
|
||||
- label: 'Spotlight'
|
||||
title: 'spotlight'
|
||||
name: 'spotlight'
|
||||
widget: object
|
||||
fields:
|
||||
- { label: Header, title: header, widget: string, default: 'Spotlight' }
|
||||
- { label: Template, title: template, widget: string, default: 'spotlight.html' }
|
||||
- { label: Text, title: text, widget: text, default: 'Hello World' }
|
||||
- { label: Header, name: header, widget: string, default: 'Spotlight' }
|
||||
- { label: Template, name: template, widget: string, default: 'spotlight.html' }
|
||||
- { label: Text, name: text, widget: text, default: 'Hello World' }
|
||||
```
|
||||
|
||||
### Example Output
|
||||
@ -367,7 +257,7 @@ init()
|
||||
init({
|
||||
config: {
|
||||
backend: {
|
||||
title: 'git-gateway',
|
||||
name: 'git-gateway',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -383,17 +273,17 @@ init({
|
||||
init({
|
||||
config: {
|
||||
backend: {
|
||||
title: 'git-gateway',
|
||||
name: 'git-gateway',
|
||||
},
|
||||
load_config_file: false,
|
||||
media_folder: "static/images/uploads",
|
||||
public_folder: "/images/uploads",
|
||||
collections: [
|
||||
{ label: "Blog", title: "blog", folder: "_posts/blog", create: true, fields: [
|
||||
{ label: "Title", title: "title", widget: "string" },
|
||||
{ label: "Publish Date", title: "date", widget: "datetime" },
|
||||
{ label: "Featured Image", title: "thumbnail", widget: "image" },
|
||||
{ label: "Body", title: "body", widget: "markdown" },
|
||||
{ label: "Blog", name: "blog", folder: "_posts/blog", create: true, fields: [
|
||||
{ label: "Title", name: "title", widget: "string" },
|
||||
{ label: "Publish Date", name: "date", widget: "datetime" },
|
||||
{ label: "Featured Image", name: "thumbnail", widget: "image" },
|
||||
{ label: "Body", name: "body", widget: "markdown" },
|
||||
]},
|
||||
],
|
||||
},
|
||||
@ -422,22 +312,22 @@ backend:
|
||||
|
||||
Static CMS generates the following commit types:
|
||||
|
||||
| Commit type | When is it triggered? | Available template tags |
|
||||
| --------------- | ---------------------------------------- | ----------------------------------------------------------- |
|
||||
| `create` | A new entry is created | `slug`, `path`, `collection`, `author-login`, `author-name` |
|
||||
| `update` | An existing entry is changed | `slug`, `path`, `collection`, `author-login`, `author-name` |
|
||||
| `delete` | An existing entry is deleted | `slug`, `path`, `collection`, `author-login`, `author-name` |
|
||||
| `uploadMedia` | A media file is uploaded | `path`, `author-login`, `author-name` |
|
||||
| `deleteMedia` | A media file is deleted | `path`, `author-login`, `author-name` |
|
||||
| Commit type | When is it triggered? | Available template tags |
|
||||
| ------------- | ---------------------------- | ----------------------------------------------------------- |
|
||||
| `create` | A new entry is created | `slug`, `path`, `collection`, `author-login`, `author-name` |
|
||||
| `update` | An existing entry is changed | `slug`, `path`, `collection`, `author-login`, `author-name` |
|
||||
| `delete` | An existing entry is deleted | `slug`, `path`, `collection`, `author-login`, `author-name` |
|
||||
| `uploadMedia` | A media file is uploaded | `path`, `author-login`, `author-name` |
|
||||
| `deleteMedia` | A media file is deleted | `path`, `author-login`, `author-name` |
|
||||
|
||||
Template tags produce the following output:
|
||||
|
||||
* `{{slug}}`: the url-safe filename of the entry changed
|
||||
* `{{collection}}`: the name of the collection containing the entry changed
|
||||
* `{{path}}`: the full path to the file changed
|
||||
* `{{message}}`: the relevant message based on the current change (e.g. the `create` message when an entry is created)
|
||||
* `{{author-login}}`: the login/username of the author
|
||||
* `{{author-name}}`: the full name of the author (might be empty based on the user's profile)
|
||||
- `{{slug}}`: the url-safe filename of the entry changed
|
||||
- `{{collection}}`: the name of the collection containing the entry changed
|
||||
- `{{path}}`: the full path to the file changed
|
||||
- `{{message}}`: the relevant message based on the current change (e.g. the `create` message when an entry is created)
|
||||
- `{{author-login}}`: the login/username of the author
|
||||
- `{{author-name}}`: the full name of the author (might be empty based on the user's profile)
|
||||
|
||||
## Image widget file size limit
|
||||
|
||||
@ -447,7 +337,7 @@ Example config:
|
||||
|
||||
```yaml
|
||||
- label: 'Featured Image'
|
||||
title: 'thumbnail'
|
||||
name: 'thumbnail'
|
||||
widget: 'image'
|
||||
default: '/uploads/chocolate-dogecoin.jpg'
|
||||
media_library:
|
||||
@ -463,18 +353,18 @@ Example config:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: 'posts'
|
||||
- name: 'posts'
|
||||
label: 'Posts'
|
||||
folder: '_posts'
|
||||
summary: "{{title | upper}} - {{date | date('YYYY-MM-DD')}} – {{body | truncate(20, '***')}}"
|
||||
fields:
|
||||
- { label: 'Title', title: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', title: 'date', widget: 'datetime' }
|
||||
- { label: 'Body', title: 'body', widget: 'markdown' }
|
||||
- { label: 'Title', name: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', name: 'date', widget: 'datetime' }
|
||||
- { label: 'Body', name: 'body', widget: 'markdown' }
|
||||
```
|
||||
|
||||
The above config will transform the title field to uppercase and format the date field using `YYYY-MM-DD` format.
|
||||
Available transformations are `upper`, `lower`, `date('<format>')`, `default('defaultValue')`, `ternary('valueForTrue','valueForFalse')` and `truncate(<number>)`/`truncate(<number>, '<string>')`
|
||||
Available transformations are `upper`, `lower`, `date('<format>')`, `default('defaultValue')`, `ternary('valueForTrue','valueForFalse')` and `truncate(<number>)`/`truncate(<number>, '<string>')`
|
||||
|
||||
## Registering to CMS Events
|
||||
|
||||
@ -484,7 +374,7 @@ Example usage:
|
||||
|
||||
```javascript
|
||||
CMS.registerEventListener({
|
||||
title: 'prePublish',
|
||||
name: 'prePublish',
|
||||
handler: ({ author, entry }) => console.info(JSON.stringify({ author, data: entry.data })),
|
||||
});
|
||||
```
|
||||
@ -493,7 +383,7 @@ Supported events are `prePublish`, `postPublish`, `preSave` and `postSave`. The
|
||||
|
||||
```javascript
|
||||
CMS.registerEventListener({
|
||||
title: 'preSave',
|
||||
name: 'preSave',
|
||||
handler: ({ entry }) => {
|
||||
return entry.data.set('title', 'new title');
|
||||
},
|
||||
@ -508,23 +398,23 @@ For example given the configuration:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: posts
|
||||
- name: posts
|
||||
label: Posts
|
||||
folder: content/posts
|
||||
create: true
|
||||
fields:
|
||||
- label: Title
|
||||
title: title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Object
|
||||
title: object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Title
|
||||
title: title
|
||||
name: title
|
||||
widget: string
|
||||
- label: body
|
||||
title: body
|
||||
name: body
|
||||
widget: markdown
|
||||
```
|
||||
|
||||
@ -534,45 +424,3 @@ will open the editor for a new post with the `title` field populated with `first
|
||||
with `second` and the markdown `body` field with `# content`.
|
||||
|
||||
**Note:** URL Encoding might be required for certain values (e.g. in the previous example the value for `body` is URL encoded).
|
||||
|
||||
## Nested Collections
|
||||
|
||||
Allows a folder collection to show a nested structure of entries and edit the locations of the entries.
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: pages
|
||||
label: Pages
|
||||
label_singular: 'Page'
|
||||
folder: content/pages
|
||||
create: true
|
||||
# adding a nested object will show the collection folder structure
|
||||
nested:
|
||||
depth: 100 # max depth to show in the collection tree
|
||||
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
|
||||
fields:
|
||||
- label: Title
|
||||
title: title
|
||||
widget: string
|
||||
- label: Body
|
||||
title: body
|
||||
widget: markdown
|
||||
```
|
||||
|
||||
Nested collections expect the following directory structure:
|
||||
|
||||
```bash
|
||||
content
|
||||
└── pages
|
||||
├── authors
|
||||
│ ├── author-1
|
||||
│ │ └── index.md
|
||||
│ └── index.md
|
||||
├── index.md
|
||||
└── posts
|
||||
├── hello-world
|
||||
│ └── index.md
|
||||
└── index.md
|
||||
```
|
||||
|
@ -1,8 +1,11 @@
|
||||
---
|
||||
group: Accounts
|
||||
group: Backends
|
||||
title: Bitbucket
|
||||
weight: 20
|
||||
weight: 30
|
||||
---
|
||||
|
||||
- **Name**: `bitbucket`
|
||||
|
||||
For repositories stored on Bitbucket, the `bitbucket` backend allows CMS users to log in directly with their Bitbucket account. Note that all users must have write access to your content repository for this to work.
|
||||
|
||||
## Authentication
|
||||
@ -12,8 +15,8 @@ To enable Bitbucket authentication it:
|
||||
1. Follow the authentication provider setup steps in the [Netlify docs](https://www.netlify.com/docs/authentication-providers/#using-an-authentication-provider).
|
||||
2. Add the following lines to your Static CMS `config.yml` file:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
title: bitbucket
|
||||
repo: owner-name/repo-name # Path to your Bitbucket repository
|
||||
```
|
||||
```yaml
|
||||
backend:
|
||||
name: bitbucket
|
||||
repo: owner-name/repo-name # Path to your Bitbucket repository
|
||||
```
|
||||
|
@ -3,6 +3,9 @@ group: Media
|
||||
title: Cloudinary
|
||||
weight: 10
|
||||
---
|
||||
|
||||
## <img src="https://img.shields.io/badge/-Beta%20Feature-blue" alt="Beta Feature. Use at your own risk" title="Beta Feature. Use at your own risk" />
|
||||
|
||||
Cloudinary is a digital asset management platform with a broad feature set, including support for responsive image generation and url based image transformation. They also provide a powerful media library UI for managing assets, and tools for organizing your assets into a hierarchy.
|
||||
|
||||
The Cloudinary media library integration for Static CMS uses Cloudinary's own media library interface within Static CMS. To get started, you'll need a Cloudinary account and Static CMS 2.3.0 or greater.
|
||||
@ -13,32 +16,33 @@ You can [sign up for Cloudinary](https://cloudinary.com/users/register/free) for
|
||||
|
||||
![Cloudinary console screenshot](/img/cloudinary-console-details.webp)
|
||||
|
||||
## Connecting Cloudinary to Static CMS
|
||||
## Connecting Cloudinary
|
||||
|
||||
To use the Cloudinary media library within Static CMS, you'll need to update your Static CMS configuration file with the information from your Cloudinary account:
|
||||
|
||||
```yaml
|
||||
media_library:
|
||||
title: cloudinary
|
||||
name: cloudinary
|
||||
config:
|
||||
cloud_title: your_cloud_name
|
||||
cloud_name: your_cloud_name
|
||||
api_key: your_api_key
|
||||
```
|
||||
|
||||
**Note:** The user must be logged in to the Cloudinary account connected to the `api_key` used in your Static CMS configuration.
|
||||
**Note:** The user must be logged in to the Cloudinary account connected to the `api_key` used in your Static CMS configuration.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
Although this setup exposes the `cloud_name` and `api_key` publicly via the `/admin/config.yml` endpoint, this information is not sensitive. Any integration of the Cloudinary media library requires this information to be exposed publicly. To use this library or use the restricted Cloudinary API endpoints, the user must have access to the Cloudinary account login details or the `api_secret` associated with the `cloud_name` and `api_key`.
|
||||
|
||||
## Static CMS configuration options
|
||||
|
||||
The following options are specific to the Static CMS integration for Cloudinary:
|
||||
|
||||
* **`output_filename_only`**: _(default: `false`)_\
|
||||
- **`output_filename_only`**: _(default: `false`)_\
|
||||
By default, the value provided for a selected image is a complete URL for the asset on Cloudinary's CDN. Setting `output_filename_only` to `true` will instead produce just the filename (e.g. `image.jpg`). This should be `true` if you will be directly embedding cloudinary transformation urls in page templates. Refer to [Inserting Cloudinary URL in page templates](#inserting-cloudinary-url-in-page-templates).
|
||||
* **`use_transformations`**: _(default: `true`)_\
|
||||
- **`use_transformations`**: _(default: `true`)_\
|
||||
If `true`, uses derived url when available (the url will have image transformation segments included). Has no effect if `output_filename_only` is set to `true`.
|
||||
* **`use_secure_url`**: _(default: `true`)_\
|
||||
- **`use_secure_url`**: _(default: `true`)_\
|
||||
Controls whether an `http` or `https` URL is provided. Has no effect if `output_filename_only` is set to `true`.
|
||||
|
||||
## Cloudinary configuration options
|
||||
@ -47,20 +51,21 @@ The following options are used to configure the media library. All options are l
|
||||
|
||||
### Authentication
|
||||
|
||||
* `cloud_name`
|
||||
* `api_key`
|
||||
- `cloud_name`
|
||||
- `api_key`
|
||||
|
||||
### Media library behavior
|
||||
|
||||
* `default_transformations` _\- only the first [image transformation](#image-transformations) is used, be sure to use the `SDK Parameter` column transformation names from the_ [_transformation reference_](https://cloudinary.com/documentation/image_transformation_reference)
|
||||
* `max_files` _\- has no impact on images inside the [markdown widget](/docs/widgets/#markdown)_. Refer to [media library documentation](https://cloudinary.com/documentation/media_library_widget#3_set_the_configuration_options) for details on this property
|
||||
* `multiple` _\- has no impact on images inside the [markdown widget](/docs/widgets/#markdown)_. Refer to [media library documentation](https://cloudinary.com/documentation/media_library_widget#3_set_the_configuration_options) for details on this property
|
||||
- `default_transformations` _\- only the first [image transformation](#image-transformations) is used, be sure to use the `SDK Parameter` column transformation names from the_ [_transformation reference_](https://cloudinary.com/documentation/image_transformation_reference)
|
||||
- `max_files` _\- has no impact on images inside the [markdown widget](/docs/widgets/#markdown)_. Refer to [media library documentation](https://cloudinary.com/documentation/media_library_widget#3_set_the_configuration_options) for details on this property
|
||||
- `multiple` _\- has no impact on images inside the [markdown widget](/docs/widgets/#markdown)_. Refer to [media library documentation](https://cloudinary.com/documentation/media_library_widget#3_set_the_configuration_options) for details on this property
|
||||
|
||||
## Image transformations
|
||||
|
||||
The Cloudinary integration allows images to be transformed in two ways: directly within Static CMS via [Cloudinary's Media Library](#transforming-images-via-media-library), and separately from the CMS via Cloudinary's [dynamic URL's](https://cloudinary.com/documentation/image_transformations#delivering_media_assets_using_dynamic_urls) by [inserting cloudinary urls](#inserting-cloudinary-url-in-page-templates).
|
||||
|
||||
### Transforming images via Media Library
|
||||
### Transforming Images
|
||||
|
||||
If you transform and insert images from within the Cloudinary media library, the transformed image URL will be output by default. This gives the editor complete freedom to make changes to the image output.
|
||||
There are two ways to configure image transformation via media library - [globally](#global-configuration) and per [field](#field-configuration). Global options will be overridden by field options.
|
||||
|
||||
@ -73,7 +78,7 @@ instance of the Cloudinary widget.
|
||||
```yaml
|
||||
# global
|
||||
media_library:
|
||||
title: cloudinary
|
||||
name: cloudinary
|
||||
output_filename_only: false
|
||||
config:
|
||||
default_transformations:
|
||||
@ -92,43 +97,45 @@ For example:
|
||||
```yaml
|
||||
# field
|
||||
fields: # The fields each document in this collection have
|
||||
- label: 'Cover Image'
|
||||
title: 'image'
|
||||
widget: 'image'
|
||||
required: false
|
||||
tagtitle: ''
|
||||
media_library:
|
||||
config:
|
||||
default_transformations:
|
||||
- fetch_format: auto
|
||||
width: 300
|
||||
quality: auto
|
||||
crop: fill
|
||||
effect: grayscale
|
||||
- label: 'Cover Image'
|
||||
name: 'image'
|
||||
widget: 'image'
|
||||
required: false
|
||||
tagtitle: ''
|
||||
media_library:
|
||||
config:
|
||||
default_transformations:
|
||||
- fetch_format: auto
|
||||
width: 300
|
||||
quality: auto
|
||||
crop: fill
|
||||
effect: grayscale
|
||||
```
|
||||
|
||||
## Inserting Cloudinary URL in page templates
|
||||
|
||||
If you prefer to provide direction so that images are transformed in a specific way, or dynamically retrieve images based on viewport size, you can do so by providing your own base Cloudinary URL and only storing the asset filenames in your content:
|
||||
|
||||
* Either globally or for specific fields, configure the Cloudinary extension to only output the asset filename
|
||||
- Either globally or for specific fields, configure the Cloudinary extension to only output the asset filename
|
||||
|
||||
```yaml
|
||||
# global
|
||||
media_library:
|
||||
title: cloudinary
|
||||
name: cloudinary
|
||||
output_filename_only: true
|
||||
# field
|
||||
media_library:
|
||||
title: cloudinary
|
||||
name: cloudinary
|
||||
output_filename_only: true
|
||||
```
|
||||
|
||||
* Provide a dynamic URL in the site template
|
||||
- Provide a dynamic URL in the site template
|
||||
|
||||
```handlebars
|
||||
{{! handlebars example }}
|
||||
<img src="https://res.cloudinary.com/<cloud_name>/<resource_type>/<type>/<transformations>/{{image}}"/>
|
||||
<img
|
||||
src='https://res.cloudinary.com/<cloud_name>/<resource_type>/<type>/<transformations>/{{image}}'
|
||||
/>
|
||||
```
|
||||
|
||||
Your dynamic URL can be formed conditionally to provide any desired transformations - please see Cloudinary's [image transformation reference](https://cloudinary.com/documentation/image_transformation_reference) for available transformations.
|
||||
|
217
website/content/docs/collection-overview.mdx
Normal file
217
website/content/docs/collection-overview.mdx
Normal file
@ -0,0 +1,217 @@
|
||||
---
|
||||
group: Collections
|
||||
title: Collections Configuration
|
||||
weight: 9
|
||||
---
|
||||
|
||||
`collections` accepts a list of collection objects, each with the following options
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| name | string | | Unique identifier for the collection, used as the key when referenced in other contexts (like the [relation widget](/docs/widgets/#relation)) |
|
||||
| identifier_field | string | `'title'` | _Optional_. See [identifier_field](#identifier_field) below |
|
||||
| label | string | `name` | _Optional_. Label for the collection in the editor UI |
|
||||
| label_singular | string | `label` | _Optional_. singular label for certain elements in the editor |
|
||||
| icon | string | | Unique name of icon to use in main menu. See [Custom Icons](/docs/custom-icons) |
|
||||
| description | string | | _Optional_. Text displayed below the label when viewing a collection |
|
||||
| files or folder | [Collection Files](/docs/collection-types#file-collections)<br />\| [Collection Folder](/docs/collection-types#folder-collections) | | **Requires one of these**: Specifies the collection type and location; details in [Collection Types](/docs/collection-types) |
|
||||
| filter | string | | _Optional_. Filter for [Folder Collections](/docs/collection-types#folder-collections) |
|
||||
| create | string | `false` | **For [Folder Collections](/docs/collection-types#folder-collections) only**<br />`true` - Allows users to create new items in the collection |
|
||||
| hide | string | `false` | `true` hides a collection in the CMS UI. Useful when using the relation widget to hide referenced collections |
|
||||
| delete | string | `true` | `false` prevents users from deleting items in a collection |
|
||||
| extension | string | | See [extension](#extension-and-format) below |
|
||||
| format | string | | See [format](#extension-and-format) below |
|
||||
| frontmatter_delimiter | string | | See [frontmatter_delimiter](#frontmatter_delimiter) below |
|
||||
| slug | string | | See [slug](#slug) below |
|
||||
| fields (required) | string | | See [fields](#fields) below |
|
||||
| editor | string | | See [editor](#editor) below |
|
||||
| summary | string | | See [summary](#summary) below |
|
||||
| sortable_fields | string | | See [sortable_fields](#sortable_fields) below |
|
||||
| view_filters | string | | See [view_filters](#view_filters) below |
|
||||
| view_groups | string | | See [view_groups](#view_groups) below |
|
||||
|
||||
## `identifier_field`
|
||||
|
||||
Static CMS expects every entry to provide a field named `"title"` that serves as an identifier for the entry. The identifier field serves as an entry's title when viewing a list of entries, and is used in [slug](#slug) creation. If you would like to use a field other than `"title"` as the identifier, you can set `identifier_field` to the name of the other field.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- name: posts
|
||||
identifier_field: name
|
||||
```
|
||||
|
||||
## `extension` and `format`
|
||||
|
||||
These settings determine how collection files are parsed and saved. Both are optional—Static CMS will attempt to infer your settings based on existing items in the collection. If your collection is empty, or you'd like more control, you can set these fields explicitly.
|
||||
|
||||
`extension` determines the file extension searched for when finding existing entries in a folder collection and it determines the file extension used to save new collection items. It accepts the following values: `yml`, `yaml`, `toml`, `json`, `md`, `markdown`, `html`.
|
||||
|
||||
You may also specify a custom `extension` not included in the list above, as long as the collection files can be parsed and saved in one of the supported formats below.
|
||||
|
||||
`format` determines how collection files are parsed and saved. It will be inferred if the `extension` field or existing collection file extensions match one of the supported extensions above. It accepts the following values:
|
||||
|
||||
- `yml` or `yaml`: parses and saves files as YAML-formatted data files; saves with `yml` extension by default
|
||||
- `toml`: parses and saves files as TOML-formatted data files; saves with `toml` extension by default
|
||||
- `json`: parses and saves files as JSON-formatted data files; saves with `json` extension by default
|
||||
- `frontmatter`: parses files and saves files with data frontmatter followed by an unparsed body text (edited using a `body` field); saves with `md` extension by default; default for collections that can't be inferred. Collections with `frontmatter` format (either inferred or explicitly set) can parse files with frontmatter in YAML, TOML, or JSON format. However, they will be saved with YAML frontmatter.
|
||||
- `yaml-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved only as YAML, followed by unparsed body text. The default delimiter for this option is `---`.
|
||||
- `toml-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved only as TOML, followed by unparsed body text. The default delimiter for this option is `+++`.
|
||||
- `json-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved as JSON, followed by unparsed body text. The default delimiter for this option is `{` `}`.
|
||||
|
||||
## `frontmatter_delimiter`
|
||||
|
||||
If you have an explicit frontmatter format declared, this option allows you to specify a custom delimiter like `~~~`. If you need different beginning and ending delimiters, you can use an array like `["(", ")"]`.
|
||||
|
||||
## `slug`
|
||||
|
||||
For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field (a different field can be used via [`identifier_field`](#identifier_field)).
|
||||
|
||||
The slug template can also reference a field value by name, eg. `{{title}}`. If a field name conflicts with a built in template tag name - for example, if you have a field named `slug`, and would like to reference that field via `{{slug}}`, you can do so by adding the explicit `fields.` prefix, eg. `{{fields.slug}}`.
|
||||
|
||||
### Available Template Tags
|
||||
|
||||
- Any field can be referenced by wrapping the field name in double curly braces, eg. `{{author}}`
|
||||
- `{{slug}}`: a url-safe version of the `title` field (or identifier field) for the file
|
||||
- `{{year}}`: 4-digit year of the file creation date
|
||||
- `{{month}}`: 2-digit month of the file creation date
|
||||
- `{{day}}`: 2-digit day of the month of the file creation date
|
||||
- `{{hour}}`: 2-digit hour of the file creation date
|
||||
- `{{minute}}`: 2-digit minute of the file creation date
|
||||
- `{{second}}`: 2-digit second of the file creation date
|
||||
|
||||
### Examples
|
||||
|
||||
#### Basic Example
|
||||
|
||||
```yaml
|
||||
slug: '{{year}}-{{month}}-{{day}}_{{slug}}'
|
||||
```
|
||||
|
||||
#### Field Names
|
||||
|
||||
```yaml
|
||||
slug: '{{year}}-{{month}}-{{day}}_{{title}}_{{some_other_field}}'
|
||||
```
|
||||
|
||||
#### Field Name That Conflicts With Template Tag
|
||||
|
||||
```yaml
|
||||
slug: '{{year}}-{{month}}-{{day}}_{{fields.slug}}'
|
||||
```
|
||||
|
||||
## `fields`
|
||||
|
||||
The `fields` option maps editor UI widgets to field-value pairs in the saved file. The order of the fields in your Static CMS `config.yml` file determines their order in the editor UI and in the saved file.
|
||||
|
||||
`fields` accepts a list of widgets. See [widgets](/docs/widgets) for more details.
|
||||
|
||||
In files with frontmatter, one field should be named `body`. This special field represents the section of the document (usually markdown) that comes after the frontmatter.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- label: "Title"
|
||||
name: "title"
|
||||
widget: "string"
|
||||
pattern: ['.{20,}', "Must have at least 20 characters"]
|
||||
- {label: "Layout", name: "layout", widget: "hidden", default: "blog"}
|
||||
- {label: "Featured Image", name: "thumbnail", widget: "image", required: false}
|
||||
- {label: "Body", name: "body", widget: "markdown"}
|
||||
comment: 'This is a multiline\ncomment'
|
||||
```
|
||||
|
||||
## `editor`
|
||||
|
||||
This setting changes options for the editor view of a collection or a file inside a files collection. It has the following options:
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| preview | boolean | `true` | Set to `false` to disable the preview pane for this collection or file |
|
||||
| frame | boolean | `true` | <ul><li>`true` - Previews render in a frame</li><li>`false` - Previews render directly in your app</li></ul> |
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
editor:
|
||||
preview: false
|
||||
frame: false
|
||||
```
|
||||
|
||||
**Note**: Setting this as a top level configuration will set the default for all collections
|
||||
|
||||
## `summary`
|
||||
|
||||
This setting allows the customization of the collection list view. Similar to the `slug` field, a string with templates can be used to include values of different fields, e.g. `{{title}}`. This option over-rides the default of `title` field and `identifier_field`.
|
||||
|
||||
### Available Template Tags
|
||||
|
||||
Template tags are the same as those for [slug](#slug), with the following additions:
|
||||
|
||||
- `{{dirname}}` The path to the file's parent directory, relative to the collection's `folder`.
|
||||
- `{{filename}}` The file name without the extension part.
|
||||
- `{{extension}}` The file extension.
|
||||
- `{{commit_date}}` The file commit date on supported backends (git based backends).
|
||||
- `{{commit_author}}` The file author date on supported backends (git based backends).
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
summary: 'Version: {{version}} - {{title}}'
|
||||
```
|
||||
|
||||
## `sortable_fields`
|
||||
|
||||
An optional list of sort fields to show in the UI.
|
||||
|
||||
Defaults to inferring `title`, `date`, `author` and `description` fields and will also show `Update On` sort field in git based backends.
|
||||
|
||||
When `author` field can't be inferred commit author will be used.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
# use dot notation for nested fields
|
||||
sortable_fields: ['commit_date', 'title', 'commit_author', 'language.en']
|
||||
```
|
||||
|
||||
### `view_filters`
|
||||
|
||||
An optional list of predefined view filters to show in the UI.
|
||||
|
||||
Defaults to an empty list.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
view_filters:
|
||||
- label: "Alice's and Bob's Posts"
|
||||
field: author
|
||||
pattern: 'Alice|Bob'
|
||||
- label: 'Posts published in 2020'
|
||||
field: date
|
||||
pattern: '2020'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
```
|
||||
|
||||
## `view_groups`
|
||||
|
||||
An optional list of predefined view groups to show in the UI.
|
||||
|
||||
Defaults to an empty list.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
# groups items based on the value matched by the pattern
|
||||
pattern: \d{4}
|
||||
- label: Drafts
|
||||
field: draft
|
||||
```
|
@ -14,7 +14,7 @@ Folder collections represent one or more files with the same format, fields, and
|
||||
|
||||
Unlike file collections, folder collections have the option to allow editors to create new items in the collection. This is set by the boolean `create` field.
|
||||
|
||||
**Note:** Folder collections must have at least one field with the name `title` for creating new entry slugs. That field should use the default `string` widget. The `label` for the field can be any string value. If you wish to use a different field as your identifier, set `identifier_field` to the field name. See the [Collections reference doc](/docs/configuration-options/#collections) for details on how collections and fields are configured. If you forget to add this field, you will get an error that your collection "must have a field that is a valid entry identifier".
|
||||
**Note:** Folder collections must have at least one field with the name `title` for creating new entry slugs. That field should use the default `string` widget. The `label` for the field can be any string value. If you wish to use a different field as your identifier, set `identifier_field` to the field name. See the [Collections reference doc](/docs/collection-overview) for details on how collections and fields are configured. If you forget to add this field, you will get an error that your collection "must have a field that is a valid entry identifier".
|
||||
|
||||
### Examples
|
||||
|
||||
@ -115,7 +115,82 @@ collections:
|
||||
widget: markdown
|
||||
```
|
||||
|
||||
### Nested Collections (Beta)
|
||||
### Folder Collections Path <img src="https://img.shields.io/badge/-Beta%20Feature-blue" alt="Beta Feature. Use at your own risk" title="Beta Feature. Use at your own risk" />
|
||||
|
||||
By default the CMS stores folder collection content under the folder specified in the collection setting.
|
||||
|
||||
For example configuring `folder: posts` for a collection will save the content under `posts/post-title.md`.
|
||||
|
||||
You can now specify an additional `path` template (similar to the `slug` template) to control the content destination.
|
||||
|
||||
This allows saving content in subfolders, e.g. configuring `path: '{{year}}/{{slug}}'` will save the content under `posts/2019/post-title.md`.
|
||||
|
||||
#### Media and Public Folder
|
||||
|
||||
By default the CMS stores media files for all collections under a global `media_folder` directory as specified in the configuration.
|
||||
|
||||
When using the global `media_folder` directory any entry field that points to a media file will use the absolute path to the published file as designated by the `public_folder` configuration.
|
||||
|
||||
For example configuring:
|
||||
|
||||
```yaml
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
```
|
||||
|
||||
And saving an entry with an image named `image.png` will result in the image being saved under `static/media/image.png` and relevant entry fields populated with the value of `/media/image.png`.
|
||||
|
||||
Some static site generators (e.g. Gatsby) work best when using relative image paths.
|
||||
|
||||
This can now be achieved using a per collection `media_folder` configuration which specifies a relative media folder for the collection.
|
||||
|
||||
For example, the following configuration will result in media files being saved in the same directory as the entry, and the image field being populated with the relative path to the image.
|
||||
|
||||
```yaml
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: 'Post'
|
||||
folder: content/posts
|
||||
path: '{{slug}}/index'
|
||||
media_folder: ''
|
||||
public_folder: ''
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: 'Cover Image'
|
||||
name: 'image'
|
||||
widget: 'image'
|
||||
```
|
||||
|
||||
More specifically, saving an entry with a title of `example post` with an image named `image.png` will result in a directory structure of:
|
||||
|
||||
```bash
|
||||
content
|
||||
posts
|
||||
example-post
|
||||
index.md
|
||||
image.png
|
||||
```
|
||||
|
||||
And for the image field being populated with a value of `image.png`.
|
||||
|
||||
**Note: When specifying a `path` on a folder collection, `media_folder` defaults to an empty string.**
|
||||
|
||||
##### Available Template Tags
|
||||
|
||||
Supports all of the [`slug` templates](/docs/configuration-options#slug) and:
|
||||
|
||||
* `{{dirname}}` The path to the file's parent directory, relative to the collection's `folder`.
|
||||
* `{{filename}}` The file name without the extension part.
|
||||
* `{{extension}}` The file extension.
|
||||
* `{{media_folder}}` The global `media_folder`.
|
||||
* `{{public_folder}}` The global `public_folder`.
|
||||
|
||||
### Nested Collections <img src="https://img.shields.io/badge/-Beta%20Feature-blue" alt="Beta Feature. Use at your own risk" title="Beta Feature. Use at your own risk" />
|
||||
|
||||
[Nested collections](/docs/beta-features/#nested-collections) is a beta feature that allows a folder collection to show a nested structure of entries and edit the locations of the entries. This feature is useful when you have a complex folder structure and may not want to create separate collections for every directory. As it is in beta, please use with discretion.
|
||||
|
||||
@ -140,7 +215,7 @@ collections:
|
||||
fields:
|
||||
- name: title
|
||||
label: Title
|
||||
widget: string
|
||||
widget: string
|
||||
- name: intro
|
||||
label: Intro
|
||||
widget: markdown
|
||||
|
@ -1,9 +1,8 @@
|
||||
---
|
||||
group: Configuration
|
||||
group: Intro
|
||||
title: Configuration Options
|
||||
weight: 10
|
||||
weight: 180
|
||||
---
|
||||
## Configuration File
|
||||
|
||||
All configuration options for Static CMS are specified in a `config.yml` file, in the folder where you access the editor UI (usually in the `/admin` folder).
|
||||
|
||||
@ -14,7 +13,7 @@ Alternatively, you can specify a custom config file using a link tag:
|
||||
<link href="path/to/config.yml" type="text/yaml" rel="cms-config-url">
|
||||
```
|
||||
|
||||
To see working configuration examples, you can [start from a template](/docs/start-with-a-template) or check out the [CMS demo site](https://cms-demo.netlify.com). (No login required: click the login button and the CMS will open.) You can refer to the demo [configuration code](https://github.com/StaticJsCMS/static-cms/blob/main/dev-test/config.yml) to see how each option was configured.
|
||||
To see working configuration examples, you can [start from a template](/docs/start-with-a-template) or check out the [CMS demo site](https://static-cms-demo.netlify.app). (No login required: click the login button and the CMS will open.) You can refer to the demo [configuration code](https://github.com/StaticJsCMS/static-cms/blob/main/core/dev-test/config.yml) to see how each option was configured.
|
||||
|
||||
You can find details about all configuration options below. Note that [YAML syntax](https://en.wikipedia.org/wiki/YAML#Basic_components) allows lists and objects to be written in block or inline style, and the code samples below include a mix of both.
|
||||
|
||||
@ -24,7 +23,7 @@ You can find details about all configuration options below. Note that [YAML synt
|
||||
|
||||
The `backend` option specifies how to access the content for your site, including authentication. Full details and code samples can be found in [Backends](/docs/backends-overview).
|
||||
|
||||
**Note**: no matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it will always fetch and commit files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS config.yml file. This means that content fetched in the admin UI will match the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI will save directly to the hosted repository, even if you're running the UI locally or in staging. If you want to have your local CMS write to a local repository, try the `local_backend` setting, [currently in beta](/docs/beta-features/#working-with-a-local-git-repository).
|
||||
**Note**: no matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it will always fetch and commit files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS config.yml file. This means that content fetched in the admin UI will match the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI will save directly to the hosted repository, even if you're running the UI locally or in staging. If you want to have your local CMS write to a local repository, [try the local_backend setting](/docs/local-backend).
|
||||
|
||||
## Media and Public Folders
|
||||
|
||||
@ -60,7 +59,7 @@ Media library integrations are configured via the `media_library` property, and
|
||||
|
||||
```yaml
|
||||
media_library:
|
||||
title: uploadcare
|
||||
name: uploadcare
|
||||
config:
|
||||
publicKey: demopublickey
|
||||
```
|
||||
@ -99,9 +98,7 @@ logo_url: https://your-site.com/images/logo.svg
|
||||
|
||||
## Locale
|
||||
|
||||
The CMS locale.
|
||||
|
||||
Defaults to `en`.
|
||||
The CMS locale. Defaults to `en`.
|
||||
|
||||
Other languages than English must be registered manually.
|
||||
|
||||
@ -127,8 +124,7 @@ When a translation for the selected locale is missing the English one will be us
|
||||
|
||||
## Search
|
||||
|
||||
The search functionally requires loading all collection(s) entries, which can exhaust rate limits on large repositories.
|
||||
It can be disabled by setting the top level `search` property to `false`.
|
||||
The search functionally requires loading all collection(s) entries, which can exhaust rate limits on large repositories. It can be disabled by setting the top level `search` property to `false`.
|
||||
|
||||
Defaults to `true`
|
||||
|
||||
@ -167,217 +163,4 @@ slug:
|
||||
|
||||
The `collections` setting is the heart of your Static CMS configuration, as it determines how content types and editor fields in the UI generate files and content in your repository. Each collection you configure displays in the left sidebar of the Content page of the editor UI, in the order they are entered into your Static CMS `config.yml` file.
|
||||
|
||||
`collections` accepts a list of collection objects, each with the following options:
|
||||
|
||||
* `name` (required): unique identifier for the collection, used as the key when referenced in other contexts (like the [relation widget](/docs/widgets/#relation))
|
||||
* `identifier_field`: see detailed description below
|
||||
* `label`: label for the collection in the editor UI; defaults to the value of `name`
|
||||
* `label_singular`: singular label for certain elements in the editor; defaults to the value of `label`
|
||||
* `description`: optional text, displayed below the label when viewing a collection
|
||||
* `files` or `folder` (requires one of these): specifies the collection type and location; details in [Collection Types](/docs/collection-types)
|
||||
* `filter`: optional filter for `folder` collections; details in [Collection Types](/docs/collection-types)
|
||||
* `create`: for `folder` collections only; `true` allows users to create new items in the collection; defaults to `false`
|
||||
* `hide`: `true` hides a collection in the CMS UI; defaults to `false`. Useful when using the relation widget to hide referenced collections.
|
||||
* `delete`: `false` prevents users from deleting items in a collection; defaults to `true`
|
||||
* `extension`: see detailed description below
|
||||
* `format`: see detailed description below
|
||||
* `frontmatter_delimiter`: see detailed description under `format`
|
||||
* `slug`: see detailed description below
|
||||
* `preview_path`: see detailed description below
|
||||
* `preview_path_date_field`: see detailed description below
|
||||
* `fields` (required): see detailed description below
|
||||
* `editor`: see detailed description below
|
||||
* `summary`: see detailed description below
|
||||
* `sortable_fields`: see detailed description below
|
||||
* `view_filters`: see detailed description below
|
||||
* `view_groups`: see detailed description below
|
||||
|
||||
The last few options require more detailed information.
|
||||
|
||||
### `identifier_field`
|
||||
|
||||
Static CMS expects every entry to provide a field named `"title"` that serves as an identifier for the entry. The identifier field serves as an entry's title when viewing a list of entries, and is used in [slug](#slug) creation. If you would like to use a field other than `"title"` as the identifier, you can set `identifier_field` to the name of the other field.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- title: posts
|
||||
identifier_field: name
|
||||
```
|
||||
|
||||
### `extension` and `format`
|
||||
|
||||
These settings determine how collection files are parsed and saved. Both are optional—Static CMS will attempt to infer your settings based on existing items in the collection. If your collection is empty, or you'd like more control, you can set these fields explicitly.
|
||||
|
||||
`extension` determines the file extension searched for when finding existing entries in a folder collection and it determines the file extension used to save new collection items. It accepts the following values: `yml`, `yaml`, `toml`, `json`, `md`, `markdown`, `html`.
|
||||
|
||||
You may also specify a custom `extension` not included in the list above, as long as the collection files can be parsed and saved in one of the supported formats below.
|
||||
|
||||
`format` determines how collection files are parsed and saved. It will be inferred if the `extension` field or existing collection file extensions match one of the supported extensions above. It accepts the following values:
|
||||
|
||||
* `yml` or `yaml`: parses and saves files as YAML-formatted data files; saves with `yml` extension by default
|
||||
* `toml`: parses and saves files as TOML-formatted data files; saves with `toml` extension by default
|
||||
* `json`: parses and saves files as JSON-formatted data files; saves with `json` extension by default
|
||||
* `frontmatter`: parses files and saves files with data frontmatter followed by an unparsed body text (edited using a `body` field); saves with `md` extension by default; default for collections that can't be inferred. Collections with `frontmatter` format (either inferred or explicitly set) can parse files with frontmatter in YAML, TOML, or JSON format. However, they will be saved with YAML frontmatter.
|
||||
* `yaml-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved only as YAML, followed by unparsed body text. The default delimiter for this option is `---`.
|
||||
* `toml-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved only as TOML, followed by unparsed body text. The default delimiter for this option is `+++`.
|
||||
* `json-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved as JSON, followed by unparsed body text. The default delimiter for this option is `{` `}`.
|
||||
|
||||
### `frontmatter_delimiter`
|
||||
|
||||
If you have an explicit frontmatter format declared, this option allows you to specify a custom delimiter like `~~~`. If you need different beginning and ending delimiters, you can use an array like `["(", ")"]`.
|
||||
|
||||
### `slug`
|
||||
|
||||
For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field (a different field can be used via [`identifier_field`](#identifier_field)).
|
||||
|
||||
The slug template can also reference a field value by name, eg. `{{title}}`. If a field name conflicts with a built in template tag name - for example, if you have a field named `slug`, and would like to reference that field via `{{slug}}`, you can do so by adding the explicit `fields.` prefix, eg. `{{fields.slug}}`.
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
* Any field can be referenced by wrapping the field name in double curly braces, eg. `{{author}}`
|
||||
* `{{slug}}`: a url-safe version of the `title` field (or identifier field) for the file
|
||||
* `{{year}}`: 4-digit year of the file creation date
|
||||
* `{{month}}`: 2-digit month of the file creation date
|
||||
* `{{day}}`: 2-digit day of the month of the file creation date
|
||||
* `{{hour}}`: 2-digit hour of the file creation date
|
||||
* `{{minute}}`: 2-digit minute of the file creation date
|
||||
* `{{second}}`: 2-digit second of the file creation date
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{slug}}"
|
||||
```
|
||||
|
||||
**Example using field names:**
|
||||
|
||||
```yaml
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{title}}_{{some_other_field}}"
|
||||
```
|
||||
|
||||
**Example using field name that conflicts with a template tag:**
|
||||
|
||||
```yaml
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{fields.slug}}"
|
||||
```
|
||||
|
||||
### `fields`
|
||||
|
||||
The `fields` option maps editor UI widgets to field-value pairs in the saved file. The order of the fields in your Static CMS `config.yml` file determines their order in the editor UI and in the saved file.
|
||||
|
||||
`fields` accepts a list of collection objects, each with the following options:
|
||||
|
||||
* `name` (required): unique identifier for the field, used as the key when referenced in other contexts (like the [relation widget](/docs/widgets/#relation))
|
||||
* `label`: label for the field in the editor UI; defaults to the value of `name`
|
||||
* `widget`: defines editor UI and inputs and file field data types; details in [Widgets](/docs/widgets)
|
||||
* `default`: specify a default value for a field; available for most widget types (see [Widgets](/docs/widgets) for details on each widget type). Please note that field default value only works for folder collection type.
|
||||
* `required`: specify as `false` to make a field optional; defaults to `true`
|
||||
* `pattern`: add field validation by specifying a list with a regex pattern and an error message; more extensive validation can be achieved with [custom widgets](/docs/custom-widgets/#advanced-field-validation)
|
||||
* `comment`: optional comment to add before the field (only supported for `yaml`)
|
||||
|
||||
In files with frontmatter, one field should be named `body`. This special field represents the section of the document (usually markdown) that comes after the frontmatter.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- label: "Title"
|
||||
title: "title"
|
||||
widget: "string"
|
||||
pattern: ['.{20,}', "Must have at least 20 characters"]
|
||||
- {label: "Layout", title: "layout", widget: "hidden", default: "blog"}
|
||||
- {label: "Featured Image", title: "thumbnail", widget: "image", required: false}
|
||||
- {label: "Body", title: "body", widget: "markdown"}
|
||||
comment: 'This is a multiline\ncomment'
|
||||
```
|
||||
|
||||
### `editor`
|
||||
|
||||
This setting changes options for the editor view of a collection or a file inside a files collection. It has one option so far:
|
||||
|
||||
* `preview`: set to `false` to disable the preview pane for this collection or file; defaults to `true`
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
editor:
|
||||
preview: false
|
||||
```
|
||||
|
||||
**Note**: Setting this as a top level configuration will set the default for all collections
|
||||
|
||||
### `summary`
|
||||
|
||||
This setting allows the customization of the collection list view. Similar to the `slug` field, a string with templates can be used to include values of different fields, e.g. `{{title}}`. This option over-rides the default of `title` field and `identifier_field`.
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
Template tags are the same as those for [slug](#slug), with the following additions:
|
||||
|
||||
* `{{dirname}}` The path to the file's parent directory, relative to the collection's `folder`.
|
||||
* `{{filename}}` The file name without the extension part.
|
||||
* `{{extension}}` The file extension.
|
||||
* `{{commit_date}}` The file commit date on supported backends (git based backends).
|
||||
* `{{commit_author}}` The file author date on supported backends (git based backends).
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
summary: "Version: {{version}} - {{title}}"
|
||||
```
|
||||
|
||||
### `sortable_fields`
|
||||
|
||||
An optional list of sort fields to show in the UI.
|
||||
|
||||
Defaults to inferring `title`, `date`, `author` and `description` fields and will also show `Update On` sort field in git based backends.
|
||||
|
||||
When `author` field can't be inferred commit author will be used.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
# use dot notation for nested fields
|
||||
sortable_fields: ['commit_date', 'title', 'commit_author', 'language.en']
|
||||
```
|
||||
|
||||
### `view_filters`
|
||||
|
||||
An optional list of predefined view filters to show in the UI.
|
||||
|
||||
Defaults to an empty list.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
view_filters:
|
||||
- label: "Alice's and Bob's Posts"
|
||||
field: author
|
||||
pattern: 'Alice|Bob'
|
||||
- label: 'Posts published in 2020'
|
||||
field: date
|
||||
pattern: '2020'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
```
|
||||
|
||||
### `view_groups`
|
||||
|
||||
An optional list of predefined view groups to show in the UI.
|
||||
|
||||
Defaults to an empty list.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
# groups items based on the value matched by the pattern
|
||||
pattern: \d{4}
|
||||
- label: Drafts
|
||||
field: draft
|
||||
```
|
||||
`collections` accepts a list of collection objects. See [Collections](/docs/collection-overview) for details.
|
@ -23,10 +23,10 @@ If you have a GitHub account, you can file an [issue](https://github.com/StaticJ
|
||||
When filing an issue, it is important to remember the [Code of Conduct](https://github.com/StaticJsCMS/static-cms/blob/main/CODE_OF_CONDUCT.md).
|
||||
|
||||
## Improve existing content
|
||||
If you are able to offer up a change to existing content, we welcome this. Once you've forked the repo, and changed the content, you would file a pull request (PR). The repo [Contributing file](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md) lays out the correct format for PRs.
|
||||
If you are able to offer up a change to existing content, it is welcome. Once you've forked the repo, and changed the content, you would file a pull request (PR). The repo [Contributing file](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md) lays out the correct format for PRs.
|
||||
|
||||
## Other places to get involved
|
||||
While we work on building this page (and you can help!), here are some links with more information about getting involved:
|
||||
Here are some links with more information about getting involved:
|
||||
|
||||
* [Setup instructions and Contribution Guidelines](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md)
|
||||
* [Join our Community Chat](/chat)
|
||||
|
27
website/content/docs/custom-icons.mdx
Normal file
27
website/content/docs/custom-icons.mdx
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
group: Customization
|
||||
title: Adding Custom Icons
|
||||
weight: 100
|
||||
---
|
||||
|
||||
The Static CMS exposes a `window.CMS` global object that you can use to register custom icons via `registerIcon`. The same object is also the default export if you import Static CMS as an npm module.
|
||||
|
||||
Custom icons can be used with [Collections](/docs/collection-overview) or [Custom Links & Pages](/docs/additional-links)
|
||||
|
||||
## Params
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ------------------------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| name | string | A unique name for the icon |
|
||||
| name | [React Function Component](https://reactjs.org/docs/components-and-props.html) | A React functional component that renders the icon |
|
||||
|
||||
## Example
|
||||
|
||||
This example uses Font Awesome to supply the icon.
|
||||
|
||||
```js
|
||||
import { faHouse } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
cmsApp.registerIcon('house', <FontAwesomeIcon icon={faHouse} size="lg" />);
|
||||
```
|
157
website/content/docs/custom-previews.mdx
Normal file
157
website/content/docs/custom-previews.mdx
Normal file
@ -0,0 +1,157 @@
|
||||
---
|
||||
group: Customization
|
||||
title: Creating Custom Previews
|
||||
weight: 50
|
||||
---
|
||||
|
||||
The Static CMS exposes a `window.CMS` global object that you can use to register custom previews for an entire collection (or file within a file collection) via `registerPreviewTemplate`.
|
||||
|
||||
### React Components Inline
|
||||
|
||||
The `registerPreviewTemplate` requires you to provide a React component. If you have a build process in place for your project, it is possible to integrate with this build process.
|
||||
|
||||
However, although possible, it may be cumbersome or even impractical to add a React build phase. For this reason, Static CMS exposes some constructs globally to allow you to create components inline: `h` (alias for React.createElement) as well some basic hooks (`useState`, `useMemo`, `useEffect`, `useCallback`).
|
||||
|
||||
**NOTE**: `createClass` is still provided, allowing for the creation of react class components. However it has now been deprecated and will be removed in `v2.0.0`.
|
||||
|
||||
## Params
|
||||
|
||||
| Param | Type | Description |
|
||||
| --------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| name | string | The name of the collection (or file for file collections) which this preview component will be used for<br /><ul><li>Folder collections: Use the name of the collection</li><li>File collections: Use the name of the file</li></ul> |
|
||||
| react_component | [React Function Component](https://reactjs.org/docs/components-and-props.html) | A React functional component that renders the collection data. |
|
||||
|
||||
The following parameters will be passed to your `react_component` during render:
|
||||
|
||||
| Param | Type | Description |
|
||||
| ---------- | -------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| entry | object | Object with a `data` field that contains the current value of all widgets in the editor |
|
||||
| document | Document | The document object the preview is within. If rendered with a frame, it will be the frame's document |
|
||||
| window | Window | The window object the preview is within. If rendered with a frame, it will be the frame's window |
|
||||
| getAsset | Async function | Function that given a url returns (as a promise) a loaded asset |
|
||||
| widgetFor | Function | Given a field name, returns the rendered preview of that field's widget and value |
|
||||
| widgetsFor | Function | Given a field name, returns the rendered previews of that field's nested child widgets and values |
|
||||
|
||||
### Example
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E1.0.0/dist/static-cms-core.js"></script>
|
||||
<script>
|
||||
const PostPreview = ({ widgetFor, getAsset, entry }) => {
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const image = useMemo(() => entry.data.image, [entry.data.image]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const loadImage = async () => {
|
||||
const imageAsset = await getAsset(image);
|
||||
if (alive) {
|
||||
setImageUrl(imageAsset.toString());
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [image]);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, entry.data.title),
|
||||
h('img', { src: imageUrl }),
|
||||
h('div', { classtitle: 'text' }, widgetFor('body')),
|
||||
);
|
||||
});
|
||||
|
||||
CMS.registerPreviewTemplate('posts', PostPreview);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Lists and Objects
|
||||
|
||||
The API for accessing the individual fields of list- and object-type entries is similar to the API for accessing fields in standard entries, but there are a few key differences. Access to these nested fields is facilitated through the `widgetsFor` function, which is passed to the preview template component during render.
|
||||
|
||||
**List Example:**
|
||||
|
||||
```html
|
||||
<script>
|
||||
// For list fields, the widgetFor function returns an array of objects
|
||||
// that you can map over in your template. If our field is a list of
|
||||
// authors containing two entries, with fields `name` and `description`,
|
||||
// the return value of `widgetsFor` would look like this:
|
||||
//
|
||||
// [{
|
||||
// data: { title: 'Mathias', description: 'Co-Founder'},
|
||||
// widgets: { title: (<WidgetComponent>), description: (WidgetComponent>)}
|
||||
// },
|
||||
// {
|
||||
// data: { title: 'Chris', description: 'Co-Founder'},
|
||||
// widgets: { title: (<WidgetComponent>), description: (WidgetComponent>)}
|
||||
// }]
|
||||
//
|
||||
// Templating would look something like this:
|
||||
const AuthorsPreview = ({ widgetsFor }) => {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
|
||||
// This is a static header that would only be rendered once for the entire list
|
||||
h('h1', {}, 'Authors'),
|
||||
|
||||
// Here we provide a simple mapping function that will be applied to each
|
||||
// object in the array of authors
|
||||
widgetsFor('authors').map(function (author, index) {
|
||||
return h(
|
||||
'div',
|
||||
{ key: index },
|
||||
h('hr', {}),
|
||||
h('strong', {}, author.data.name),
|
||||
author.widgets.description,
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
CMS.registerPreviewTemplate('authors', AuthorsPreview);
|
||||
</script>
|
||||
```
|
||||
|
||||
**Object Example:**
|
||||
|
||||
```html
|
||||
<script>
|
||||
// Object fields are simpler than lists - instead of `widgetsFor` returning
|
||||
// an array of objects, it returns a single object. Accessing the shape of
|
||||
// that object is the same as the shape of objects returned for list fields:
|
||||
//
|
||||
// {
|
||||
// data: { front_limit: 0, author: 'Chris' },
|
||||
// widgets: { front_limit: (<WidgetComponent>), author: (WidgetComponent>)}
|
||||
// }
|
||||
const GeneralPreview = ({ entry, widgetsFor }) => {
|
||||
const title = entry.data.site_title;
|
||||
const posts = entry.data.posts;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, title),
|
||||
h(
|
||||
'dl',
|
||||
{},
|
||||
h('dt', {}, 'Posts on Frontpage'),
|
||||
h('dd', {}, widgetsFor('posts').widgets.front_limit || 0),
|
||||
|
||||
h('dt', {}, 'Default Author'),
|
||||
h('dd', {}, widgetsFor('posts').data.author || 'None'),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
CMS.registerPreviewTemplate('general', GeneralPreview);
|
||||
</script>
|
||||
```
|
@ -1,20 +1,20 @@
|
||||
---
|
||||
group: Widgets
|
||||
group: Customization
|
||||
title: Creating Custom Widgets
|
||||
weight: 50
|
||||
weight: 40
|
||||
---
|
||||
The Static CMS exposes a `window.CMS` a global object that you can use to register custom widgets, previews, and editor plugins. The same object is also the default export if you import Static CMS as an npm module. The available widget extension methods are:
|
||||
|
||||
* **registerWidget:** registers a custom widget.
|
||||
* **registerEditorComponent:** adds a block component to the Markdown editor.
|
||||
The Static CMS exposes a `window.CMS` global object that you can use to register custom widgets via `registerWidget`. The same object is also the default export if you import Static CMS as an npm module.
|
||||
|
||||
### Writing React Components inline
|
||||
### React Components Inline
|
||||
|
||||
The `registerWidget` requires you to provide a React component. If you have a build process in place for your project, it is possible to integrate with this build process.
|
||||
The `registerPreviewTemplate` requires you to provide a React component. If you have a build process in place for your project, it is possible to integrate with this build process.
|
||||
|
||||
However, although possible, it may be cumbersome or even impractical to add a React build phase. For this reason, Static CMS exposes two constructs globally to allow you to create components inline: `createClass` and `h` (alias for React.createElement).
|
||||
However, although possible, it may be cumbersome or even impractical to add a React build phase. For this reason, Static CMS exposes some constructs globally to allow you to create components inline: `h` (alias for React.createElement) as well some basic hooks (`useState`, `useMemo`, `useEffect`, `useCallback`).
|
||||
|
||||
## `registerWidget`
|
||||
**NOTE**: `createClass` is still provided, allowing for the creation of react class components. However it has now been deprecated and will be removed in `v2.0.0`.
|
||||
|
||||
## Register Widget
|
||||
|
||||
Register a custom widget.
|
||||
|
||||
@ -27,58 +27,139 @@ import CMS from '@staticcms/core';
|
||||
CMS.registerWidget(name, control, [preview], [schema]);
|
||||
```
|
||||
|
||||
**Params:**
|
||||
### Params
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | `string` | Widget name, allows this widget to be used via the field `widget` property in config |
|
||||
| `control` | `React.Component` or `string` | <ul><li>React component that renders the control, receives the following props: <ul><li>**value:** Current field value</li><li>**field:** Immutable map of current field configuration</li><li>**forID:** Unique identifier for the field</li><li>**classNameWrapper:** class name to apply CMS styling to the field</li><li>**onChange:** Callback function to update the field value</li></ul></li><li>Name of a registered widget whose control should be used (includes built in widgets).</li></ul> |
|
||||
| [`preview`] | `React.Component`, optional | Renders the widget preview, receives the following props: <ul><li>**value:** Current preview value</li><li>**field:** Immutable map of current field configuration</li><li>**getAsset:** Function for retrieving an asset url for image/file fields</li><li>**entry:** Immutable Record of all entry data</li></ul> |
|
||||
| [`schema`] | `JSON Schema object`, optional | Enforces a schema for the widget's field configuration |
|
||||
| Param | Type | Description |
|
||||
| ------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| name | string | Widget name, allows this widget to be used via the field `widget` property in config |
|
||||
| control | [React Function Component](https://reactjs.org/docs/components-and-props.html)<br />\| string | <ul><li>`React Function Component` - The react component that renders the control. See [Control Component](#control-component)</li><li>`string` - Name of a registered widget whose control should be used (includes built in widgets).</li></ul> |
|
||||
| preview | [React Function Component](https://reactjs.org/docs/components-and-props.html) | _Optional_. Renders the widget preview. See [Preview Component](#preview-component) |
|
||||
| options | object | _Optional_. Widget options. See [Options](#options) |
|
||||
|
||||
**Example:**
|
||||
### Control Component
|
||||
|
||||
The react component that renders the control. It receives the following props:
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| label | string | The label for the widget |
|
||||
| value | An valid widget value | The current value of the widget |
|
||||
| onChange | function | Function to be called when the value changes. Accepts a valid widget value |
|
||||
| field | object | The field configuration for the current widget. See [Widget Options](/docs/widgets#common-widget-options) |
|
||||
| collection | object | The collection configuration for the current widget. See [Collections](/docs/collection-overview) |
|
||||
| config | object | The current Static CMS config. See [configuration options](/docs/configuration-options) |
|
||||
| entry | object | Object with a `data` field that contains the current value of all widgets in the editor |
|
||||
| path | string | `.` separated string donating the path to the current widget within the entry |
|
||||
| hasErrors | boolean | Specifies if there are validation errors with the current widget |
|
||||
| fieldsErrors | object | Key/value object of field names mapping to validation errors |
|
||||
| isDisabled | boolean | Specifies if the widget control should be disabled |
|
||||
| submitted | boolean | Specifies if a save attempt has been made in the editor session |
|
||||
| forList | boolean | Specifices if the widget is within a `list` widget |
|
||||
| isFieldDuplicate | function | Function that given a field configuration, returns if that field is a duplicate |
|
||||
| isFieldHidden | function | Function that given a field configuration, returns if that field is hidden |
|
||||
| getAsset | Async function | Function that given a url returns (as a promise) a loaded asset |
|
||||
| locale | string<br />\| undefined | The current locale of the editor |
|
||||
| mediaPaths | object | Key/value object of control IDs (passed to the media library) mapping to media paths |
|
||||
| clearMediaControl | function | Clears a control ID's value from the internal store |
|
||||
| openMediaLibrary | function | Opens the media library popup. See [Open Media Library](#open-media-library) |
|
||||
| removeInsertedMedia | function | Removes draft media for a give control ID |
|
||||
| removeMediaControl | function | Clears a control ID completely from the internal store |
|
||||
| query | function | Runs a search on another collection. See [Query](#query) |
|
||||
| i18n | object | The current i18n settings |
|
||||
| t | function | Translates a given key to the current locale |
|
||||
|
||||
#### Open Media Library
|
||||
|
||||
`openMediaLibrary` allows you to open up the media library popup. It accepts the following props:
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| ------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| controlID | string | | _Optional_ A unique identifier to which the uploaded media will be linked |
|
||||
| forImage | boolean | `false` | _Optional_ If `true`, restricts upload to image files only |
|
||||
| value | string<br />list of strings | | _Optional_ The current selected media value |
|
||||
| allowMultiple | boolean | | _Optional_ Allow multiple files or images to be uploaded at once. Only used on media libraries that support multi upload |
|
||||
| replaceIndex | number | | _Optional_ The index of the image in an list. Ignored if ` allowMultiple` is `false` |
|
||||
| config | object | | _Optional_ Media library config options. Available options depend on the media library being used |
|
||||
| field | object | | _Optional_ The current field configuration |
|
||||
|
||||
#### Query
|
||||
|
||||
`query` allows you to search the entries of a given collection. It accepts the following props:
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| -------------- | --------------- | ------- | -------------------------------------------------------------------------------------- |
|
||||
| namespace | string | | Unique identifier for search |
|
||||
| collectionName | string | | The collection to be searched |
|
||||
| searchFields | list of strings | | The Fields to be searched within the target collection |
|
||||
| searchTerm | string | | The term to search with |
|
||||
| file | string | | _Optional_ The file in a file collection to search. Ignored on folder collections |
|
||||
| limit | string | | _Optional_ The number of results to return. If not specified, all results are returned |
|
||||
|
||||
### Preview Component
|
||||
|
||||
The react component that renders the preview. It receives the following props:
|
||||
|
||||
| Param | Type | Description |
|
||||
| ---------- | --------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| value | An valid widget value | The current value of the widget |
|
||||
| field | object | The field configuration for the current widget. See [Widget Options](/docs/widgets#common-widget-options) |
|
||||
| collection | object | The collection configuration for the current widget. See [Collections](/docs/collection-overview) |
|
||||
| config | object | The current Static CMS config. See [configuration options](/docs/configuration-options) |
|
||||
| entry | object | Object with a `data` field that contains the current value of all widgets in the editor |
|
||||
| getAsset | Async function | Function that given a url returns (as a promise) a loaded asset |
|
||||
|
||||
### Options
|
||||
|
||||
Register widget takes an optional object of options. These options include:
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| validator | function | _Optional_. Validates the value of the widget |
|
||||
| getValidValue | string | _Optional_. Given the current value, returns a valid value. See [Advanced field validation](#advanced-field-validation) |
|
||||
| schema | JSON Schema object | _Optional_. Enforces a schema for the widget's field configuration |
|
||||
|
||||
### Example
|
||||
|
||||
`admin/index.html`
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E0.1.0/dist/static-cms-core.js"></script>
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E1.0.0/dist/static-cms-core.js"></script>
|
||||
<script>
|
||||
var CategoriesControl = createClass({
|
||||
handleChange: function(e) {
|
||||
const separator = this.props.field.get('separator', ', ')
|
||||
this.props.onChange(e.target.value.split(separator).map((e) => e.trim()));
|
||||
},
|
||||
const CategoriesControl = ({ label, value, field, onChange }) => {
|
||||
const separator = useMemo(() => field.separator ?? ', ', [field.separator]);
|
||||
|
||||
render: function() {
|
||||
const separator = this.props.field.get('separator', ', ');
|
||||
var value = this.props.value;
|
||||
return h('input', {
|
||||
id: this.props.forID,
|
||||
classtitle: this.props.classNameWrapper,
|
||||
type: 'text',
|
||||
value: value ? value.join(separator) : '',
|
||||
onChange: this.handleChange,
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleChange = useCallback((e) => {
|
||||
onChange(e.target.value.split(separator).map(e => e.trim()));
|
||||
}, [separator, onChange]);
|
||||
|
||||
var CategoriesPreview = createClass({
|
||||
render: function() {
|
||||
return h('ul', {},
|
||||
this.props.value.map(function(val, index) {
|
||||
return h('li', {key: index}, val);
|
||||
return h('div', {}, {
|
||||
h('label', { for: 'inputId' }, label),
|
||||
h('input', {
|
||||
id: 'inputId',
|
||||
type: 'text',
|
||||
value: value ? value.join(separator) : '',
|
||||
onChange: this.handleChange,
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const CategoriesPreview = ({ value }) => {
|
||||
return h(
|
||||
'ul',
|
||||
{},
|
||||
value.map(function (val, index) {
|
||||
return h('li', { key: index }, val);
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var schema = {
|
||||
properties: {
|
||||
separator: { type: 'string' },
|
||||
},
|
||||
}
|
||||
const schema = {
|
||||
properties: {
|
||||
separator: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
CMS.registerWidget('categories', CategoriesControl, CategoriesPreview, schema);
|
||||
CMS.registerWidget('categories', CategoriesControl, CategoriesPreview, options: { schema });
|
||||
</script>
|
||||
```
|
||||
|
||||
@ -86,478 +167,71 @@ CMS.registerWidget('categories', CategoriesControl, CategoriesPreview, schema);
|
||||
|
||||
```yml
|
||||
collections:
|
||||
- title: posts
|
||||
- name: posts
|
||||
label: Posts
|
||||
folder: content/posts
|
||||
fields:
|
||||
- title: title
|
||||
- name: title
|
||||
label: Title
|
||||
widget: string
|
||||
- title: categories
|
||||
- name: categories
|
||||
label: Categories
|
||||
widget: categories
|
||||
separator: __
|
||||
```
|
||||
|
||||
## `registerEditorComponent`
|
||||
|
||||
Register a block level component for the Markdown editor:
|
||||
|
||||
```js
|
||||
CMS.registerEditorComponent(definition)
|
||||
```
|
||||
|
||||
**Params**
|
||||
|
||||
* **definition:** The component definition; must specify: id, label, fields, patterns, fromBlock, toBlock, toPreview
|
||||
|
||||
> Additional properties are optional and will be passed to the underlying widget control (object widget by default). For example, adding a `collapsed: true` property will collapse the widget by default.
|
||||
|
||||
**Example:**
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E0.1.0/dist/static-cms-core.js"></script>
|
||||
<script>
|
||||
CMS.registerEditorComponent({
|
||||
// Internal id of the component
|
||||
id: "collapsible-note",
|
||||
// Visible label
|
||||
label: "Collapsible Note",
|
||||
// Fields the user need to fill out when adding an instance of the component
|
||||
fields: [
|
||||
{
|
||||
title: 'summary',
|
||||
label: 'Summary',
|
||||
widget: 'string'
|
||||
},
|
||||
{
|
||||
title: 'details',
|
||||
label: 'Details',
|
||||
widget: 'markdown'
|
||||
}
|
||||
],
|
||||
// Regex pattern used to search for instances of this block in the markdown document.
|
||||
// Patterns are run in a multline environment (against the entire markdown document),
|
||||
// and so generally should make use of the multiline flag (`m`). If you need to capture
|
||||
// newlines in your capturing groups, you can either use something like
|
||||
// `([\S\s]*)`, or you can additionally enable the "dot all" flag (`s`),
|
||||
// which will cause `(.*)` to match newlines as well.
|
||||
//
|
||||
// Additionally, it's recommended that you use non-greedy capturing groups (e.g.
|
||||
// `(.*?)` vs `(.*)`), especially if matching against newline characters.
|
||||
pattern: /^<details>$\s*?<summary>(.*?)<\/summary>\n\n(.*?)\n^<\/details>$/ms,
|
||||
// Given a RegExp Match object
|
||||
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match#return_value),
|
||||
// return an object with one property for each field defined in `fields`.
|
||||
//
|
||||
// This is used to populate the custom widget in the markdown editor in the CMS.
|
||||
fromBlock: function(match) {
|
||||
return {
|
||||
summary: match[1],
|
||||
detail: match[2]
|
||||
};
|
||||
},
|
||||
// Given an object with one property for each field defined in `fields`,
|
||||
// return the string you wish to be inserted into your markdown.
|
||||
//
|
||||
// This is used to serialize the data from the custom widget to the
|
||||
// markdown document
|
||||
toBlock: function(data) {
|
||||
return `
|
||||
<details>
|
||||
<summary>${data.summary}</summary>
|
||||
|
||||
${data.detail}
|
||||
|
||||
</details>
|
||||
`;
|
||||
},
|
||||
// Preview output for this component. Can either be a string or a React component
|
||||
// (component gives better render performance)
|
||||
toPreview: function(data) {
|
||||
return `
|
||||
<details>
|
||||
<summary>${data.summary}</summary>
|
||||
|
||||
${data.detail}
|
||||
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Result:**
|
||||
|
||||
![youtube-widget](/img/youtube-widget.webp)
|
||||
|
||||
## Advanced field validation
|
||||
|
||||
All widget fields, including those for built-in widgets, [include basic validation](/docs/widgets/#common-widget-options) capability using the `required` and `pattern` options.
|
||||
|
||||
With custom widgets, the widget control can also optionally implement an `isValid` method to perform custom validations, in addition to presence and pattern. The `isValid` method will be automatically called, and it can return either a boolean value, an object with an error message or a promise. Examples:
|
||||
With custom widgets, the widget can also optionally pass in a `validator` method to perform custom validations, in addition to presence and pattern. The `validator` function will be automatically called, and it can return either a `boolean` value, an `object` with a type and error message or a promise.
|
||||
|
||||
**Boolean**
|
||||
No errors:
|
||||
### Examples
|
||||
|
||||
#### No Errors
|
||||
|
||||
```javascript
|
||||
isValid = () => {
|
||||
// Do internal validation
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
Existing error:
|
||||
|
||||
```javascript
|
||||
isValid = () => {
|
||||
// Do internal validation
|
||||
return false;
|
||||
};
|
||||
```
|
||||
|
||||
**Object with `error` (useful for returning custom error messages)**
|
||||
Existing error:
|
||||
|
||||
```javascript
|
||||
isValid = () => {
|
||||
// Do internal validation
|
||||
return { error: { message: 'Your error message.' } };
|
||||
};
|
||||
```
|
||||
|
||||
**Promise**
|
||||
You can also return a promise from `isValid`. While the promise is pending, the widget will be marked as "in error". When the promise resolves, the error is automatically cleared.
|
||||
|
||||
```javascript
|
||||
isValid = () => {
|
||||
return this.existingPromise;
|
||||
};
|
||||
```
|
||||
|
||||
**Note:** Do not create a promise inside `isValid` - `isValid` is called right before trying to persist. This means that even if a previous promise was already resolved, when the user hits 'save', `isValid` will be called again. If it returns a new promise, it will be immediately marked as "in error" until the new promise resolves.
|
||||
|
||||
## Writing custom widgets as a separate package
|
||||
|
||||
Widgets are inputs for the Static CMS editor interface. It's a React component that receives user input and outputs a serialized value. Those are the only rules - the component can be extremely simple, like text input, or extremely complicated, like a full-blown markdown editor. They can make calls to external services, and generally do anything that JavaScript can do.
|
||||
|
||||
For writing custom widgets as a separate package you should follow these steps:
|
||||
|
||||
1. Create a directory
|
||||
|
||||
```javascript
|
||||
mkdir my-custom-widget
|
||||
```
|
||||
2. Navigate to the directory
|
||||
|
||||
```javascript
|
||||
cd my-custom-widget
|
||||
```
|
||||
3. For setting up a new npm package run this command:
|
||||
|
||||
```javascript
|
||||
npm init
|
||||
```
|
||||
4. Answer the questions in the command line questionnaire.
|
||||
5. In order to build React components, we need to set up a build step. We'll be using Webpack. Please run the following commands to install the required dependencies:
|
||||
|
||||
```javascript
|
||||
npm install --save-dev @staticcms/core babel-loader@7 babel-core babel-plugin-transform-class-properties babel-plugin-transform-export-extensions babel-plugin-transform-object-rest-spread babel-preset-env babel-preset-react cross-env css-loader html-webpack-plugin react source-map-loader style-loader webpack webpack-cli webpack-serve
|
||||
```
|
||||
|
||||
```javascript
|
||||
npm install --save prop-types
|
||||
```
|
||||
|
||||
And you should manually add "**peerDependencies**" and "**scripts**" as shown below.
|
||||
|
||||
Here is the content of `package.json` that you will have at the end:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"name": "static-cms-widget-starter",
|
||||
"description": "A boilerplate for creating Static CMS widgets.",
|
||||
"author": "name of developer",
|
||||
"keywords": [
|
||||
"simple",
|
||||
"static-cms",
|
||||
"cms",
|
||||
"widget",
|
||||
"starter",
|
||||
"boilerplate"
|
||||
],
|
||||
"version": "0.0.1",
|
||||
"homepage": "https://github.com/StaticJsCMS/static-cms-widget-starter",
|
||||
"license": "MIT",
|
||||
"main": "dist/main.js",
|
||||
"devDependencies": {
|
||||
"@staticcms/core": "^0.1.0",
|
||||
"babel-loader": "^7.1.4",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-export-extensions": "^6.22.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-env": "^1.6.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"cross-env": "^5.1.4",
|
||||
"css-loader": "^0.28.11",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"react": "^16.3.2",
|
||||
"source-map-loader": "^0.2.3",
|
||||
"style-loader": "^0.20.3",
|
||||
"webpack": "^4.6.0",
|
||||
"webpack-cli": "^2.0.14",
|
||||
"webpack-serve": "^0.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"prop-types": "^15.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack-serve --static public --open"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. Create a Webpack configuration file with this content:
|
||||
|
||||
`webpack.config.js`
|
||||
|
||||
```javascript
|
||||
const path = require('path')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
|
||||
const developmentConfig = {
|
||||
mode: 'development',
|
||||
entry: './dev/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'public'),
|
||||
},
|
||||
optimization: { minimize: false },
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'source-map-loader',
|
||||
enforce: 'pre',
|
||||
},
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin(),
|
||||
],
|
||||
devtool: 'eval-source-map',
|
||||
}
|
||||
|
||||
const productionConfig = {
|
||||
mode: 'production',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
devtool: 'source-map',
|
||||
}
|
||||
|
||||
module.exports = process.env.NODE_ENV === 'production' ? productionConfig : developmentConfig
|
||||
```
|
||||
6. The `.babelrc` file is our local configuration for our code in the project. You should create it under the root of the application repo. It will affect all files that Babel processes. So, create a `.babelrc` file under the main project with this content:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"presets": [
|
||||
"react",
|
||||
"env",
|
||||
],
|
||||
"plugins": [
|
||||
"transform-export-extensions",
|
||||
"transform-class-properties",
|
||||
"transform-object-rest-spread",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
7. Create a `src` directory with the files `Control.js`, `Preview.js` and `index.js`
|
||||
|
||||
`src/Control.js`
|
||||
|
||||
```javascript
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default class Control extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
forID: PropTypes.string,
|
||||
value: PropTypes.node,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
value: '',
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
forID,
|
||||
value,
|
||||
onChange,
|
||||
classNameWrapper,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
id={forID}
|
||||
className={classNameWrapper}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`src/Preview.js`
|
||||
|
||||
```javascript
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default function Preview({ value }) {
|
||||
return <div>{ value }</div>;
|
||||
}
|
||||
|
||||
Preview.propTypes = {
|
||||
value: PropTypes.node,
|
||||
const validator = () => {
|
||||
// Do internal validation
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
`src/index.js`
|
||||
#### Has Error
|
||||
|
||||
```javascript
|
||||
import Control from './Control'
|
||||
import Preview from './Preview'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.Control = Control
|
||||
window.Preview = Preview
|
||||
}
|
||||
|
||||
export { Control, Preview }
|
||||
const validator = () => {
|
||||
// Do internal validation
|
||||
return false;
|
||||
};
|
||||
```
|
||||
|
||||
8. Now you need to set up the locale example site.
|
||||
Under the main project, create a `dev` directory with the files `bootstrap.js` and `index.js`
|
||||
|
||||
`bootstrap.js`
|
||||
#### Error With Type
|
||||
|
||||
```javascript
|
||||
window.CMS_MANUAL_INIT = true
|
||||
const validator = () => {
|
||||
// Do internal validation
|
||||
return { type: 'custom-error' };
|
||||
};
|
||||
```
|
||||
|
||||
`index.js`
|
||||
#### Error With Type and Message
|
||||
|
||||
_Useful for returning custom error messages_
|
||||
|
||||
```javascript
|
||||
import './bootstrap.js'
|
||||
import CMS, { init } from '@staticcms/core'
|
||||
import { Control, Preview } from '/docs/src'
|
||||
|
||||
const config = {
|
||||
backend: {
|
||||
title: 'test-repo',
|
||||
login: false,
|
||||
},
|
||||
media_folder: 'assets',
|
||||
collections: [{
|
||||
title: 'test',
|
||||
label: 'Test',
|
||||
files: [{
|
||||
file: 'test.yml',
|
||||
title: 'test',
|
||||
label: 'Test',
|
||||
fields: [
|
||||
{ title: 'test_widget', label: 'Test Widget', widget: 'test'},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
CMS.registerWidget('test', Control, Preview)
|
||||
|
||||
init({ config })
|
||||
const validator = () => {
|
||||
// Do internal validation
|
||||
return { type: 'custom-error', message: 'Your error message.' };
|
||||
};
|
||||
```
|
||||
|
||||
### [](https://github.com/StaticJsCMS/static-cms-widget-starter#development)Development
|
||||
#### Promise
|
||||
|
||||
To run a copy of Static CMS with your widget for development, use the start script:
|
||||
You can also return a promise from `validator`. The promise can return `boolean` value, an `object` with a type and error message or a promise.
|
||||
|
||||
```javascript
|
||||
npm start
|
||||
```
|
||||
|
||||
Your widget source is in the `src` directory, where there are separate files for the `Control` and `Preview` components.
|
||||
|
||||
### [](https://github.com/StaticJsCMS/static-cms-widget-starter#production--publishing)Production & Publishing
|
||||
|
||||
You'll want to take a few steps before publishing a production built package to npm:
|
||||
|
||||
1. Customize `package.json` with details for your specific widget, e.g. name, description, author, version, etc.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "static-cms-widget-starter",
|
||||
"description": "A boilerplate for creating Static CMS widgets.",
|
||||
"author": "name of developer",
|
||||
"keywords": [
|
||||
"simple",
|
||||
"static-cms",
|
||||
"cms",
|
||||
"widget",
|
||||
"starter",
|
||||
"boilerplate"
|
||||
],
|
||||
"version": "0.0.1",
|
||||
// ... rest
|
||||
}
|
||||
```
|
||||
2. For discoverability, ensure that your package name follows the pattern `static-cms-widget-<name>`.
|
||||
3. Delete this `README.md`, rename `README_TEMPLATE.md` to `README.md`, and update the new file for your specific widget.
|
||||
4. Rename the exports in `src/index.js`. For example, if your widget is `static-cms-widget-awesome`, you would do:
|
||||
|
||||
```javascript
|
||||
if (typeof window !== 'undefined') {
|
||||
window.AwesomeControl = Control
|
||||
window.AwesomePreview = Preview
|
||||
}
|
||||
|
||||
export { Control as AwesomeControl, Preview as AwesomePreview }
|
||||
```
|
||||
|
||||
5. Optional: customize the component and file names in `src`.
|
||||
6. If you haven't already, push your repo to your GitHub account so the source available to other developers.
|
||||
7. Create a production build, which will be output to `dist`:
|
||||
|
||||
```javascript
|
||||
npm run build
|
||||
```
|
||||
|
||||
8. Finally, if you're sure things are tested and working, publish!
|
||||
|
||||
```javascript
|
||||
npm publish
|
||||
const validator = () => {
|
||||
return this.existingPromise;
|
||||
};
|
||||
```
|
||||
|
13
website/content/docs/customization-overview.mdx
Normal file
13
website/content/docs/customization-overview.mdx
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
group: Customization
|
||||
title: Overview
|
||||
weight: 1
|
||||
---
|
||||
|
||||
The Static CMS exposes a `window.CMS` global object that you can use to customize your CMS. The same object is also the default export if you import Static CMS as an npm module. Available options are:
|
||||
|
||||
- Register [custom widgets](/docs/custom-widgets)
|
||||
- Register [custom previews](/docs/custom-previews)
|
||||
- Register [editor customizations](/docs/widget-markdown#customization)
|
||||
- Register [additional menu links or custom pages](/docs/additional-links)
|
||||
- Register [custom icons](/docs/custom-icons)
|
@ -1,147 +0,0 @@
|
||||
---
|
||||
group: Customization
|
||||
title: Creating Custom Previews
|
||||
weight: 50
|
||||
---
|
||||
|
||||
The Static CMS exposes a `window.CMS` global object that you can use to register custom widgets, previews and editor plugins. The available customization methods are:
|
||||
|
||||
- **registerPreviewTemplate:** Registers a template for a collection.
|
||||
|
||||
### React Components inline interaction
|
||||
|
||||
Static CMS is a collection of React components and exposes two constructs globally to allow you to create components inline: `createClass` and `h` (alias for React.createElement).
|
||||
|
||||
## `registerPreviewTemplate`
|
||||
|
||||
Registers a template for a folder collection or an individual file in a file collection.
|
||||
|
||||
`CMS.registerPreviewTemplate(name, react_component);`
|
||||
|
||||
**Params:**
|
||||
|
||||
- title: The name of the collection (or file for file collections) which this preview component will be used for.
|
||||
- Folder collections: Use the name of the collection
|
||||
- File collections: Use the name of the file
|
||||
- react_component: A React component that renders the collection data. Six props will be passed to your component during render:
|
||||
|
||||
- entry: Immutable collection containing the entry data.
|
||||
- widgetFor: Returns the appropriate widget preview component for a given field.
|
||||
- [widgetsFor](#lists-and-objects): Returns an array of objects with widgets and associated field data. For use with list and object type entries.
|
||||
- getAsset: Returns the correct filePath or in-memory preview for uploaded images.
|
||||
**Example:**
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E0.1.0/dist/static-cms-core.js"></script>
|
||||
<script>
|
||||
var PostPreview = createClass({
|
||||
render: function () {
|
||||
var entry = this.props.entry;
|
||||
var image = entry.data.image;
|
||||
var bg = this.props.getAsset(image);
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, entry.data.title),
|
||||
h('img', { src: bg.toString() }),
|
||||
h('div', { classtitle: 'text' }, this.props.widgetFor('body')),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
CMS.registerPreviewTemplate('posts', PostPreview);
|
||||
</script>
|
||||
```
|
||||
|
||||
- document: The preview pane iframe's [document instance](https://github.com/ryanseddon/react-frame-component/tree/9f8f06e1d3fc40da7122f0a57c62f7dec306e6cb#accessing-the-iframes-window-and-document).
|
||||
- window: The preview pane iframe's [window instance](https://github.com/ryanseddon/react-frame-component/tree/9f8f06e1d3fc40da7122f0a57c62f7dec306e6cb#accessing-the-iframes-window-and-document).
|
||||
|
||||
### Lists and Objects
|
||||
|
||||
The API for accessing the individual fields of list- and object-type entries is similar to the API for accessing fields in standard entries, but there are a few key differences. Access to these nested fields is facilitated through the `widgetsFor` function, which is passed to the preview template component during render.
|
||||
**Note**: as is often the case with the Static CMS API, arrays and objects are created with Immutable.js. If some of the methods that we use are unfamiliar, such as `getIn`, check out [their docs](https://facebook.github.io/immutable-js/docs/#/) to get a better understanding.
|
||||
**List Example:**
|
||||
|
||||
```html
|
||||
<script>
|
||||
var AuthorsPreview = createClass({
|
||||
// For list fields, the widgetFor function returns an array of objects
|
||||
// that you can map over in your template. If our field is a list of
|
||||
// authors containing two entries, with fields `name` and `description`,
|
||||
// the return value of `widgetsFor` would look like this:
|
||||
//
|
||||
// [{
|
||||
// data: { title: 'Mathias', description: 'Co-Founder'},
|
||||
// widgets: { title: (<WidgetComponent>), description: (WidgetComponent>)}
|
||||
// },
|
||||
// {
|
||||
// data: { title: 'Chris', description: 'Co-Founder'},
|
||||
// widgets: { title: (<WidgetComponent>), description: (WidgetComponent>)}
|
||||
// }]
|
||||
//
|
||||
// Templating would look something like this:
|
||||
|
||||
render: function () {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
|
||||
// This is a static header that would only be rendered once for the entire list
|
||||
h('h1', {}, 'Authors'),
|
||||
|
||||
// Here we provide a simple mapping function that will be applied to each
|
||||
// object in the array of authors
|
||||
this.props.widgetsFor('authors').map(function (author, index) {
|
||||
return h(
|
||||
'div',
|
||||
{ key: index },
|
||||
h('hr', {}),
|
||||
h('strong', {}, author.data.name),
|
||||
author.widgets.description,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
CMS.registerPreviewTemplate('authors', AuthorsPreview);
|
||||
</script>
|
||||
```
|
||||
|
||||
**Object Example:**
|
||||
|
||||
```html
|
||||
<script>
|
||||
var GeneralPreview = createClass({
|
||||
// Object fields are simpler than lists - instead of `widgetsFor` returning
|
||||
// an array of objects, it returns a single object. Accessing the shape of
|
||||
// that object is the same as the shape of objects returned for list fields:
|
||||
//
|
||||
// {
|
||||
// data: { front_limit: 0, author: 'Chris' },
|
||||
// widgets: { front_limit: (<WidgetComponent>), author: (WidgetComponent>)}
|
||||
// }
|
||||
render: function () {
|
||||
var entry = this.props.entry;
|
||||
var title = entry.data.site_title;
|
||||
var posts = entry.data.posts;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('h1', {}, title),
|
||||
h(
|
||||
'dl',
|
||||
{},
|
||||
h('dt', {}, 'Posts on Frontpage'),
|
||||
h('dd', {}, this.props.widgetsFor('posts').widgets.front_limit || 0),
|
||||
|
||||
h('dt', {}, 'Default Author'),
|
||||
h('dd', {}, this.props.widgetsFor('posts').data.author || 'None'),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
CMS.registerPreviewTemplate('general', GeneralPreview);
|
||||
</script>
|
||||
```
|
@ -95,7 +95,7 @@ Your website is now deployed. Netlify provides you with a randomly generated dom
|
||||
- foo
|
||||
- bar
|
||||
authors:
|
||||
- title: Garrison McMullen
|
||||
- name: Garrison McMullen
|
||||
title: Instruction Writer
|
||||
url: https://github.com/garrison0
|
||||
image_url: https://avatars.githubusercontent.com/u/4089393?v=4
|
||||
@ -127,7 +127,7 @@ Your website is now deployed. Netlify provides you with a randomly generated dom
|
||||
</head>
|
||||
<body>
|
||||
<!-- Include the script that builds the page and powers Static CMS -->
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E0.1.0/dist/static-cms-core.js"></script>
|
||||
<script src="https://unpkg.com/@staticcms/core@%5E1.0.0/dist/static-cms-core.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
@ -135,7 +135,7 @@ Your website is now deployed. Netlify provides you with a randomly generated dom
|
||||
4. Edit `config.yml` to look like this:
|
||||
```yaml
|
||||
backend:
|
||||
title: github
|
||||
name: github
|
||||
branch: main
|
||||
repo: <your-github>/my-website
|
||||
|
||||
@ -144,7 +144,7 @@ Your website is now deployed. Netlify provides you with a randomly generated dom
|
||||
public_folder: "/img/" # The src attribute for uploaded media will begin with /images/uploads
|
||||
|
||||
collections:
|
||||
- title: blog
|
||||
- name: blog
|
||||
label: "blog"
|
||||
folder: blog
|
||||
identifier_field: title
|
||||
@ -153,20 +153,20 @@ Your website is now deployed. Netlify provides you with a randomly generated dom
|
||||
create: true
|
||||
slug: "{{year}}-{{month}}-{{day}}-{{slug}}" # Filename template, e.g., YYYY-MM-DD-title.md
|
||||
fields:
|
||||
- { title: title, label: Title, widget: string }
|
||||
- { title: body, label: Body, widget: markdown }
|
||||
- { title: slug, label: Slug, widget: string }
|
||||
- { name: title, label: Title, widget: string }
|
||||
- { name: body, label: Body, widget: markdown }
|
||||
- { name: slug, label: Slug, widget: string }
|
||||
- label: "Tags"
|
||||
title: "tags"
|
||||
name: "tags"
|
||||
widget: "list"
|
||||
- label: "Authors"
|
||||
title: "authors"
|
||||
name: "authors"
|
||||
widget: "list"
|
||||
fields:
|
||||
- { title: name, label: Name, widget: string }
|
||||
- { title: title, label: Title, widget: string }
|
||||
- { title: url, label: URL, widget: string }
|
||||
- { title: imageUrl, label: ImageURL, widget: string }
|
||||
- { name: name, label: Name, widget: string }
|
||||
- { name: title, label: Title, widget: string }
|
||||
- { name: url, label: URL, widget: string }
|
||||
- { name: imageUrl, label: ImageURL, widget: string }
|
||||
```
|
||||
|
||||
`config.yml` specifies what kind of content your blog posts have. The content specification enables Static CMS to edit existing posts and create new ones with the same format. To learn more, read about Static CMS' [](/docs/configuration-options/)[Configuration options](/docs/configuration-options/).
|
||||
|
@ -6,5 +6,8 @@ weight: 110
|
||||
|
||||
Do you have a great, open source example? Submit a pull request to this page!
|
||||
|
||||
Example | Tools | Type | Source | More info |
|
||||
--- | --- | --- | --- | ---
|
||||
<div class="non-props-table" />
|
||||
|
||||
| Example | Tools | Type | Source | More info |
|
||||
| ---------------------------------------------------------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| [St Joseph Catholic Church](https://stjosephchurchbluffton.org/) | Next, React | Website | [saint-joseph-catholic-church-site](https://github.com/SaintJosephCatholicChurch/saint-joseph-catholic-church-site) | [README](https://github.com/SaintJosephCatholicChurch/saint-joseph-catholic-church-site/blob/main/README.md) |
|
||||
|
@ -1,24 +0,0 @@
|
||||
---
|
||||
group: Accounts
|
||||
title: External OAuth Clients
|
||||
weight: 60
|
||||
---
|
||||
If you would like to facilitate your own OAuth authentication rather than use Netlify's service or a client side flow like implicit or PKCE, you can use one of the community-maintained projects below. Feel free to hit the "Edit this page" button if you'd like to add yours!
|
||||
|
||||
| Author | Supported Git hosts | Language(s)/Platform(s) | Link |
|
||||
| ------------------------------------------------------------ | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [@vencax](https://github.com/vencax) | GitHub, GitHub Enterprise | Node.js | [Repo](https://github.com/vencax/netlify-cms-github-oauth-provider) |
|
||||
| [@igk1972](https://github.com/igk1972) | GitHub, GitHub Enterprise | Go | [Repo](https://github.com/igk1972/netlify-cms-oauth-provider-go) |
|
||||
| [@davidejones](https://github.com/davidejones) | GitHub, GitHub Enterprise | Python | [Repo](https://github.com/davidejones/netlify-cms-oauth-provider-python) |
|
||||
| [@marcelkornblum](https://github.com/marcelkornblum) | GitHub, GitHub Enterprise | Google AppEngine with Python | [Repo](https://github.com/signal-noise/netlify-cms-oauth-provider-python-appengine) |
|
||||
| [@marksteele](https://github.com/marksteele) | GitHub, GitHub Enterprise | Serverless | [Repo](https://github.com/marksteele/netlify-serverless-oauth2-backend), [Blog](https://www.control-alt-del.org/blog/serverless-blog-howto/) |
|
||||
| [@Herohtar](https://github.com/Herohtar) | GitHub, GitHub Enterprise | Firebase Cloud Function | [Repo](https://github.com/Herohtar/netlify-cms-oauth-firebase) |
|
||||
| [@abcalderon3](https://github.com/abcalderon3) | GitHub, GitHub Enterprise | Google Cloud Function with Python | [Repo](https://github.com/abcalderon3/netlify-cms-oauth-client-cloud-function) |
|
||||
| [@TSV-Zorneding-1920](https://github.com/TSV-Zorneding-1920) | GitHub, GitHub Enterprise | PHP | [Repo](https://github.com/TSV-Zorneding-1920/netlify-cms-oauth-provider-php) |
|
||||
| [@bericp1](https://github.com/bericp1) | GitHub, GitHub Enterprise | Node.js, Vercel Serverless | [Repo](https://github.com/bericp1/netlify-cms-oauth-provider-node) |
|
||||
| [@mcdeck](https://github.com/mcdeck) | GitHub, GitHub Enterprise, GitLab | PHP | [Repo](https://github.com/mcdeck/netlify-cms-oauth-provider-php), [Blog](https://www.van-porten.de/blog/2021/01/netlify-auth-provider/) |
|
||||
| [@deepbass](https://github.com/deepbass) | GitHub, GitHub Enterprise | Node.js Azure Functions | [Repo](https://github.com/deepbass/serverless-cms-azure), [Blog](https://www.danielbass.dev/building-a-serverless-cms-on-azure-with-netlify-cms-and-gatsby/) |
|
||||
| [@adrian-ub](https://github.com/adrian-ub) | GitHub, GitLab | TypeScript | [Repo](https://github.com/ublabs/netlify-cms-oauth) |
|
||||
| [@hatappo](https://github.com/hatappo) | GitHub | ClojureScript, Firebase Functions | [Repo](https://github.com/hatappo/netlifycms-oauth-server) |
|
||||
|
||||
Check each project's documentation for instructions on installation and usage.
|
@ -60,14 +60,14 @@ In your `config.yml` file paste the following configuration:
|
||||
|
||||
```yml
|
||||
backend:
|
||||
title: git-gateway
|
||||
name: git-gateway
|
||||
branch: main # Branch to update (optional; defaults to main)
|
||||
|
||||
media_folder: static/img
|
||||
public_folder: /img
|
||||
|
||||
collections:
|
||||
- title: 'blog'
|
||||
- name: 'blog'
|
||||
label: 'Blog'
|
||||
folder: 'content/blog'
|
||||
create: true
|
||||
@ -78,10 +78,10 @@ collections:
|
||||
editor:
|
||||
preview: false
|
||||
fields:
|
||||
- { label: 'Title', title: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', title: 'date', widget: 'datetime' }
|
||||
- { label: 'Description', title: 'description', widget: 'string' }
|
||||
- { label: 'Body', title: 'body', widget: 'markdown' }
|
||||
- { label: 'Title', name: 'title', widget: 'string' }
|
||||
- { label: 'Publish Date', name: 'date', widget: 'datetime' }
|
||||
- { label: 'Description', name: 'description', widget: 'string' }
|
||||
- { label: 'Body', name: 'body', widget: 'markdown' }
|
||||
```
|
||||
|
||||
**Note:** The above configuration allows assets to be stored relative to their content. Therefore posts would be stored in the format below as it is in `gatsby-starter-blog`.
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user