add media library
* rebase editorial workflow pull requests when behind * fix async/await transpilation * add media library pagination * switch media library to grid layout * ensure that only cms branches can be force updated
This commit is contained in:
12
.babelrc
12
.babelrc
@ -1,11 +1,21 @@
|
|||||||
{
|
{
|
||||||
"presets": [["env", { "modules": false }], "stage-1", "react"],
|
"presets": [
|
||||||
|
["env", {
|
||||||
|
"modules": false
|
||||||
|
}],
|
||||||
|
"stage-1",
|
||||||
|
"react"
|
||||||
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"react-hot-loader/babel",
|
"react-hot-loader/babel",
|
||||||
"lodash",
|
"lodash",
|
||||||
["babel-plugin-transform-builtin-extend", {
|
["babel-plugin-transform-builtin-extend", {
|
||||||
"globals": ["Error"]
|
"globals": ["Error"]
|
||||||
}],
|
}],
|
||||||
|
["transform-runtime", {
|
||||||
|
"useBuiltIns": true,
|
||||||
|
"useESModules": true
|
||||||
|
}]
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"test": {
|
"test": {
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
backend:
|
backend:
|
||||||
name: test-repo
|
name: github
|
||||||
|
repo: netlify/netlify-cms
|
||||||
|
branch: pr-554-backend
|
||||||
|
|
||||||
media_folder: "assets/uploads"
|
media_folder: "assets/uploads"
|
||||||
|
|
||||||
|
@ -82,6 +82,7 @@
|
|||||||
"babel-loader": "^7.0.0",
|
"babel-loader": "^7.0.0",
|
||||||
"babel-plugin-lodash": "^3.2.0",
|
"babel-plugin-lodash": "^3.2.0",
|
||||||
"babel-plugin-transform-builtin-extend": "^1.1.0",
|
"babel-plugin-transform-builtin-extend": "^1.1.0",
|
||||||
|
"babel-plugin-transform-runtime": "^6.23.0",
|
||||||
"babel-preset-env": "^1.6.0",
|
"babel-preset-env": "^1.6.0",
|
||||||
"babel-preset-react": "^6.23.0",
|
"babel-preset-react": "^6.23.0",
|
||||||
"babel-preset-stage-1": "^6.22.0",
|
"babel-preset-stage-1": "^6.22.0",
|
||||||
@ -108,8 +109,8 @@
|
|||||||
"postcss-cssnext": "^3.0.2",
|
"postcss-cssnext": "^3.0.2",
|
||||||
"postcss-import": "^11.0.0",
|
"postcss-import": "^11.0.0",
|
||||||
"postcss-loader": "^2.0.7",
|
"postcss-loader": "^2.0.7",
|
||||||
"react-test-renderer": "^16.0.0",
|
|
||||||
"react-hot-loader": "^3.0.0-beta.7",
|
"react-hot-loader": "^3.0.0-beta.7",
|
||||||
|
"react-test-renderer": "^16.0.0",
|
||||||
"style-loader": "^0.18.2",
|
"style-loader": "^0.18.2",
|
||||||
"stylefmt": "^4.3.1",
|
"stylefmt": "^4.3.1",
|
||||||
"stylelint": "^7.9.0",
|
"stylelint": "^7.9.0",
|
||||||
@ -126,6 +127,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"create-react-class": "^15.6.0",
|
"create-react-class": "^15.6.0",
|
||||||
|
"focus-trap-react": "^3.0.3",
|
||||||
"fuzzy": "^0.1.1",
|
"fuzzy": "^0.1.1",
|
||||||
"gotrue-js": "^0.9.11",
|
"gotrue-js": "^0.9.11",
|
||||||
"gray-matter": "^3.0.6",
|
"gray-matter": "^3.0.6",
|
||||||
@ -184,6 +186,7 @@
|
|||||||
"unified": "^6.1.4",
|
"unified": "^6.1.4",
|
||||||
"unist-builder": "^1.0.2",
|
"unist-builder": "^1.0.2",
|
||||||
"unist-util-visit-parents": "^1.1.1",
|
"unist-util-visit-parents": "^1.1.1",
|
||||||
|
"url": "^0.11.0",
|
||||||
"uuid": "^3.1.0"
|
"uuid": "^3.1.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
@ -121,6 +121,7 @@ function unpublishedEntryPersistedFail(error, transactionID) {
|
|||||||
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
|
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
|
||||||
payload: { error },
|
payload: { error },
|
||||||
optimist: { type: REVERT, id: transactionID },
|
optimist: { type: REVERT, id: transactionID },
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
183
src/actions/mediaLibrary.js
Normal file
183
src/actions/mediaLibrary.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { actions as notifActions } from 'redux-notifications';
|
||||||
|
import { currentBackend } from '../backends/backend';
|
||||||
|
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||||
|
import { getAsset, selectIntegration } from '../reducers';
|
||||||
|
import { addAsset } from './media';
|
||||||
|
import { getIntegrationProvider } from '../integrations';
|
||||||
|
|
||||||
|
const { notifSend } = notifActions;
|
||||||
|
|
||||||
|
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
|
||||||
|
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
|
||||||
|
export const MEDIA_INSERT = 'MEDIA_INSERT';
|
||||||
|
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
|
||||||
|
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
|
||||||
|
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
|
||||||
|
export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST';
|
||||||
|
export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS';
|
||||||
|
export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE';
|
||||||
|
export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST';
|
||||||
|
export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS';
|
||||||
|
export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
|
||||||
|
|
||||||
|
export function openMediaLibrary(payload) {
|
||||||
|
return { type: MEDIA_LIBRARY_OPEN, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeMediaLibrary() {
|
||||||
|
return { type: MEDIA_LIBRARY_CLOSE };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMedia(mediaPath) {
|
||||||
|
return { type: MEDIA_INSERT, payload: { mediaPath } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadMedia(opts = {}) {
|
||||||
|
const { delay = 0, query = '', page = 1 } = opts;
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const backend = currentBackend(state.config);
|
||||||
|
const integration = selectIntegration(state, null, 'assetStore');
|
||||||
|
if (integration) {
|
||||||
|
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||||
|
dispatch(mediaLoading(page));
|
||||||
|
try {
|
||||||
|
const files = await provider.retrieve(query, page);
|
||||||
|
const mediaLoadedOpts = {
|
||||||
|
page,
|
||||||
|
canPaginate: true,
|
||||||
|
dynamicSearch: true,
|
||||||
|
dynamicSearchQuery: query
|
||||||
|
};
|
||||||
|
return dispatch(mediaLoaded(files, mediaLoadedOpts));
|
||||||
|
}
|
||||||
|
catch(error) {
|
||||||
|
return dispatch(mediaLoadFailed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispatch(mediaLoading(page));
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => resolve(
|
||||||
|
backend.getMedia()
|
||||||
|
.then(files => dispatch(mediaLoaded(files)))
|
||||||
|
.catch((error) => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed()))
|
||||||
|
));
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistMedia(file, privateUpload) {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const backend = currentBackend(state.config);
|
||||||
|
const integration = selectIntegration(state, null, 'assetStore');
|
||||||
|
|
||||||
|
dispatch(mediaPersisting());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assetProxy = await createAssetProxy(file.name.toLowerCase(), file, false, privateUpload);
|
||||||
|
dispatch(addAsset(assetProxy));
|
||||||
|
if (!integration) {
|
||||||
|
const asset = await backend.persistMedia(assetProxy);
|
||||||
|
return dispatch(mediaPersisted(asset));
|
||||||
|
}
|
||||||
|
return dispatch(mediaPersisted(assetProxy.asset));
|
||||||
|
}
|
||||||
|
catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
dispatch(notifSend({
|
||||||
|
message: `Failed to persist media: ${ error }`,
|
||||||
|
kind: 'danger',
|
||||||
|
dismissAfter: 8000,
|
||||||
|
}));
|
||||||
|
return dispatch(mediaPersistFailed());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMedia(file) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const backend = currentBackend(state.config);
|
||||||
|
const integration = selectIntegration(state, null, 'assetStore');
|
||||||
|
if (integration) {
|
||||||
|
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||||
|
dispatch(mediaDeleting());
|
||||||
|
return provider.delete(file.id)
|
||||||
|
.then(() => {
|
||||||
|
return dispatch(mediaDeleted(file));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
dispatch(notifSend({
|
||||||
|
message: `Failed to delete media: ${ error.message }`,
|
||||||
|
kind: 'danger',
|
||||||
|
dismissAfter: 8000,
|
||||||
|
}));
|
||||||
|
return dispatch(mediaDeleteFailed());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatch(mediaDeleting());
|
||||||
|
return backend.deleteMedia(file.path)
|
||||||
|
.then(() => {
|
||||||
|
return dispatch(mediaDeleted(file));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
dispatch(notifSend({
|
||||||
|
message: `Failed to delete media: ${ error.message }`,
|
||||||
|
kind: 'danger',
|
||||||
|
dismissAfter: 8000,
|
||||||
|
}));
|
||||||
|
return dispatch(mediaDeleteFailed());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaLoading(page) {
|
||||||
|
return {
|
||||||
|
type: MEDIA_LOAD_REQUEST,
|
||||||
|
payload: { page },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaLoaded(files, opts = {}) {
|
||||||
|
return {
|
||||||
|
type: MEDIA_LOAD_SUCCESS,
|
||||||
|
payload: { files, ...opts }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaLoadFailed(error) {
|
||||||
|
return { type: MEDIA_LOAD_FAILURE };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaPersisting() {
|
||||||
|
return { type: MEDIA_PERSIST_REQUEST };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaPersisted(asset) {
|
||||||
|
return {
|
||||||
|
type: MEDIA_PERSIST_SUCCESS,
|
||||||
|
payload: { file: asset },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaPersistFailed(error) {
|
||||||
|
return { type: MEDIA_PERSIST_FAILURE };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaDeleting() {
|
||||||
|
return { type: MEDIA_DELETE_REQUEST };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaDeleted(file) {
|
||||||
|
return {
|
||||||
|
type: MEDIA_DELETE_SUCCESS,
|
||||||
|
payload: { file },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaDeleteFailed(error) {
|
||||||
|
return { type: MEDIA_DELETE_FAILURE };
|
||||||
|
}
|
@ -148,6 +148,10 @@ class Backend {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMedia() {
|
||||||
|
return this.implementation.getMedia();
|
||||||
|
}
|
||||||
|
|
||||||
entryWithFormat(collectionOrEntity) {
|
entryWithFormat(collectionOrEntity) {
|
||||||
return (entry) => {
|
return (entry) => {
|
||||||
const format = resolveFormat(collectionOrEntity, entry);
|
const format = resolveFormat(collectionOrEntity, entry);
|
||||||
@ -244,6 +248,13 @@ class Backend {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
persistMedia(file) {
|
||||||
|
const options = {
|
||||||
|
commitMessage: `Upload ${file.path}`,
|
||||||
|
};
|
||||||
|
return this.implementation.persistMedia(file, options);
|
||||||
|
}
|
||||||
|
|
||||||
deleteEntry(config, collection, slug) {
|
deleteEntry(config, collection, slug) {
|
||||||
const path = selectEntryPath(collection, slug);
|
const path = selectEntryPath(collection, slug);
|
||||||
|
|
||||||
@ -255,6 +266,11 @@ class Backend {
|
|||||||
return this.implementation.deleteFile(path, commitMessage);
|
return this.implementation.deleteFile(path, commitMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteMedia(path) {
|
||||||
|
const commitMessage = `Delete ${path}`;
|
||||||
|
return this.implementation.deleteFile(path, commitMessage);
|
||||||
|
}
|
||||||
|
|
||||||
persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) {
|
persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) {
|
||||||
return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true });
|
return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true });
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import GithubAPI from "../github/API";
|
import GithubAPI from "../github/API";
|
||||||
|
import { APIError } from "../../valueObjects/errors";
|
||||||
|
|
||||||
export default class API extends GithubAPI {
|
export default class API extends GithubAPI {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
@ -44,15 +45,20 @@ export default class API extends GithubAPI {
|
|||||||
|
|
||||||
request(path, options = {}) {
|
request(path, options = {}) {
|
||||||
const url = this.urlFor(path, options);
|
const url = this.urlFor(path, options);
|
||||||
|
let responseStatus;
|
||||||
return this.getRequestHeaders(options.headers || {})
|
return this.getRequestHeaders(options.headers || {})
|
||||||
.then(headers => fetch(url, { ...options, headers }))
|
.then(headers => fetch(url, { ...options, headers }))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
responseStatus = response.status;
|
||||||
const contentType = response.headers.get("Content-Type");
|
const contentType = response.headers.get("Content-Type");
|
||||||
if (contentType && contentType.match(/json/)) {
|
if (contentType && contentType.match(/json/)) {
|
||||||
return this.parseJsonResponse(response);
|
return this.parseJsonResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.text();
|
return response.text();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
throw new APIError(error.message, responseStatus, 'Git Gateway');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import LocalForage from "localforage";
|
import LocalForage from "localforage";
|
||||||
import { Base64 } from "js-base64";
|
import { Base64 } from "js-base64";
|
||||||
import _ from "lodash";
|
import { uniq, initial, last, get, find } from "lodash";
|
||||||
import { filterPromises, resolvePromiseProperties } from "../../lib/promiseHelper";
|
import { filterPromises, resolvePromiseProperties } from "../../lib/promiseHelper";
|
||||||
import AssetProxy from "../../valueObjects/AssetProxy";
|
import AssetProxy from "../../valueObjects/AssetProxy";
|
||||||
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
|
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
|
||||||
import { APIError, EditorialWorkflowError } from "../../valueObjects/errors";
|
import { APIError, EditorialWorkflowError } from "../../valueObjects/errors";
|
||||||
|
|
||||||
|
const CMS_BRANCH_PREFIX = 'cms/';
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
this.api_root = config.api_root || "https://api.github.com";
|
this.api_root = config.api_root || "https://api.github.com";
|
||||||
@ -83,6 +85,10 @@ export default class API {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateBranchName(basename) {
|
||||||
|
return `${CMS_BRANCH_PREFIX}${basename}`;
|
||||||
|
}
|
||||||
|
|
||||||
checkMetadataRef() {
|
checkMetadataRef() {
|
||||||
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
|
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -249,7 +255,7 @@ export default class API {
|
|||||||
|
|
||||||
persistFiles(entry, mediaFiles, options) {
|
persistFiles(entry, mediaFiles, options) {
|
||||||
const uploadPromises = [];
|
const uploadPromises = [];
|
||||||
const files = mediaFiles.concat(entry);
|
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
if (file.uploaded) { return; }
|
if (file.uploaded) { return; }
|
||||||
@ -273,43 +279,51 @@ export default class API {
|
|||||||
|
|
||||||
deleteFile(path, message, options={}) {
|
deleteFile(path, message, options={}) {
|
||||||
const branch = options.branch || this.branch;
|
const branch = options.branch || this.branch;
|
||||||
|
const pathArray = path.split('/');
|
||||||
|
const filename = last(pathArray);
|
||||||
|
const directory = initial(pathArray).join('/');
|
||||||
|
const fileDataPath = encodeURIComponent(directory);
|
||||||
|
const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`;
|
||||||
const fileURL = `${ this.repoURL }/contents/${ path }`;
|
const fileURL = `${ this.repoURL }/contents/${ path }`;
|
||||||
// We need to request the file first to get the SHA
|
|
||||||
return this.request(fileURL, {
|
/**
|
||||||
params: { ref: branch },
|
* We need to request the tree first to get the SHA. We use extended SHA-1
|
||||||
cache: "no-store",
|
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
|
||||||
}).then(({ sha }) => this.request(fileURL, {
|
* through the tree.
|
||||||
method: "DELETE",
|
*/
|
||||||
params: {
|
return this.request(fileDataURL, { cache: 'no-store' })
|
||||||
sha,
|
.then(resp => {
|
||||||
message,
|
const { sha } = resp.tree.find(file => file.path === filename);
|
||||||
branch,
|
const opts = { method: 'DELETE', params: { sha, message, branch } };
|
||||||
},
|
return this.request(fileURL, opts);
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
editorialWorkflowGit(fileTree, entry, filesList, options) {
|
editorialWorkflowGit(fileTree, entry, filesList, options) {
|
||||||
const contentKey = entry.slug;
|
const contentKey = entry.slug;
|
||||||
const branchName = `cms/${ contentKey }`;
|
const branchName = this.generateBranchName(contentKey);
|
||||||
const unpublished = options.unpublished || false;
|
const unpublished = options.unpublished || false;
|
||||||
if (!unpublished) {
|
if (!unpublished) {
|
||||||
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
|
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
|
||||||
const contentKey = entry.slug;
|
let prResponse;
|
||||||
const branchName = `cms/${ contentKey }`;
|
|
||||||
|
|
||||||
return this.getBranch()
|
return this.getBranch()
|
||||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||||
.then(branchResponse => this.createPR(options.commitMessage, branchName))
|
.then(branchResponse => this.createPR(options.commitMessage, branchName))
|
||||||
.then(prResponse => this.user().then(user => user.name ? user.name : user.login)
|
.then(pr => {
|
||||||
.then(username => this.storeMetadata(contentKey, {
|
prResponse = pr;
|
||||||
|
return this.user();
|
||||||
|
})
|
||||||
|
.then(user => {
|
||||||
|
return this.storeMetadata(contentKey, {
|
||||||
type: "PR",
|
type: "PR",
|
||||||
pr: {
|
pr: {
|
||||||
number: prResponse.number,
|
number: prResponse.number,
|
||||||
head: prResponse.head && prResponse.head.sha,
|
head: prResponse.head && prResponse.head.sha,
|
||||||
},
|
},
|
||||||
user: username,
|
user: user.name || user.login,
|
||||||
status: status.first(),
|
status: status.first(),
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
collection: options.collectionName,
|
collection: options.collectionName,
|
||||||
@ -323,44 +337,176 @@ export default class API {
|
|||||||
files: filesList,
|
files: filesList,
|
||||||
},
|
},
|
||||||
timeStamp: new Date().toISOString(),
|
timeStamp: new Date().toISOString(),
|
||||||
}
|
});
|
||||||
)));
|
});
|
||||||
} else {
|
} else {
|
||||||
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
||||||
|
let newHead;
|
||||||
return this.getBranch(branchName)
|
return this.getBranch(branchName)
|
||||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||||
.then((response) => {
|
.then(commit => {
|
||||||
const contentKey = entry.slug;
|
newHead = commit;
|
||||||
const branchName = `cms/${ contentKey }`;
|
return this.retrieveMetadata(contentKey);
|
||||||
return this.user().then(user => user.name ? user.name : user.login)
|
|
||||||
.then(username => this.retrieveMetadata(contentKey))
|
|
||||||
.then((metadata) => {
|
|
||||||
let files = metadata.objects && metadata.objects.files || [];
|
|
||||||
files = files.concat(filesList);
|
|
||||||
const updatedPR = metadata.pr;
|
|
||||||
updatedPR.head = response.sha;
|
|
||||||
return {
|
|
||||||
...metadata,
|
|
||||||
pr: updatedPR,
|
|
||||||
title: options.parsedData && options.parsedData.title,
|
|
||||||
description: options.parsedData && options.parsedData.description,
|
|
||||||
objects: {
|
|
||||||
entry: {
|
|
||||||
path: entry.path,
|
|
||||||
sha: entry.sha,
|
|
||||||
},
|
|
||||||
files: _.uniq(files),
|
|
||||||
},
|
|
||||||
timeStamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata))
|
.then(metadata => {
|
||||||
.then(this.patchBranch(branchName, response.sha));
|
const { title, description } = options.parsedData || {};
|
||||||
});
|
const metadataFiles = get(metadata.objects, 'files', []);
|
||||||
|
const files = [ ...metadataFiles, ...filesList ];
|
||||||
|
const pr = { ...metadata.pr, head: newHead.sha };
|
||||||
|
const objects = {
|
||||||
|
entry: { path: entry.path, sha: entry.sha },
|
||||||
|
files: uniq(files),
|
||||||
|
};
|
||||||
|
const updatedMetadata = { ...metadata, pr, title, description, objects };
|
||||||
|
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, newHead);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebase a pull request onto the latest HEAD of it's target base branch
|
||||||
|
* (should generally be the configured backend branch). Only rebases changes
|
||||||
|
* in the entry file.
|
||||||
|
*/
|
||||||
|
async rebasePullRequest(prNumber, branchName, contentKey, metadata, head) {
|
||||||
|
const { path } = metadata.objects.entry;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* Get the published branch and create new commits over it. If the pull
|
||||||
|
* request is up to date, no rebase will occur.
|
||||||
|
*/
|
||||||
|
const baseBranch = await this.getBranch();
|
||||||
|
const commits = await this.getPullRequestCommits(prNumber, head);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sometimes the list of commits for a pull request isn't updated
|
||||||
|
* immediately after the PR branch is patched. There's also the possibility
|
||||||
|
* that the branch has changed unexpectedly. We account for both by adding
|
||||||
|
* the head if it's missing, or else throwing an error if the PR head is
|
||||||
|
* neither the head we expect nor its parent.
|
||||||
|
*/
|
||||||
|
const finalCommits = this.assertHead(commits, head);
|
||||||
|
const rebasedHead = await this.rebaseSingleBlobCommits(baseBranch.commit, finalCommits, path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update metadata, then force update the pull request branch head.
|
||||||
|
*/
|
||||||
|
const pr = { ...metadata.pr, head: rebasedHead.sha };
|
||||||
|
const timeStamp = new Date().toISOString();
|
||||||
|
const updatedMetadata = { ...metadata, pr, timeStamp };
|
||||||
|
await this.storeMetadata(contentKey, updatedMetadata);
|
||||||
|
return this.patchBranch(branchName, rebasedHead.sha, { force: true });
|
||||||
|
}
|
||||||
|
catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebase an array of commits one-by-one, starting from a given base SHA. Can
|
||||||
|
* accept an array of commits as received from the GitHub API. All commits are
|
||||||
|
* expected to change the same, single blob.
|
||||||
|
*/
|
||||||
|
rebaseSingleBlobCommits(baseCommit, commits, pathToBlob) {
|
||||||
|
/**
|
||||||
|
* If the parent of the first commit already matches the target base,
|
||||||
|
* return commits as is.
|
||||||
|
*/
|
||||||
|
if (commits.length === 0 || commits[0].parents[0].sha === baseCommit.sha) {
|
||||||
|
return Promise.resolve(last(commits));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-create each commit over the new base, applying each to the previous,
|
||||||
|
* changing only the parent SHA and tree for each, but retaining all other
|
||||||
|
* info, such as the author/committer data.
|
||||||
|
*/
|
||||||
|
const newHeadPromise = commits.reduce((lastCommitPromise, commit, idx) => {
|
||||||
|
return lastCommitPromise.then(newParent => {
|
||||||
|
/**
|
||||||
|
* Normalize commit data to ensure it's not nested in `commit.commit`.
|
||||||
|
*/
|
||||||
|
const parent = this.normalizeCommit(newParent);
|
||||||
|
const commitToRebase = this.normalizeCommit(commit);
|
||||||
|
|
||||||
|
return this.rebaseSingleBlobCommit(parent, commitToRebase, pathToBlob);
|
||||||
|
});
|
||||||
|
}, Promise.resolve(baseCommit));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a promise that resolves when all commits have been created.
|
||||||
|
*/
|
||||||
|
return newHeadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebase a commit that changes a single blob. Also handles updating the tree.
|
||||||
|
*/
|
||||||
|
rebaseSingleBlobCommit(baseCommit, commit, pathToBlob) {
|
||||||
|
/**
|
||||||
|
* Retain original commit metadata.
|
||||||
|
*/
|
||||||
|
const { message, author, committer } = commit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base commit as the parent.
|
||||||
|
*/
|
||||||
|
const parent = [ baseCommit.sha ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the blob data by path.
|
||||||
|
*/
|
||||||
|
return this.getBlobInTree(commit.tree.sha, pathToBlob)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree consisting of the base tree and the single updated
|
||||||
|
* blob. Use the full path to indicate nesting, GitHub will take care of
|
||||||
|
* subtree creation.
|
||||||
|
*/
|
||||||
|
.then(blob => this.createTree(baseCommit.tree.sha, [{ ...blob, path: pathToBlob }]))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new commit with the updated tree and original commit metadata.
|
||||||
|
*/
|
||||||
|
.then(tree => this.createCommit(message, tree.sha, parent, author, committer));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a pull request by PR number.
|
||||||
|
*/
|
||||||
|
getPullRequest(prNumber) {
|
||||||
|
return this.request(`${ this.repoURL }/pulls/${prNumber} }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of commits for a given pull request.
|
||||||
|
*/
|
||||||
|
getPullRequestCommits (prNumber) {
|
||||||
|
return this.request(`${ this.repoURL }/pulls/${prNumber}/commits`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `commits` with `headToAssert` appended if it's the child of the
|
||||||
|
* last commit in `commits`. Returns `commits` unaltered if `headToAssert` is
|
||||||
|
* already the last commit in `commits`. Otherwise throws an error.
|
||||||
|
*/
|
||||||
|
assertHead(commits, headToAssert) {
|
||||||
|
const headIsMissing = headToAssert.parents[0].sha === last(commits).sha;
|
||||||
|
const headIsNotMissing = headToAssert.sha === last(commits).sha;
|
||||||
|
|
||||||
|
if (headIsMissing) {
|
||||||
|
return commits.concat(headToAssert);
|
||||||
|
} else if (headIsNotMissing) {
|
||||||
|
return commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Error('Editorial workflow branch changed unexpectedly.');
|
||||||
|
}
|
||||||
|
|
||||||
updateUnpublishedEntryStatus(collection, slug, status) {
|
updateUnpublishedEntryStatus(collection, slug, status) {
|
||||||
const contentKey = slug;
|
const contentKey = slug;
|
||||||
return this.retrieveMetadata(contentKey)
|
return this.retrieveMetadata(contentKey)
|
||||||
@ -373,9 +519,10 @@ export default class API {
|
|||||||
|
|
||||||
deleteUnpublishedEntry(collection, slug) {
|
deleteUnpublishedEntry(collection, slug) {
|
||||||
const contentKey = slug;
|
const contentKey = slug;
|
||||||
|
const branchName = this.generateBranchName(contentKey);
|
||||||
return this.retrieveMetadata(contentKey)
|
return this.retrieveMetadata(contentKey)
|
||||||
.then(metadata => this.closePR(metadata.pr, metadata.objects))
|
.then(metadata => this.closePR(metadata.pr, metadata.objects))
|
||||||
.then(() => this.deleteBranch(`cms/${ contentKey }`))
|
.then(() => this.deleteBranch(branchName))
|
||||||
// If the PR doesn't exist, then this has already been deleted -
|
// If the PR doesn't exist, then this has already been deleted -
|
||||||
// deletion should be idempotent, so we can consider this a
|
// deletion should be idempotent, so we can consider this a
|
||||||
// success.
|
// success.
|
||||||
@ -389,10 +536,11 @@ export default class API {
|
|||||||
|
|
||||||
publishUnpublishedEntry(collection, slug) {
|
publishUnpublishedEntry(collection, slug) {
|
||||||
const contentKey = slug;
|
const contentKey = slug;
|
||||||
|
const branchName = this.generateBranchName(contentKey);
|
||||||
let prNumber;
|
let prNumber;
|
||||||
return this.retrieveMetadata(contentKey)
|
return this.retrieveMetadata(contentKey)
|
||||||
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
|
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
|
||||||
.then(() => this.deleteBranch(`cms/${ contentKey }`));
|
.then(() => this.deleteBranch(branchName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -403,10 +551,11 @@ export default class API {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
patchRef(type, name, sha) {
|
patchRef(type, name, sha, opts = {}) {
|
||||||
|
const force = opts.force || false;
|
||||||
return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, {
|
return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ sha }),
|
body: JSON.stringify({ sha, force }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,8 +573,16 @@ export default class API {
|
|||||||
return this.createRef("heads", branchName, sha);
|
return this.createRef("heads", branchName, sha);
|
||||||
}
|
}
|
||||||
|
|
||||||
patchBranch(branchName, sha) {
|
assertCmsBranch(branchName) {
|
||||||
return this.patchRef("heads", branchName, sha);
|
return branchName.startsWith(CMS_BRANCH_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
patchBranch(branchName, sha, opts = {}) {
|
||||||
|
const force = opts.force || false;
|
||||||
|
if (force && !this.assertCmsBranch(branchName)) {
|
||||||
|
throw Error(`Only CMS branches can be force updated, cannot force update ${branchName}`);
|
||||||
|
}
|
||||||
|
return this.patchRef("heads", branchName, sha, { force });
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteBranch(branchName) {
|
deleteBranch(branchName) {
|
||||||
@ -487,7 +644,28 @@ export default class API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTree(sha) {
|
getTree(sha) {
|
||||||
return sha ? this.request(`${ this.repoURL }/git/trees/${ sha }`) : Promise.resolve({ tree: [] });
|
if (sha) {
|
||||||
|
return this.request(`${this.repoURL}/git/trees/${sha}`);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ tree: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a blob from a tree. Requests individual subtrees recursively if blob is
|
||||||
|
* nested within one or more directories.
|
||||||
|
*/
|
||||||
|
getBlobInTree(treeSha, pathToBlob) {
|
||||||
|
const pathSegments = pathToBlob.split('/').filter(val => val);
|
||||||
|
const directories = pathSegments.slice(0, -1);
|
||||||
|
const filename = pathSegments.slice(-1)[0];
|
||||||
|
const baseTree = this.getTree(treeSha);
|
||||||
|
const subTreePromise = directories.reduce((treePromise, segment) => {
|
||||||
|
return treePromise.then(tree => {
|
||||||
|
const subTreeSha = find(tree.tree, { path: segment }).sha;
|
||||||
|
return this.getTree(subTreeSha);
|
||||||
|
});
|
||||||
|
}, baseTree);
|
||||||
|
return subTreePromise.then(subTree => find(subTree.tree, { path: filename }));
|
||||||
}
|
}
|
||||||
|
|
||||||
toBase64(str) {
|
toBase64(str) {
|
||||||
@ -542,19 +720,40 @@ export default class API {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Promise.all(updates)
|
return Promise.all(updates)
|
||||||
.then(updates => this.request(`${ this.repoURL }/git/trees`, {
|
.then(tree => this.createTree(sha, tree))
|
||||||
method: "POST",
|
.then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
|
||||||
body: JSON.stringify({ base_tree: sha, tree: updates }),
|
|
||||||
})).then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTree(baseSha, tree) {
|
||||||
|
return this.request(`${ this.repoURL }/git/trees`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ base_tree: baseSha, tree }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some GitHub API calls return commit data in a nested `commit` property,
|
||||||
|
* with the SHA outside of the nested property, while others return a
|
||||||
|
* flatter object with no nested `commit` property. This normalizes a commit
|
||||||
|
* to resemble the latter.
|
||||||
|
*/
|
||||||
|
normalizeCommit(commit) {
|
||||||
|
if (commit.commit) {
|
||||||
|
return { ...commit.commit, sha: commit.sha };
|
||||||
|
}
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
commit(message, changeTree) {
|
commit(message, changeTree) {
|
||||||
const tree = changeTree.sha;
|
|
||||||
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
|
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
|
||||||
|
return this.createCommit(message, changeTree.sha, parents);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCommit(message, treeSha, parents, author, committer) {
|
||||||
return this.request(`${ this.repoURL }/git/commits`, {
|
return this.request(`${ this.repoURL }/git/commits`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ message, tree, parents }),
|
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import trimStart from 'lodash/trimStart';
|
||||||
import semaphore from "semaphore";
|
import semaphore from "semaphore";
|
||||||
import AuthenticationPage from "./AuthenticationPage";
|
import AuthenticationPage from "./AuthenticationPage";
|
||||||
import API from "./API";
|
import API from "./API";
|
||||||
@ -89,10 +90,31 @@ export default class GitHub {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMedia() {
|
||||||
|
return this.api.listFiles(this.config.get('media_folder'))
|
||||||
|
.then(files => files.filter(file => file.type === 'file'))
|
||||||
|
.then(files => files.map(({ sha, name, size, download_url, path }) => {
|
||||||
|
return { id: sha, name, size, url: download_url, path };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
persistEntry(entry, mediaFiles = [], options = {}) {
|
persistEntry(entry, mediaFiles = [], options = {}) {
|
||||||
return this.api.persistFiles(entry, mediaFiles, options);
|
return this.api.persistFiles(entry, mediaFiles, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async persistMedia(mediaFile, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await this.api.persistFiles(null, [mediaFile], options);
|
||||||
|
const { value, size, path, fileObj } = mediaFile;
|
||||||
|
const url = `https://raw.githubusercontent.com/${this.repo}/${this.branch}${path}`;
|
||||||
|
return { id: response.sha, name: value, size: fileObj.size, url, path: trimStart(path, '/') };
|
||||||
|
}
|
||||||
|
catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deleteFile(path, commitMessage, options) {
|
deleteFile(path, commitMessage, options) {
|
||||||
return this.api.deleteFile(path, commitMessage, options);
|
return this.api.deleteFile(path, commitMessage, options);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { remove, attempt, isError } from 'lodash';
|
||||||
|
import uuid from 'uuid/v4';
|
||||||
import AuthenticationPage from './AuthenticationPage';
|
import AuthenticationPage from './AuthenticationPage';
|
||||||
import { fileExtension } from '../../lib/pathHelper'
|
import { fileExtension } from '../../lib/pathHelper'
|
||||||
|
|
||||||
@ -24,6 +26,7 @@ function nameFromEmail(email) {
|
|||||||
export default class TestRepo {
|
export default class TestRepo {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.assets = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
authComponent() {
|
authComponent() {
|
||||||
@ -99,10 +102,32 @@ export default class TestRepo {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMedia() {
|
||||||
|
return Promise.resolve(this.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistMedia({ fileObj }) {
|
||||||
|
const { name, size } = fileObj;
|
||||||
|
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
|
||||||
|
const url = isError(objectUrl) ? '' : objectUrl;
|
||||||
|
const normalizedAsset = { id: uuid(), name, size, path: url, url };
|
||||||
|
|
||||||
|
this.assets.push(normalizedAsset);
|
||||||
|
return Promise.resolve(normalizedAsset);
|
||||||
|
}
|
||||||
|
|
||||||
deleteFile(path, commitMessage) {
|
deleteFile(path, commitMessage) {
|
||||||
const folder = path.substring(0, path.lastIndexOf('/'));
|
const assetIndex = this.assets.findIndex(asset => asset.path === path);
|
||||||
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
if (assetIndex > -1) {
|
||||||
delete window.repoFiles[folder][fileName];
|
this.assets.splice(assetIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
const folder = path.substring(0, path.lastIndexOf('/'));
|
||||||
|
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||||
|
delete window.repoFiles[folder][fileName];
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,13 +7,22 @@
|
|||||||
|
|
||||||
/* Gross stuff below, React Toolbox hacks */
|
/* Gross stuff below, React Toolbox hacks */
|
||||||
|
|
||||||
.nc-appHeader-homeLink,
|
.nc-appHeader-button,
|
||||||
.nc-appHeader-iconMenu {
|
.nc-appHeader-iconMenu {
|
||||||
margin-left: 2%;
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nc-appHeader-homeLink &icon {
|
.nc-appHeader-button {
|
||||||
vertical-align: top;
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
width: 36px;
|
||||||
|
padding: 6px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& .nc-appHeader-icon {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nc-appHeader-icon,
|
.nc-appHeader-icon,
|
||||||
|
@ -20,11 +20,6 @@ export default class AppHeader extends React.Component {
|
|||||||
onLogoutClick: PropTypes.func.isRequired,
|
onLogoutClick: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
|
||||||
createMenuActive: false,
|
|
||||||
userMenuActive: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCreatePostClick = (collectionName) => {
|
handleCreatePostClick = (collectionName) => {
|
||||||
const { onCreateEntryClick } = this.props;
|
const { onCreateEntryClick } = this.props;
|
||||||
if (onCreateEntryClick) {
|
if (onCreateEntryClick) {
|
||||||
@ -32,18 +27,6 @@ export default class AppHeader extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCreateButtonClick = () => {
|
|
||||||
this.setState({
|
|
||||||
createMenuActive: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCreateMenuHide = () => {
|
|
||||||
this.setState({
|
|
||||||
createMenuActive: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
@ -51,6 +34,7 @@ export default class AppHeader extends React.Component {
|
|||||||
runCommand,
|
runCommand,
|
||||||
toggleDrawer,
|
toggleDrawer,
|
||||||
onLogoutClick,
|
onLogoutClick,
|
||||||
|
openMediaLibrary,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const avatarStyle = {
|
const avatarStyle = {
|
||||||
@ -59,7 +43,6 @@ export default class AppHeader extends React.Component {
|
|||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
appBar: 'nc-appHeader-appBar',
|
appBar: 'nc-appHeader-appBar',
|
||||||
homeLink: 'nc-appHeader-homeLink',
|
|
||||||
iconMenu: 'nc-appHeader-iconMenu',
|
iconMenu: 'nc-appHeader-iconMenu',
|
||||||
icon: 'nc-appHeader-icon',
|
icon: 'nc-appHeader-icon',
|
||||||
leftIcon: 'nc-appHeader-leftIcon',
|
leftIcon: 'nc-appHeader-leftIcon',
|
||||||
@ -76,17 +59,14 @@ export default class AppHeader extends React.Component {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
leftIcon="menu"
|
leftIcon="menu"
|
||||||
onLeftIconClick={toggleDrawer}
|
onLeftIconClick={toggleDrawer}
|
||||||
onRightIconClick={this.handleRightIconClick}
|
|
||||||
>
|
>
|
||||||
<Link to="/" className="nc-appHeader-homeLink">
|
<Link to="/" className="nc-appHeader-button">
|
||||||
<FontIcon value="home" className="nc-appHeader-icon" />
|
<FontIcon value="home" className="nc-appHeader-icon" />
|
||||||
</Link>
|
</Link>
|
||||||
<IconMenu
|
<button onClick={openMediaLibrary} className="nc-appHeader-button">
|
||||||
theme={theme}
|
<FontIcon value="perm_media" className="nc-appHeader-icon" />
|
||||||
icon="add"
|
</button>
|
||||||
onClick={this.handleCreateButtonClick}
|
<IconMenu icon="add" theme={theme}>
|
||||||
onHide={this.handleCreateMenuHide}
|
|
||||||
>
|
|
||||||
{
|
{
|
||||||
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
@ -7,39 +7,15 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding: 20px 0 10px 0;
|
padding: 20px 0 10px 0;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
|
& input,
|
||||||
.nc-controlPane-control input,
|
& textarea,
|
||||||
.nc-controlPane-control textarea,
|
& select,
|
||||||
.nc-controlPane-control select,
|
& div[contenteditable=true] {
|
||||||
.nc-controlPane-control div[contenteditable=true] {
|
@apply --input;
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
margin: 0;
|
|
||||||
border: var(--textFieldBorder);
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
background-color: var(--controlBGColor);
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--textColor);
|
|
||||||
transition: border-color .3s ease;
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
border-color: var(--primaryColor);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nc-controlPane-control input,
|
|
||||||
.nc-controlPane-control textarea,
|
|
||||||
.nc-controlPane-control select {
|
|
||||||
font-family: var(--fontFamilyMono);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.nc-controlPane-label {
|
.nc-controlPane-label {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--controlLabelColor);
|
color: var(--controlLabelColor);
|
||||||
|
@ -24,7 +24,17 @@ export default class ControlPane extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
controlFor(field) {
|
controlFor(field) {
|
||||||
const { entry, fieldsMetaData, fieldsErrors, getAsset, onChange, onAddAsset, onRemoveAsset } = this.props;
|
const {
|
||||||
|
entry,
|
||||||
|
fieldsMetaData,
|
||||||
|
fieldsErrors,
|
||||||
|
mediaPaths,
|
||||||
|
getAsset,
|
||||||
|
onChange,
|
||||||
|
onOpenMediaLibrary,
|
||||||
|
onAddAsset,
|
||||||
|
onRemoveAsset
|
||||||
|
} = this.props;
|
||||||
const widget = resolveWidget(field.get('widget'));
|
const widget = resolveWidget(field.get('widget'));
|
||||||
const fieldName = field.get('name');
|
const fieldName = field.get('name');
|
||||||
const value = entry.getIn(['data', fieldName]);
|
const value = entry.getIn(['data', fieldName]);
|
||||||
@ -48,9 +58,11 @@ export default class ControlPane extends Component {
|
|||||||
controlComponent={widget.control}
|
controlComponent={widget.control}
|
||||||
field={field}
|
field={field}
|
||||||
value={value}
|
value={value}
|
||||||
|
mediaPaths={mediaPaths}
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
|
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
|
||||||
onValidate={this.props.onValidate.bind(this, fieldName)}
|
onValidate={this.props.onValidate.bind(this, fieldName)}
|
||||||
|
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||||
onAddAsset={onAddAsset}
|
onAddAsset={onAddAsset}
|
||||||
onRemoveAsset={onRemoveAsset}
|
onRemoveAsset={onRemoveAsset}
|
||||||
getAsset={getAsset}
|
getAsset={getAsset}
|
||||||
@ -87,7 +99,9 @@ ControlPane.propTypes = {
|
|||||||
fields: ImmutablePropTypes.list.isRequired,
|
fields: ImmutablePropTypes.list.isRequired,
|
||||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onValidate: PropTypes.func.isRequired,
|
onValidate: PropTypes.func.isRequired,
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
z-index: 1000;
|
z-index: 299;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@ -41,7 +41,7 @@
|
|||||||
height: 55px;
|
height: 55px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9999;
|
z-index: 299;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
@ -52,12 +52,14 @@ class EntryEditor extends Component {
|
|||||||
fields,
|
fields,
|
||||||
fieldsMetaData,
|
fieldsMetaData,
|
||||||
fieldsErrors,
|
fieldsErrors,
|
||||||
|
mediaPaths,
|
||||||
getAsset,
|
getAsset,
|
||||||
onChange,
|
onChange,
|
||||||
enableSave,
|
enableSave,
|
||||||
showDelete,
|
showDelete,
|
||||||
onDelete,
|
onDelete,
|
||||||
onValidate,
|
onValidate,
|
||||||
|
onOpenMediaLibrary,
|
||||||
onAddAsset,
|
onAddAsset,
|
||||||
onRemoveAsset,
|
onRemoveAsset,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
@ -102,9 +104,11 @@ class EntryEditor extends Component {
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsMetaData={fieldsMetaData}
|
fieldsMetaData={fieldsMetaData}
|
||||||
fieldsErrors={fieldsErrors}
|
fieldsErrors={fieldsErrors}
|
||||||
|
mediaPaths={mediaPaths}
|
||||||
getAsset={getAsset}
|
getAsset={getAsset}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onValidate={onValidate}
|
onValidate={onValidate}
|
||||||
|
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||||
onAddAsset={onAddAsset}
|
onAddAsset={onAddAsset}
|
||||||
onRemoveAsset={onRemoveAsset}
|
onRemoveAsset={onRemoveAsset}
|
||||||
ref={c => this.controlPaneRef = c} // eslint-disable-line
|
ref={c => this.controlPaneRef = c} // eslint-disable-line
|
||||||
@ -166,7 +170,9 @@ EntryEditor.propTypes = {
|
|||||||
fields: ImmutablePropTypes.list.isRequired,
|
fields: ImmutablePropTypes.list.isRequired,
|
||||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onValidate: PropTypes.func.isRequired,
|
onValidate: PropTypes.func.isRequired,
|
||||||
|
98
src/components/MediaLibrary/MediaLibrary.css
Normal file
98
src/components/MediaLibrary/MediaLibrary.css
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
@import './MediaLibraryFooter.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--mediaLibraryCardWidth: 300px;
|
||||||
|
--mediaLibraryCardMargin: 10px;
|
||||||
|
--mediaLibraryCardOutsideWidth: calc(var(--mediaLibraryCardWidth) + var(--mediaLibraryCardMargin) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-dialog {
|
||||||
|
width: calc(var(--mediaLibraryCardOutsideWidth) + 48px);
|
||||||
|
|
||||||
|
@media (width >= 800px) {
|
||||||
|
width: calc(var(--mediaLibraryCardOutsideWidth) * 2 + 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1120px) {
|
||||||
|
width: calc(var(--mediaLibraryCardOutsideWidth) * 3 + 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1440px) {
|
||||||
|
width: calc(var(--mediaLibraryCardOutsideWidth) * 4 + 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1760px) {
|
||||||
|
width: calc(var(--mediaLibraryCardOutsideWidth) * 5 + 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 2080px) {
|
||||||
|
width: calc(var(--mediaLibraryCardOutsideWidth) * 6 + 48px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-title {
|
||||||
|
position: absolute;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-searchInput {
|
||||||
|
@apply --input;
|
||||||
|
font-family: var(--fontFamilyPrimary);
|
||||||
|
width: 50%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 12px auto;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-emptyMessage {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-cardGrid-container {
|
||||||
|
height: calc(100% - 150px);
|
||||||
|
margin: 20px auto 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-cardGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-card {
|
||||||
|
width: var(--mediaLibraryCardWidth);
|
||||||
|
height: 240px;
|
||||||
|
margin: var(--mediaLibraryCardMargin);
|
||||||
|
border: var(--textFieldBorder);
|
||||||
|
border-radius: var(--borderRadius);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-card-selected {
|
||||||
|
border-color: var(--primaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-cardImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-cardText {
|
||||||
|
color: var(--textColor);
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
}
|
332
src/components/MediaLibrary/MediaLibrary.js
Normal file
332
src/components/MediaLibrary/MediaLibrary.js
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { orderBy, get, last, isEmpty, map } from 'lodash';
|
||||||
|
import c from 'classnames';
|
||||||
|
import fuzzy from 'fuzzy';
|
||||||
|
import Waypoint from 'react-waypoint';
|
||||||
|
import Dialog from '../UI/Dialog';
|
||||||
|
import { resolvePath } from '../../lib/pathHelper';
|
||||||
|
import { changeDraftField } from '../../actions/entries';
|
||||||
|
import {
|
||||||
|
loadMedia as loadMediaAction,
|
||||||
|
persistMedia as persistMediaAction,
|
||||||
|
deleteMedia as deleteMediaAction,
|
||||||
|
insertMedia as insertMediaAction,
|
||||||
|
closeMediaLibrary as closeMediaLibraryAction,
|
||||||
|
} from '../../actions/mediaLibrary';
|
||||||
|
import MediaLibraryFooter from './MediaLibraryFooter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions used to determine which files to show when the media library is
|
||||||
|
* accessed from an image insertion field.
|
||||||
|
*/
|
||||||
|
const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff' ];
|
||||||
|
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE, 'svg' ];
|
||||||
|
|
||||||
|
class MediaLibrary extends React.Component {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently selected file and query are tracked in component state as
|
||||||
|
* they do not impact the rest of the application.
|
||||||
|
*/
|
||||||
|
state = {
|
||||||
|
selectedFile: {},
|
||||||
|
query: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.loadMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
/**
|
||||||
|
* We clear old state from the media library when it's being re-opened
|
||||||
|
* because, when doing so on close, the state is cleared while the media
|
||||||
|
* library is still fading away.
|
||||||
|
*/
|
||||||
|
const isOpening = !this.props.isVisible && nextProps.isVisible;
|
||||||
|
if (isOpening) {
|
||||||
|
this.setState({ selectedFile: {}, query: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter an array of file data to include only images.
|
||||||
|
*/
|
||||||
|
filterImages = files => {
|
||||||
|
return files ? files.filter(file => IMAGE_EXTENSIONS.includes(last(file.name.split('.')))) : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform file data for table display.
|
||||||
|
*/
|
||||||
|
toTableData = files => {
|
||||||
|
const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
|
||||||
|
const ext = last(name.split('.'));
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
type: ext.toUpperCase(),
|
||||||
|
size,
|
||||||
|
queryOrder,
|
||||||
|
url,
|
||||||
|
urlIsPublicPath,
|
||||||
|
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||||
|
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sort order for use with `lodash.orderBy`, and always add the
|
||||||
|
* `queryOrder` sort as the lowest priority sort order.
|
||||||
|
*/
|
||||||
|
const { sortFields } = this.state;
|
||||||
|
const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
|
||||||
|
const directions = map(sortFields, 'direction').concat('asc');
|
||||||
|
return orderBy(tableData, fieldNames, directions);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClose = () => {
|
||||||
|
this.props.closeMediaLibrary();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle asset selection on click.
|
||||||
|
*/
|
||||||
|
handleAssetClick = asset => {
|
||||||
|
const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset;
|
||||||
|
this.setState({ selectedFile });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file.
|
||||||
|
*/
|
||||||
|
handlePersist = async event => {
|
||||||
|
/**
|
||||||
|
* Stop the browser from automatically handling the file input click, and
|
||||||
|
* get the file for upload.
|
||||||
|
*/
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const { loadMedia, persistMedia, privateUpload } = this.props;
|
||||||
|
const { files: fileList } = event.dataTransfer || event.target;
|
||||||
|
const files = [...fileList];
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the selected file, then refresh the media library. This should be
|
||||||
|
* improved in the future, but isn't currently resulting in noticeable
|
||||||
|
* performance/load time issues.
|
||||||
|
*/
|
||||||
|
await persistMedia(file, privateUpload);
|
||||||
|
this.scrollToTop();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the public path of the file in the application store, where the
|
||||||
|
* editor field that launched the media library can retrieve it.
|
||||||
|
*/
|
||||||
|
handleInsert = () => {
|
||||||
|
const { selectedFile } = this.state;
|
||||||
|
const { name, url, urlIsPublicPath } = selectedFile;
|
||||||
|
const { insertMedia, publicFolder } = this.props;
|
||||||
|
const publicPath = urlIsPublicPath ? url : resolvePath(name, publicFolder);
|
||||||
|
insertMedia(publicPath);
|
||||||
|
this.handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the selected file from the backend.
|
||||||
|
*/
|
||||||
|
handleDelete = () => {
|
||||||
|
const { selectedFile } = this.state;
|
||||||
|
const { files, deleteMedia } = this.props;
|
||||||
|
if (!window.confirm('Are you sure you want to delete selected media?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = files.find(file => selectedFile.key === file.key);
|
||||||
|
deleteMedia(file)
|
||||||
|
.then(() => {
|
||||||
|
this.setState({ selectedFile: {} });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLoadMore = () => {
|
||||||
|
const { loadMedia, dynamicSearchQuery, page } = this.props;
|
||||||
|
loadMedia({ query: dynamicSearchQuery, page: page + 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes media library search for implementations that support dynamic
|
||||||
|
* search via request. For these implementations, the Enter key must be
|
||||||
|
* pressed to execute search. If assets are being stored directly through
|
||||||
|
* the GitHub backend, search is in-memory and occurs as the query is typed,
|
||||||
|
* so this handler has no impact.
|
||||||
|
*/
|
||||||
|
handleSearchKeyDown = async (event) => {
|
||||||
|
if (event.key === 'Enter' && this.props.dynamicSearch) {
|
||||||
|
await this.props.loadMedia({ query: this.state.query })
|
||||||
|
this.scrollToTop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToTop = () => {
|
||||||
|
this.scrollContainerRef.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates query state as the user types in the search field.
|
||||||
|
*/
|
||||||
|
handleSearchChange = event => {
|
||||||
|
this.setState({ query: event.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters files that do not match the query. Not used for dynamic search.
|
||||||
|
*/
|
||||||
|
queryFilter = (query, files) => {
|
||||||
|
/**
|
||||||
|
* Because file names don't have spaces, typing a space eliminates all
|
||||||
|
* potential matches, so we strip them all out internally before running the
|
||||||
|
* query.
|
||||||
|
*/
|
||||||
|
const strippedQuery = query.replace(/ /g, '');
|
||||||
|
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
|
||||||
|
const matchFiles = matches.map((match, queryIndex) => {
|
||||||
|
const file = files[match.index];
|
||||||
|
return { ...file, queryIndex };
|
||||||
|
});
|
||||||
|
return matchFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isVisible,
|
||||||
|
canInsert,
|
||||||
|
files,
|
||||||
|
dynamicSearch,
|
||||||
|
dynamicSearchActive,
|
||||||
|
forImage,
|
||||||
|
isLoading,
|
||||||
|
isPersisting,
|
||||||
|
isDeleting,
|
||||||
|
hasNextPage,
|
||||||
|
page,
|
||||||
|
isPaginating,
|
||||||
|
} = this.props;
|
||||||
|
const { query, selectedFile } = this.state;
|
||||||
|
const filteredFiles = forImage ? this.filterImages(files) : files;
|
||||||
|
const queriedFiles = (!dynamicSearch && query) ? this.queryFilter(query, filteredFiles) : filteredFiles;
|
||||||
|
const tableData = this.toTableData(queriedFiles);
|
||||||
|
const hasFiles = files && !!files.length;
|
||||||
|
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
|
||||||
|
const hasSearchResults = queriedFiles && !!queriedFiles.length;
|
||||||
|
const hasMedia = hasSearchResults;
|
||||||
|
const shouldShowEmptyMessage = !hasMedia;
|
||||||
|
const emptyMessage = (isLoading && !hasMedia && 'Loading...')
|
||||||
|
|| (dynamicSearchActive && 'No results.')
|
||||||
|
|| (!hasFiles && 'No assets found.')
|
||||||
|
|| (!hasFilteredFiles && 'No images found.')
|
||||||
|
|| (!hasSearchResults && 'No results.');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isVisible={isVisible}
|
||||||
|
onClose={this.handleClose}
|
||||||
|
className="nc-mediaLibrary-dialog"
|
||||||
|
footer={
|
||||||
|
<MediaLibraryFooter
|
||||||
|
onDelete={this.handleDelete}
|
||||||
|
onPersist={this.handlePersist}
|
||||||
|
onClose={this.handleClose}
|
||||||
|
onInsert={this.handleInsert}
|
||||||
|
hasSelection={hasMedia && !isEmpty(selectedFile)}
|
||||||
|
forImage={forImage}
|
||||||
|
canInsert={canInsert}
|
||||||
|
isPersisting={isPersisting}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 className="nc-mediaLibrary-title">{forImage ? 'Images' : 'Assets'}</h1>
|
||||||
|
<input
|
||||||
|
className="nc-mediaLibrary-searchInput"
|
||||||
|
value={query}
|
||||||
|
onChange={this.handleSearchChange}
|
||||||
|
onKeyDown={event => this.handleSearchKeyDown(event)}
|
||||||
|
placeholder="Search..."
|
||||||
|
disabled={!dynamicSearchActive && !hasFilteredFiles}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="nc-mediaLibrary-cardGrid-container" ref={ref => (this.scrollContainerRef = ref)}>
|
||||||
|
<div className="nc-mediaLibrary-cardGrid">
|
||||||
|
{
|
||||||
|
tableData.map((file, idx) =>
|
||||||
|
<div
|
||||||
|
key={file.key}
|
||||||
|
className={c('nc-mediaLibrary-card', { 'nc-mediaLibrary-card-selected': selectedFile.key === file.key })}
|
||||||
|
onClick={() => this.handleAssetClick(file)}
|
||||||
|
tabIndex="-1"
|
||||||
|
>
|
||||||
|
<div className="nc-mediaLibrary-cardImage-container">
|
||||||
|
{
|
||||||
|
file.isViewableImage
|
||||||
|
? <img src={file.url} className="nc-mediaLibrary-cardImage"/>
|
||||||
|
: <div className="nc-mediaLibrary-cardImage"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p className="nc-mediaLibrary-cardText">{file.name}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hasNextPage
|
||||||
|
? <Waypoint onEnter={() => this.handleLoadMore()}/>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
shouldShowEmptyMessage
|
||||||
|
? <div className="nc-mediaLibrary-emptyMessage"><h1>{emptyMessage}</h1></div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{ isPaginating ? <h1 className="nc-mediaLibrary-paginatingMessage">Loading...</h1> : null }
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
const { config, mediaLibrary } = state;
|
||||||
|
const configProps = {
|
||||||
|
publicFolder: config.get('public_folder'),
|
||||||
|
};
|
||||||
|
const mediaLibraryProps = {
|
||||||
|
isVisible: mediaLibrary.get('isVisible'),
|
||||||
|
canInsert: mediaLibrary.get('canInsert'),
|
||||||
|
files: mediaLibrary.get('files'),
|
||||||
|
dynamicSearch: mediaLibrary.get('dynamicSearch'),
|
||||||
|
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
|
||||||
|
dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'),
|
||||||
|
forImage: mediaLibrary.get('forImage'),
|
||||||
|
isLoading: mediaLibrary.get('isLoading'),
|
||||||
|
isPersisting: mediaLibrary.get('isPersisting'),
|
||||||
|
isDeleting: mediaLibrary.get('isDeleting'),
|
||||||
|
privateUpload: mediaLibrary.get('privateUpload'),
|
||||||
|
page: mediaLibrary.get('page'),
|
||||||
|
hasNextPage: mediaLibrary.get('hasNextPage'),
|
||||||
|
isPaginating: mediaLibrary.get('isPaginating'),
|
||||||
|
};
|
||||||
|
return { ...configProps, ...mediaLibraryProps };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
loadMedia: loadMediaAction,
|
||||||
|
persistMedia: persistMediaAction,
|
||||||
|
deleteMedia: deleteMediaAction,
|
||||||
|
insertMedia: insertMediaAction,
|
||||||
|
closeMediaLibrary: closeMediaLibraryAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(MediaLibrary);
|
25
src/components/MediaLibrary/MediaLibraryFooter.css
Normal file
25
src/components/MediaLibrary/MediaLibraryFooter.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.nc-mediaLibrary-footer-buttonRight {
|
||||||
|
float: right;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-footer-buttonLeft {
|
||||||
|
float: left;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-footer-button-loader {
|
||||||
|
float: left;
|
||||||
|
margin: 8px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-footer-button-loaderSpinner {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-mediaLibrary-footer-button-loaderText {
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
|
|
64
src/components/MediaLibrary/MediaLibraryFooter.js
Normal file
64
src/components/MediaLibrary/MediaLibraryFooter.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, BrowseButton } from 'react-toolbox/lib/button';
|
||||||
|
import Loader from '../UI/loader/Loader';
|
||||||
|
|
||||||
|
const MediaLibraryFooter = ({
|
||||||
|
onDelete,
|
||||||
|
onPersist,
|
||||||
|
onClose,
|
||||||
|
onInsert,
|
||||||
|
hasSelection,
|
||||||
|
forImage,
|
||||||
|
canInsert,
|
||||||
|
isPersisting,
|
||||||
|
isDeleting,
|
||||||
|
}) => {
|
||||||
|
const shouldShowLoader = isPersisting || isDeleting;
|
||||||
|
const loaderText = isPersisting ? 'Uploading...' : 'Deleting...';
|
||||||
|
const loader = (
|
||||||
|
<div className="nc-mediaLibrary-footer-button-loader">
|
||||||
|
<Loader className="nc-mediaLibrary-footer-button-loaderSpinner" active/>
|
||||||
|
<strong className="nc-mediaLibrary-footer-button-loaderText">{loaderText}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
label="Delete"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="nc-mediaLibrary-footer-buttonLeft"
|
||||||
|
disabled={shouldShowLoader || !hasSelection}
|
||||||
|
accent
|
||||||
|
raised
|
||||||
|
/>
|
||||||
|
<BrowseButton
|
||||||
|
label="Upload"
|
||||||
|
accept={forImage}
|
||||||
|
onChange={onPersist}
|
||||||
|
className="nc-mediaLibrary-footer-buttonLeft"
|
||||||
|
disabled={shouldShowLoader}
|
||||||
|
primary
|
||||||
|
raised
|
||||||
|
/>
|
||||||
|
{ shouldShowLoader ? loader : null }
|
||||||
|
<Button
|
||||||
|
label="Close"
|
||||||
|
onClick={onClose}
|
||||||
|
className="nc-mediaLibrary-footer-buttonRight"
|
||||||
|
raised
|
||||||
|
/>
|
||||||
|
{ !canInsert ? null :
|
||||||
|
<Button
|
||||||
|
label="Insert"
|
||||||
|
onClick={onInsert}
|
||||||
|
className="nc-mediaLibrary-footer-buttonRight"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
primary
|
||||||
|
raised
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MediaLibraryFooter;
|
52
src/components/UI/Dialog/Dialog.css
Normal file
52
src/components/UI/Dialog/Dialog.css
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
.nc-dialog-wrapper {
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-root {
|
||||||
|
height: 80%;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 2200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-contentWrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-footer {
|
||||||
|
margin: 24px 0;
|
||||||
|
width: calc(100% - 48px);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress Bar
|
||||||
|
*/
|
||||||
|
.nc-dialog-progressOverlay {
|
||||||
|
background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
z-index: 1;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-progressOverlay-active {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-progressBarContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-dialog-progressBar-linear {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
16
src/components/UI/Dialog/FocusTrap.js
Normal file
16
src/components/UI/Dialog/FocusTrap.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import FocusTrapReact from 'focus-trap-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper for focus-trap-react, which we use to completely remove focus traps
|
||||||
|
* from the DOM rather than using the library's own internal activation/pausing
|
||||||
|
* mechanisms, which can manifest bugs when nested.
|
||||||
|
*/
|
||||||
|
const FocusTrap = props => {
|
||||||
|
const { active, children, focusTrapOptions, className } = props;
|
||||||
|
return active
|
||||||
|
? <FocusTrapReact focusTrapOptions={focusTrapOptions} className={className}>{children}</FocusTrapReact>
|
||||||
|
: <div className={className}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FocusTrap;
|
65
src/components/UI/Dialog/index.js
Normal file
65
src/components/UI/Dialog/index.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import RTDialog from 'react-toolbox/lib/dialog';
|
||||||
|
import Overlay from 'react-toolbox/lib/overlay';
|
||||||
|
import ProgressBar from 'react-toolbox/lib/progress_bar';
|
||||||
|
import FocusTrap from './FocusTrap';
|
||||||
|
|
||||||
|
const dialogTheme = {
|
||||||
|
wrapper: 'nc-dialog-wrapper',
|
||||||
|
dialog: 'nc-dialog-root',
|
||||||
|
body: 'nc-dialog-body',
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressOverlayTheme = {
|
||||||
|
overlay: 'nc-dialog-progressOverlay',
|
||||||
|
active: 'nc-dialog-progressOverlay-active',
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressBarTheme = {
|
||||||
|
linear: 'nc-dialog-progressBar-linear',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dialog = ({
|
||||||
|
type,
|
||||||
|
isVisible,
|
||||||
|
isLoading,
|
||||||
|
loadingMessage,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}) =>
|
||||||
|
<RTDialog
|
||||||
|
type={type || 'large'}
|
||||||
|
active={isVisible}
|
||||||
|
onEscKeyDown={onClose}
|
||||||
|
onOverlayClick={onClose}
|
||||||
|
theme={dialogTheme}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<FocusTrap
|
||||||
|
active={isVisible && !isLoading}
|
||||||
|
focusTrapOptions={{ clickOutsideDeactivates: true, fallbackFocus: '.fallbackFocus' }}
|
||||||
|
className="nc-dialog-contentWrapper"
|
||||||
|
>
|
||||||
|
<Overlay active={isLoading} theme={progressOverlayTheme}>
|
||||||
|
<FocusTrap
|
||||||
|
active={isVisible && isLoading}
|
||||||
|
focusTrapOptions={{ clickOutsideDeactivates: true, initialFocus: 'h1' }}
|
||||||
|
className="nc-dialog-progressBarContainer"
|
||||||
|
>
|
||||||
|
<h1 style={{ marginTop: '-40px' }} tabIndex="-1">{ loadingMessage }</h1>
|
||||||
|
<ProgressBar type="linear" mode="indeterminate" theme={progressBarTheme}/>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
|
||||||
|
<div className="fallbackFocus" className="nc-dialog-contentWrapper">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ footer ? <div className="nc-dialog-footer">{footer}</div> : null }
|
||||||
|
|
||||||
|
</FocusTrap>
|
||||||
|
</RTDialog>;
|
||||||
|
|
||||||
|
export default Dialog;
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import CSSTransition from 'react-transition-group/CSSTransition';
|
import CSSTransition from 'react-transition-group/CSSTransition';
|
||||||
import classnames from 'classnames';
|
import c from 'classnames';
|
||||||
|
|
||||||
export default class Loader extends React.Component {
|
export default class Loader extends React.Component {
|
||||||
|
|
||||||
@ -50,8 +50,8 @@ export default class Loader extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { active } = this.props;
|
const { active, className } = this.props;
|
||||||
const className = classnames('nc-loader-root', { 'nc-loader-active': active });
|
const combinedClassName = c('nc-loader-root', { 'nc-loader-active': active }, className);
|
||||||
return <div className={className}>{this.renderChild()}</div>;
|
return <div className={combinedClassName}>{this.renderChild()}</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
--fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
--fontFamilyPrimary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
--fontFamilyMono: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
--fontFamilyMono: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||||
--defaultColor: #333;
|
--defaultColor: #333;
|
||||||
--defaultColorLight: #fff;
|
--defaultColorLight: #fff;
|
||||||
@ -32,6 +32,27 @@
|
|||||||
--borderWidth: 2px;
|
--borderWidth: 2px;
|
||||||
--border: solid var(--borderWidth) var(--secondaryColor);
|
--border: solid var(--borderWidth) var(--secondaryColor);
|
||||||
--textFieldBorder: var(--border);
|
--textFieldBorder: var(--border);
|
||||||
|
|
||||||
|
--input: {
|
||||||
|
font-family: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0;
|
||||||
|
border: var(--textFieldBorder);
|
||||||
|
border-radius: var(--borderRadius);
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: var(--controlBGColor);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--textColor);
|
||||||
|
transition: border-color .3s ease;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
border-color: var(--primaryColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nc-theme-base {
|
.nc-theme-base {
|
||||||
|
@ -16,21 +16,36 @@ class ControlHOC extends Component {
|
|||||||
PropTypes.string,
|
PropTypes.string,
|
||||||
PropTypes.bool,
|
PropTypes.bool,
|
||||||
]),
|
]),
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
metadata: ImmutablePropTypes.map,
|
metadata: ImmutablePropTypes.map,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onValidate: PropTypes.func.isRequired,
|
onValidate: PropTypes.func,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
|
/**
|
||||||
|
* Allow widgets to provide their own `shouldComponentUpdate` method.
|
||||||
|
*/
|
||||||
|
if (this.wrappedControlShouldComponentUpdate) {
|
||||||
|
return this.wrappedControlShouldComponentUpdate(nextProps);
|
||||||
|
}
|
||||||
return this.props.value !== nextProps.value;
|
return this.props.value !== nextProps.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
processInnerControlRef = (wrappedControl) => {
|
processInnerControlRef = (wrappedControl) => {
|
||||||
if (!wrappedControl) return;
|
if (!wrappedControl) return;
|
||||||
this.wrappedControlValid = wrappedControl.isValid || truthy;
|
this.wrappedControlValid = wrappedControl.isValid || truthy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the `shouldComponentUpdate` method from the wrapped control, and
|
||||||
|
* provide the control instance is the `this` binding.
|
||||||
|
*/
|
||||||
|
const { shouldComponentUpdate: scu } = wrappedControl;
|
||||||
|
this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl);
|
||||||
};
|
};
|
||||||
|
|
||||||
validate = (skipWrapped = false) => {
|
validate = (skipWrapped = false) => {
|
||||||
@ -113,12 +128,25 @@ class ControlHOC extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { controlComponent, field, value, metadata, onChange, onAddAsset, onRemoveAsset, getAsset } = this.props;
|
const {
|
||||||
|
controlComponent,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
mediaPaths,
|
||||||
|
metadata,
|
||||||
|
onChange,
|
||||||
|
onOpenMediaLibrary,
|
||||||
|
onAddAsset,
|
||||||
|
onRemoveAsset,
|
||||||
|
getAsset
|
||||||
|
} = this.props;
|
||||||
return React.createElement(controlComponent, {
|
return React.createElement(controlComponent, {
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
mediaPaths,
|
||||||
metadata,
|
metadata,
|
||||||
onChange,
|
onChange,
|
||||||
|
onOpenMediaLibrary,
|
||||||
onAddAsset,
|
onAddAsset,
|
||||||
onRemoveAsset,
|
onRemoveAsset,
|
||||||
getAsset,
|
getAsset,
|
||||||
|
@ -1,115 +1,76 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||||
|
import { get } from 'lodash';
|
||||||
|
import uuid from 'uuid/v4';
|
||||||
import { truncateMiddle } from '../../lib/textHelper';
|
import { truncateMiddle } from '../../lib/textHelper';
|
||||||
import { Loader } from '../UI';
|
|
||||||
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||||
|
|
||||||
const MAX_DISPLAY_LENGTH = 50;
|
const MAX_DISPLAY_LENGTH = 50;
|
||||||
|
|
||||||
export default class FileControl extends React.Component {
|
export default class FileControl extends React.Component {
|
||||||
state = {
|
constructor(props) {
|
||||||
processing: false,
|
super(props);
|
||||||
};
|
this.controlID = uuid();
|
||||||
|
}
|
||||||
|
|
||||||
promise = null;
|
|
||||||
|
|
||||||
isValid = () => {
|
shouldComponentUpdate(nextProps) {
|
||||||
if (this.promise) {
|
/**
|
||||||
return this.promise;
|
* Always update if the value changes.
|
||||||
|
*/
|
||||||
|
if (this.props.value !== nextProps.value) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return { error: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is a media path for this control in the state object, and that
|
||||||
|
* path is different than the value in `nextProps`, update.
|
||||||
|
*/
|
||||||
|
const mediaPath = nextProps.mediaPaths.get(this.controlID);
|
||||||
|
if (mediaPath && (nextProps.value !== mediaPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
handleFileInputRef = (el) => {
|
return false;
|
||||||
this._fileInput = el;
|
}
|
||||||
};
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
const { mediaPaths, value } = nextProps;
|
||||||
|
const mediaPath = mediaPaths.get(this.controlID);
|
||||||
|
if (mediaPath && mediaPath !== value) {
|
||||||
|
this.props.onChange(mediaPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleClick = (e) => {
|
handleClick = (e) => {
|
||||||
this._fileInput.click();
|
const { field, onOpenMediaLibrary } = this.props;
|
||||||
};
|
return onOpenMediaLibrary({ controlID: this.controlID, privateUpload: field.private });
|
||||||
|
|
||||||
handleDragEnter = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDragOver = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
|
|
||||||
const files = [...fileList];
|
|
||||||
const imageType = /^image\//;
|
|
||||||
|
|
||||||
// Return the first file on the list
|
|
||||||
const file = files[0];
|
|
||||||
|
|
||||||
this.props.onRemoveAsset(this.props.value);
|
|
||||||
if (file) {
|
|
||||||
this.setState({ processing: true });
|
|
||||||
this.promise = createAssetProxy(file.name, file, false, this.props.field.get('private', false))
|
|
||||||
.then((assetProxy) => {
|
|
||||||
this.setState({ processing: false });
|
|
||||||
this.props.onAddAsset(assetProxy);
|
|
||||||
this.props.onChange(assetProxy.public_path);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.props.onChange(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
renderFileName = () => {
|
renderFileName = () => {
|
||||||
if (!this.props.value) return null;
|
const { value } = this.props;
|
||||||
if (this.value instanceof AssetProxy) {
|
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
|
||||||
return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
|
|
||||||
} else {
|
|
||||||
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { processing } = this.state;
|
|
||||||
const fileName = this.renderFileName();
|
const fileName = this.renderFileName();
|
||||||
if (processing) {
|
|
||||||
return (
|
|
||||||
<div className="nc-fileControl-imageUpload">
|
|
||||||
<span className="nc-fileControl-message">
|
|
||||||
<Loader active />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="nc-fileControl-imageUpload">
|
||||||
className="nc-fileControl-imageUpload"
|
|
||||||
onDragEnter={this.handleDragEnter}
|
|
||||||
onDragOver={this.handleDragOver}
|
|
||||||
onDrop={this.handleChange}
|
|
||||||
>
|
|
||||||
<span className="nc-fileControl-message" onClick={this.handleClick}>
|
<span className="nc-fileControl-message" onClick={this.handleClick}>
|
||||||
{fileName ? fileName : 'Click here to upload a file from your computer, or drag and drop a file directly into this box'}
|
{fileName ? fileName : 'Click here to select an asset from the asset library'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={this.handleChange}
|
|
||||||
className="nc-fileControl-input"
|
|
||||||
ref={this.handleFileInputRef}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FileControl.propTypes = {
|
FileControl.propTypes = {
|
||||||
|
field: PropTypes.object.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
value: PropTypes.node,
|
value: PropTypes.node,
|
||||||
field: PropTypes.object,
|
|
||||||
};
|
};
|
||||||
|
@ -1,119 +1,74 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||||
|
import uuid from 'uuid/v4';
|
||||||
import { truncateMiddle } from '../../lib/textHelper';
|
import { truncateMiddle } from '../../lib/textHelper';
|
||||||
import { Loader } from '../UI';
|
|
||||||
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||||
|
|
||||||
const MAX_DISPLAY_LENGTH = 50;
|
const MAX_DISPLAY_LENGTH = 50;
|
||||||
|
|
||||||
export default class ImageControl extends React.Component {
|
export default class ImageControl extends React.Component {
|
||||||
state = {
|
constructor(props) {
|
||||||
processing: false,
|
super(props);
|
||||||
};
|
this.controlID = uuid();
|
||||||
|
}
|
||||||
|
|
||||||
promise = null;
|
shouldComponentUpdate(nextProps) {
|
||||||
|
/**
|
||||||
isValid = () => {
|
* Always update if the value changes.
|
||||||
if (this.promise) {
|
*/
|
||||||
return this.promise;
|
if (this.props.value !== nextProps.value) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return { error: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is a media path for this control in the state object, and that
|
||||||
|
* path is different than the value in `nextProps`, update.
|
||||||
|
*/
|
||||||
|
const mediaPath = nextProps.mediaPaths.get(this.controlID);
|
||||||
|
if (mediaPath && (nextProps.value !== mediaPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
handleFileInputRef = (el) => {
|
return false;
|
||||||
this._fileInput = el;
|
}
|
||||||
};
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
const { mediaPaths, value } = nextProps;
|
||||||
|
const mediaPath = mediaPaths.get(this.controlID);
|
||||||
|
if (mediaPath && mediaPath !== value) {
|
||||||
|
this.props.onChange(mediaPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleClick = (e) => {
|
handleClick = (e) => {
|
||||||
this._fileInput.click();
|
const { field, onOpenMediaLibrary } = this.props;
|
||||||
|
return onOpenMediaLibrary({ controlID: this.controlID, forImage: true, privateUpload: field.private });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDragEnter = (e) => {
|
renderFileName = () => {
|
||||||
e.stopPropagation();
|
const { value } = this.props;
|
||||||
e.preventDefault();
|
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
|
||||||
};
|
|
||||||
|
|
||||||
handleDragOver = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
|
|
||||||
const files = [...fileList];
|
|
||||||
const imageType = /^image\//;
|
|
||||||
|
|
||||||
// Iterate through the list of files and return the first image on the list
|
|
||||||
const file = files.find((currentFile) => {
|
|
||||||
if (imageType.test(currentFile.type)) {
|
|
||||||
return currentFile;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.props.onRemoveAsset(this.props.value);
|
|
||||||
if (file) {
|
|
||||||
this.setState({ processing: true });
|
|
||||||
this.promise = createAssetProxy(file.name, file)
|
|
||||||
.then((assetProxy) => {
|
|
||||||
this.setState({ processing: false });
|
|
||||||
this.props.onAddAsset(assetProxy);
|
|
||||||
this.props.onChange(assetProxy.public_path);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.props.onChange(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
renderImageName = () => {
|
|
||||||
if (!this.props.value) return null;
|
|
||||||
if (this.value instanceof AssetProxy) {
|
|
||||||
return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
|
|
||||||
} else {
|
|
||||||
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { processing } = this.state;
|
const fileName = this.renderFileName();
|
||||||
const imageName = this.renderImageName();
|
|
||||||
if (processing) {
|
|
||||||
return (
|
|
||||||
<div className="nc-fileControl-imageUpload">
|
|
||||||
<span className="nc-fileControl-message">
|
|
||||||
<Loader active />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="nc-fileControl-imageUpload">
|
||||||
className="nc-fileControl-imageUpload"
|
|
||||||
onDragEnter={this.handleDragEnter}
|
|
||||||
onDragOver={this.handleDragOver}
|
|
||||||
onDrop={this.handleChange}
|
|
||||||
>
|
|
||||||
<span className="nc-fileControl-message" onClick={this.handleClick}>
|
<span className="nc-fileControl-message" onClick={this.handleClick}>
|
||||||
{imageName ? imageName : 'Click here to upload an image from your computer, or drag and drop a file directly into this box'}
|
{fileName ? fileName : 'Click here to select an image from the image library'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={this.handleChange}
|
|
||||||
className="nc-fileControl-input"
|
|
||||||
ref={this.handleFileInputRef}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageControl.propTypes = {
|
ImageControl.propTypes = {
|
||||||
|
field: PropTypes.object.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
value: PropTypes.node,
|
value: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { List, Map, fromJS } from 'immutable';
|
import { List, Map, fromJS } from 'immutable';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
||||||
import FontIcon from 'react-toolbox/lib/font_icon';
|
import FontIcon from 'react-toolbox/lib/font_icon';
|
||||||
import ObjectControl from './ObjectControl';
|
import ObjectControl from './ObjectControl';
|
||||||
@ -36,7 +37,9 @@ export default class ListControl extends Component {
|
|||||||
value: PropTypes.node,
|
value: PropTypes.node,
|
||||||
field: PropTypes.node,
|
field: PropTypes.node,
|
||||||
forID: PropTypes.string,
|
forID: PropTypes.string,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
@ -47,6 +50,16 @@ export default class ListControl extends Component {
|
|||||||
this.valueType = null;
|
this.valueType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always update so that each nested widget has the option to update. This is
|
||||||
|
* required because ControlHOC provides a default `shouldComponentUpdate`
|
||||||
|
* which only updates if the value changes, but every widget must be allowed
|
||||||
|
* to override this.
|
||||||
|
*/
|
||||||
|
shouldComponentUpdate() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { field } = this.props;
|
const { field } = this.props;
|
||||||
if (field.get('fields')) {
|
if (field.get('fields')) {
|
||||||
@ -147,7 +160,7 @@ export default class ListControl extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderItem = (item, index) => {
|
renderItem = (item, index) => {
|
||||||
const { field, getAsset, onAddAsset, onRemoveAsset } = this.props;
|
const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props;
|
||||||
const { itemsCollapsed } = this.state;
|
const { itemsCollapsed } = this.state;
|
||||||
const collapsed = itemsCollapsed.get(index);
|
const collapsed = itemsCollapsed.get(index);
|
||||||
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
|
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
|
||||||
@ -167,6 +180,8 @@ export default class ListControl extends Component {
|
|||||||
className="nc-listControl-objectControl"
|
className="nc-listControl-objectControl"
|
||||||
onChange={this.handleChangeFor(index)}
|
onChange={this.handleChangeFor(index)}
|
||||||
getAsset={getAsset}
|
getAsset={getAsset}
|
||||||
|
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||||
|
mediaPaths={mediaPaths}
|
||||||
onAddAsset={onAddAsset}
|
onAddAsset={onAddAsset}
|
||||||
onRemoveAsset={onRemoveAsset}
|
onRemoveAsset={onRemoveAsset}
|
||||||
/>
|
/>
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||||
|
import { connect } from 'react-redux';
|
||||||
import { Map } from 'immutable';
|
import { Map } from 'immutable';
|
||||||
import { Button } from 'react-toolbox/lib/button';
|
import { Button } from 'react-toolbox/lib/button';
|
||||||
|
import { openMediaLibrary } from '../../../../../actions/mediaLibrary';
|
||||||
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
|
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
|
||||||
|
|
||||||
export default class ToolbarPluginForm extends React.Component {
|
class ToolbarPluginForm extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
plugin: PropTypes.object.isRequired,
|
plugin: PropTypes.object.isRequired,
|
||||||
onSubmit: PropTypes.func.isRequired,
|
onSubmit: PropTypes.func.isRequired,
|
||||||
@ -12,6 +15,8 @@ export default class ToolbarPluginForm extends React.Component {
|
|||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -37,6 +42,8 @@ export default class ToolbarPluginForm extends React.Component {
|
|||||||
onRemoveAsset,
|
onRemoveAsset,
|
||||||
getAsset,
|
getAsset,
|
||||||
onChange,
|
onChange,
|
||||||
|
onOpenMediaLibrary,
|
||||||
|
mediaPaths,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -54,6 +61,8 @@ export default class ToolbarPluginForm extends React.Component {
|
|||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
this.setState({ data: this.state.data.set(field.get('name'), val) });
|
this.setState({ data: this.state.data.set(field.get('name'), val) });
|
||||||
}}
|
}}
|
||||||
|
mediaPaths={mediaPaths}
|
||||||
|
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -66,3 +75,13 @@ export default class ToolbarPluginForm extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
mediaPaths: state.mediaLibrary.get('controlMedia'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
onOpenMediaLibrary: openMediaLibrary,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(ToolbarPluginForm);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||||
import { resolveWidget } from '../../../../Widgets';
|
import { resolveWidget } from '../../../../Widgets';
|
||||||
|
|
||||||
const ToolbarPluginFormControl = ({
|
const ToolbarPluginFormControl = ({
|
||||||
@ -10,11 +11,13 @@ const ToolbarPluginFormControl = ({
|
|||||||
onRemoveAsset,
|
onRemoveAsset,
|
||||||
getAsset,
|
getAsset,
|
||||||
onChange,
|
onChange,
|
||||||
|
mediaPaths,
|
||||||
|
onOpenMediaLibrary,
|
||||||
}) => {
|
}) => {
|
||||||
const widget = resolveWidget(field.get('widget') || 'string');
|
const widget = resolveWidget(field.get('widget') || 'string');
|
||||||
const key = `field-${ field.get('name') }`;
|
const key = `field-${ field.get('name') }`;
|
||||||
const Control = widget.control;
|
const Control = widget.control;
|
||||||
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange };
|
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange, mediaPaths, onOpenMediaLibrary };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="nc-controlPane-control" key={key}>
|
<div className="nc-controlPane-control" key={key}>
|
||||||
@ -34,6 +37,8 @@ ToolbarPluginFormControl.propTypes = {
|
|||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ToolbarPluginFormControl;
|
export default ToolbarPluginFormControl;
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
min-height: var(--richTextEditorMinHeight);
|
min-height: var(--richTextEditorMinHeight);
|
||||||
font-family: var(--fontFamily);
|
font-family: var(--fontFamilyPrimary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nc-visualEditor-editor h1 {
|
.nc-visualEditor-editor h1 {
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Map } from 'immutable';
|
import { Map } from 'immutable';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ControlHOC from './ControlHOC';
|
||||||
import { resolveWidget } from '../Widgets';
|
import { resolveWidget } from '../Widgets';
|
||||||
|
|
||||||
export default class ObjectControl extends Component {
|
export default class ObjectControl extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
@ -24,10 +28,25 @@ export default class ObjectControl extends Component {
|
|||||||
* e.g. when debounced, always get the latest object value instead of usin
|
* e.g. when debounced, always get the latest object value instead of usin
|
||||||
* `this.props.value` directly.
|
* `this.props.value` directly.
|
||||||
*/
|
*/
|
||||||
getObjectValue = () => this.props.value;
|
getObjectValue = () => this.props.value || Map();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Always update so that each nested widget has the option to update. This is
|
||||||
|
* required because ControlHOC provides a default `shouldComponentUpdate`
|
||||||
|
* which only updates if the value changes, but every widget must be allowed
|
||||||
|
* to override this.
|
||||||
|
*/
|
||||||
|
shouldComponentUpdate() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange = (fieldName, newValue, newMetadata) => {
|
||||||
|
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
|
||||||
|
return this.props.onChange(newObjectValue, newMetadata);
|
||||||
|
};
|
||||||
|
|
||||||
controlFor(field) {
|
controlFor(field) {
|
||||||
const { onAddAsset, onRemoveAsset, getAsset, value, onChange } = this.props;
|
const { onAddAsset, onOpenMediaLibrary, mediaPaths, onRemoveAsset, getAsset, value, onChange } = this.props;
|
||||||
if (field.get('widget') === 'hidden') {
|
if (field.get('widget') === 'hidden') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -37,20 +56,18 @@ export default class ObjectControl extends Component {
|
|||||||
return (<div className="nc-controlPane-widget" key={field.get('name')}>
|
return (<div className="nc-controlPane-widget" key={field.get('name')}>
|
||||||
<div className="nc-controlPane-control" key={field.get('name')}>
|
<div className="nc-controlPane-control" key={field.get('name')}>
|
||||||
<label className="nc-controlPane-label" htmlFor={field.get('name')}>{field.get('label')}</label>
|
<label className="nc-controlPane-label" htmlFor={field.get('name')}>{field.get('label')}</label>
|
||||||
{
|
<ControlHOC
|
||||||
React.createElement(widget.control, {
|
controlComponent={widget.control}
|
||||||
id: field.get('name'),
|
field={field}
|
||||||
field,
|
value={fieldValue}
|
||||||
value: fieldValue,
|
onChange={this.onChange.bind(this, field.get('name'))}
|
||||||
onChange: (val, metadata) => {
|
mediaPaths={mediaPaths}
|
||||||
onChange((this.getObjectValue() || Map()).set(field.get('name'), val), metadata);
|
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||||
},
|
onAddAsset={onAddAsset}
|
||||||
onAddAsset,
|
onRemoveAsset={onRemoveAsset}
|
||||||
onRemoveAsset,
|
getAsset={getAsset}
|
||||||
getAsset,
|
forID={field.get('name')}
|
||||||
forID: field.get('name'),
|
/>
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,9 @@ import {
|
|||||||
navigateToCollection as actionNavigateToCollection,
|
navigateToCollection as actionNavigateToCollection,
|
||||||
createNewEntryInCollection as actionCreateNewEntryInCollection,
|
createNewEntryInCollection as actionCreateNewEntryInCollection,
|
||||||
} from '../actions/findbar';
|
} from '../actions/findbar';
|
||||||
|
import { openMediaLibrary as actionOpenMediaLibrary } from '../actions/mediaLibrary';
|
||||||
import AppHeader from '../components/AppHeader/AppHeader';
|
import AppHeader from '../components/AppHeader/AppHeader';
|
||||||
|
import MediaLibrary from '../components/MediaLibrary/MediaLibrary';
|
||||||
import { Loader, Toast } from '../components/UI/index';
|
import { Loader, Toast } from '../components/UI/index';
|
||||||
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
|
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
|
||||||
import { SIMPLE, EDITORIAL_WORKFLOW } from '../constants/publishModes';
|
import { SIMPLE, EDITORIAL_WORKFLOW } from '../constants/publishModes';
|
||||||
@ -114,6 +116,7 @@ class App extends React.Component {
|
|||||||
logoutUser,
|
logoutUser,
|
||||||
isFetching,
|
isFetching,
|
||||||
publishMode,
|
publishMode,
|
||||||
|
openMediaLibrary,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
|
||||||
@ -190,6 +193,7 @@ class App extends React.Component {
|
|||||||
onCreateEntryClick={createNewEntryInCollection}
|
onCreateEntryClick={createNewEntryInCollection}
|
||||||
onLogoutClick={logoutUser}
|
onLogoutClick={logoutUser}
|
||||||
toggleDrawer={toggleSidebar}
|
toggleDrawer={toggleSidebar}
|
||||||
|
openMediaLibrary={openMediaLibrary}
|
||||||
/>
|
/>
|
||||||
<div className="nc-app-entriesPanel">
|
<div className="nc-app-entriesPanel">
|
||||||
{ isFetching && <TopBarProgress /> }
|
{ isFetching && <TopBarProgress /> }
|
||||||
@ -202,6 +206,7 @@ class App extends React.Component {
|
|||||||
<Route path="/search/:searchTerm" component={SearchPage} />
|
<Route path="/search/:searchTerm" component={SearchPage} />
|
||||||
<Route component={NotFoundPage} />
|
<Route component={NotFoundPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
<MediaLibrary/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -231,6 +236,9 @@ function mapDispatchToProps(dispatch) {
|
|||||||
createNewEntryInCollection: (collectionName) => {
|
createNewEntryInCollection: (collectionName) => {
|
||||||
dispatch(actionCreateNewEntryInCollection(collectionName));
|
dispatch(actionCreateNewEntryInCollection(collectionName));
|
||||||
},
|
},
|
||||||
|
openMediaLibrary: () => {
|
||||||
|
dispatch(actionOpenMediaLibrary());
|
||||||
|
},
|
||||||
logoutUser: () => {
|
logoutUser: () => {
|
||||||
dispatch(actionLogoutUser());
|
dispatch(actionLogoutUser());
|
||||||
},
|
},
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
import { closeEntry } from '../actions/editor';
|
import { closeEntry } from '../actions/editor';
|
||||||
import { deserializeValues } from '../lib/serializeEntryValues';
|
import { deserializeValues } from '../lib/serializeEntryValues';
|
||||||
import { addAsset, removeAsset } from '../actions/media';
|
import { addAsset, removeAsset } from '../actions/media';
|
||||||
|
import { openMediaLibrary } from '../actions/mediaLibrary';
|
||||||
import { openSidebar } from '../actions/globalUI';
|
import { openSidebar } from '../actions/globalUI';
|
||||||
import { selectEntry, getAsset } from '../reducers';
|
import { selectEntry, getAsset } from '../reducers';
|
||||||
import { selectFields } from '../reducers/collections';
|
import { selectFields } from '../reducers/collections';
|
||||||
@ -34,11 +35,13 @@ class EntryPage extends React.Component {
|
|||||||
createEmptyDraft: PropTypes.func.isRequired,
|
createEmptyDraft: PropTypes.func.isRequired,
|
||||||
discardDraft: PropTypes.func.isRequired,
|
discardDraft: PropTypes.func.isRequired,
|
||||||
entry: ImmutablePropTypes.map,
|
entry: ImmutablePropTypes.map,
|
||||||
|
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||||
entryDraft: ImmutablePropTypes.map.isRequired,
|
entryDraft: ImmutablePropTypes.map.isRequired,
|
||||||
loadEntry: PropTypes.func.isRequired,
|
loadEntry: PropTypes.func.isRequired,
|
||||||
persistEntry: PropTypes.func.isRequired,
|
persistEntry: PropTypes.func.isRequired,
|
||||||
deleteEntry: PropTypes.func.isRequired,
|
deleteEntry: PropTypes.func.isRequired,
|
||||||
showDelete: PropTypes.bool.isRequired,
|
showDelete: PropTypes.bool.isRequired,
|
||||||
|
openMediaLibrary: PropTypes.func.isRequired,
|
||||||
removeAsset: PropTypes.func.isRequired,
|
removeAsset: PropTypes.func.isRequired,
|
||||||
closeEntry: PropTypes.func.isRequired,
|
closeEntry: PropTypes.func.isRequired,
|
||||||
openSidebar: PropTypes.func.isRequired,
|
openSidebar: PropTypes.func.isRequired,
|
||||||
@ -125,10 +128,12 @@ class EntryPage extends React.Component {
|
|||||||
entry,
|
entry,
|
||||||
entryDraft,
|
entryDraft,
|
||||||
fields,
|
fields,
|
||||||
|
mediaPaths,
|
||||||
boundGetAsset,
|
boundGetAsset,
|
||||||
collection,
|
collection,
|
||||||
changeDraftField,
|
changeDraftField,
|
||||||
changeDraftFieldValidation,
|
changeDraftFieldValidation,
|
||||||
|
openMediaLibrary,
|
||||||
addAsset,
|
addAsset,
|
||||||
removeAsset,
|
removeAsset,
|
||||||
closeEntry,
|
closeEntry,
|
||||||
@ -150,8 +155,10 @@ class EntryPage extends React.Component {
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsMetaData={entryDraft.get('fieldsMetaData')}
|
fieldsMetaData={entryDraft.get('fieldsMetaData')}
|
||||||
fieldsErrors={entryDraft.get('fieldsErrors')}
|
fieldsErrors={entryDraft.get('fieldsErrors')}
|
||||||
|
mediaPaths={mediaPaths}
|
||||||
onChange={changeDraftField}
|
onChange={changeDraftField}
|
||||||
onValidate={changeDraftFieldValidation}
|
onValidate={changeDraftFieldValidation}
|
||||||
|
onOpenMediaLibrary={openMediaLibrary}
|
||||||
onAddAsset={addAsset}
|
onAddAsset={addAsset}
|
||||||
onRemoveAsset={removeAsset}
|
onRemoveAsset={removeAsset}
|
||||||
onPersist={this.handlePersistEntry}
|
onPersist={this.handlePersistEntry}
|
||||||
@ -165,18 +172,20 @@ class EntryPage extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapStateToProps(state, ownProps) {
|
function mapStateToProps(state, ownProps) {
|
||||||
const { collections, entryDraft } = state;
|
const { collections, entryDraft, mediaLibrary } = state;
|
||||||
const slug = ownProps.match.params.slug;
|
const slug = ownProps.match.params.slug;
|
||||||
const collection = collections.get(ownProps.match.params.name);
|
const collection = collections.get(ownProps.match.params.name);
|
||||||
const newEntry = ownProps.newRecord === true;
|
const newEntry = ownProps.newRecord === true;
|
||||||
const fields = selectFields(collection, slug);
|
const fields = selectFields(collection, slug);
|
||||||
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
|
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
|
||||||
const boundGetAsset = getAsset.bind(null, state);
|
const boundGetAsset = getAsset.bind(null, state);
|
||||||
|
const mediaPaths = mediaLibrary.get('controlMedia');
|
||||||
return {
|
return {
|
||||||
collection,
|
collection,
|
||||||
collections,
|
collections,
|
||||||
newEntry,
|
newEntry,
|
||||||
entryDraft,
|
entryDraft,
|
||||||
|
mediaPaths,
|
||||||
boundGetAsset,
|
boundGetAsset,
|
||||||
fields,
|
fields,
|
||||||
slug,
|
slug,
|
||||||
@ -189,6 +198,7 @@ export default connect(
|
|||||||
{
|
{
|
||||||
changeDraftField,
|
changeDraftField,
|
||||||
changeDraftFieldValidation,
|
changeDraftFieldValidation,
|
||||||
|
openMediaLibrary,
|
||||||
addAsset,
|
addAsset,
|
||||||
removeAsset,
|
removeAsset,
|
||||||
loadEntry,
|
loadEntry,
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
@import "./components/UI/icon/Icon.css";
|
@import "./components/UI/icon/Icon.css";
|
||||||
@import "./components/UI/loader/Loader.css";
|
@import "./components/UI/loader/Loader.css";
|
||||||
@import "./components/UI/toast/Toast.css";
|
@import "./components/UI/toast/Toast.css";
|
||||||
|
@import "./components/UI/Dialog/Dialog.css";
|
||||||
@import "./components/UnpublishedListing/UnpublishedListing.css";
|
@import "./components/UnpublishedListing/UnpublishedListing.css";
|
||||||
@import "./components/UnpublishedListing/UnpublishedListingCardMeta.css";
|
@import "./components/UnpublishedListing/UnpublishedListingCardMeta.css";
|
||||||
@import "./components/Widgets/BooleanControl.css";
|
@import "./components/Widgets/BooleanControl.css";
|
||||||
@ -32,6 +33,7 @@
|
|||||||
@import "./containers/App.css";
|
@import "./containers/App.css";
|
||||||
@import "./containers/CollectionPage.css";
|
@import "./containers/CollectionPage.css";
|
||||||
@import "./containers/Sidebar.css";
|
@import "./containers/Sidebar.css";
|
||||||
|
@import "./components/MediaLibrary/MediaLibrary.css";
|
||||||
|
|
||||||
html {
|
html {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -45,7 +47,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--fontFamily);
|
font-family: var(--fontFamilyPrimary);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: #7c8382;
|
color: #7c8382;
|
||||||
@ -54,7 +56,7 @@ body {
|
|||||||
|
|
||||||
h1, h2, h3, h4, h5, h6, p {
|
h1, h2, h3, h4, h5, h6, p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--fontFamily);
|
font-family: var(--fontFamilyPrimary);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { pickBy } from 'lodash';
|
||||||
|
import { addParams } from '../../../lib/urlHelper';
|
||||||
|
|
||||||
export default class AssetStore {
|
export default class AssetStore {
|
||||||
constructor(config, getToken) {
|
constructor(config, getToken) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@ -52,21 +55,44 @@ export default class AssetStore {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async request(path, options = {}) {
|
||||||
request(path, options = {}) {
|
|
||||||
const headers = this.requestHeaders(options.headers || {});
|
const headers = this.requestHeaders(options.headers || {});
|
||||||
const url = this.urlFor(path, options);
|
const url = this.urlFor(path, options);
|
||||||
return fetch(url, { ...options, headers }).then((response) => {
|
const response = await fetch(url, { ...options, headers });
|
||||||
const contentType = response.headers.get('Content-Type');
|
const contentType = response.headers.get('Content-Type');
|
||||||
if (contentType && contentType.match(/json/)) {
|
const isJson = contentType && contentType.match(/json/);
|
||||||
return this.parseJsonResponse(response);
|
const content = isJson ? await this.parseJsonResponse(response) : response.text();
|
||||||
}
|
return content;
|
||||||
|
|
||||||
return response.text();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
upload(file, privateUpload = false) {
|
async retrieve(query, page) {
|
||||||
|
const params = pickBy({ search: query, page }, val => !!val);
|
||||||
|
const url = addParams(this.getSignedFormURL, params);
|
||||||
|
const token = await this.getToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${ token }`,
|
||||||
|
};
|
||||||
|
const response = await this.request(url, { headers });
|
||||||
|
const files = response.map(({ id, name, size, url }) => {
|
||||||
|
return { id, name, size, url, urlIsPublicPath: true };
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(assetID) {
|
||||||
|
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, privateUpload = false) {
|
||||||
const fileData = {
|
const fileData = {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size
|
size: file.size
|
||||||
@ -79,33 +105,35 @@ export default class AssetStore {
|
|||||||
fileData.visibility = 'private';
|
fileData.visibility = 'private';
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getToken()
|
try {
|
||||||
.then(token => this.request(this.getSignedFormURL, {
|
const token = await this.getToken();
|
||||||
method: 'POST',
|
const response = await this.request(this.getSignedFormURL, {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'Authorization': `Bearer ${ token }`,
|
'Content-Type': 'application/json',
|
||||||
},
|
'Authorization': `Bearer ${ token }`,
|
||||||
body: JSON.stringify(fileData),
|
},
|
||||||
}))
|
body: JSON.stringify(fileData),
|
||||||
.then((response) => {
|
});
|
||||||
const formURL = response.form.url;
|
const formURL = response.form.url;
|
||||||
const formFields = response.form.fields;
|
const formFields = response.form.fields;
|
||||||
const assetID = response.asset.id;
|
const { id, name, size, url } = response.asset;
|
||||||
const assetURL = response.asset.url;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.keys(formFields).forEach(key => formData.append(key, formFields[key]));
|
Object.keys(formFields).forEach(key => formData.append(key, formFields[key]));
|
||||||
formData.append('file', file, file.name);
|
formData.append('file', file, file.name);
|
||||||
|
|
||||||
return this.request(formURL, {
|
await this.request(formURL, { method: 'POST', body: formData });
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
if (this.shouldConfirmUpload) {
|
||||||
})
|
await this.confirmRequest(id);
|
||||||
.then(() => {
|
}
|
||||||
if (this.shouldConfirmUpload) this.confirmRequest(assetID);
|
|
||||||
return { success: true, assetURL };
|
const asset = { id, name, size, url, urlIsPublicPath: true };
|
||||||
});
|
return { success: true, url, asset };
|
||||||
});
|
}
|
||||||
|
catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import url from 'url';
|
||||||
import sanitizeFilename from 'sanitize-filename';
|
import sanitizeFilename from 'sanitize-filename';
|
||||||
import { isString, escapeRegExp, flow, partialRight } from 'lodash';
|
import { isString, escapeRegExp, flow, partialRight } from 'lodash';
|
||||||
|
|
||||||
@ -13,6 +14,12 @@ export function getNewEntryUrl(collectionName, direct) {
|
|||||||
return getUrl(`/collections/${ collectionName }/new`, direct);
|
return getUrl(`/collections/${ collectionName }/new`, direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addParams(urlString, params) {
|
||||||
|
const parsedUrl = url.parse(urlString, true);
|
||||||
|
parsedUrl.query = { ...parsedUrl.query, ...params };
|
||||||
|
return url.format(parsedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
/* See https://www.w3.org/International/articles/idn-and-iri/#path.
|
/* See https://www.w3.org/International/articles/idn-and-iri/#path.
|
||||||
* According to the new IRI (Internationalized Resource Identifier) spec, RFC 3987,
|
* According to the new IRI (Internationalized Resource Identifier) spec, RFC 3987,
|
||||||
* ASCII chars should be kept the same way as in standard URIs (letters digits _ - . ~).
|
* ASCII chars should be kept the same way as in standard URIs (letters digits _ - . ~).
|
||||||
|
@ -85,7 +85,11 @@ const entryDraftReducer = (state = Map(), action) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
case ADD_ASSET:
|
case ADD_ASSET:
|
||||||
return state.update('mediaFiles', list => list.push(action.payload.public_path));
|
if (state.has('mediaFiles')) {
|
||||||
|
return state.update('mediaFiles', list => list.push(action.payload.public_path));
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
case REMOVE_ASSET:
|
case REMOVE_ASSET:
|
||||||
return state.update('mediaFiles', list => list.filterNot(path => path === action.payload));
|
return state.update('mediaFiles', list => list.filterNot(path => path === action.payload));
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
|
|||||||
import entryDraft from './entryDraft';
|
import entryDraft from './entryDraft';
|
||||||
import collections from './collections';
|
import collections from './collections';
|
||||||
import search from './search';
|
import search from './search';
|
||||||
|
import mediaLibrary from './mediaLibrary';
|
||||||
import medias, * as fromMedias from './medias';
|
import medias, * as fromMedias from './medias';
|
||||||
import globalUI from './globalUI';
|
import globalUI from './globalUI';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ const reducers = {
|
|||||||
entries,
|
entries,
|
||||||
editorialWorkflow,
|
editorialWorkflow,
|
||||||
entryDraft,
|
entryDraft,
|
||||||
|
mediaLibrary,
|
||||||
medias,
|
medias,
|
||||||
globalUI,
|
globalUI,
|
||||||
};
|
};
|
||||||
|
93
src/reducers/mediaLibrary.js
Normal file
93
src/reducers/mediaLibrary.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { get } from 'lodash';
|
||||||
|
import { Map } from 'immutable';
|
||||||
|
import uuid from 'uuid/v4';
|
||||||
|
import {
|
||||||
|
MEDIA_LIBRARY_OPEN,
|
||||||
|
MEDIA_LIBRARY_CLOSE,
|
||||||
|
MEDIA_INSERT,
|
||||||
|
MEDIA_LOAD_REQUEST,
|
||||||
|
MEDIA_LOAD_SUCCESS,
|
||||||
|
MEDIA_LOAD_FAILURE,
|
||||||
|
MEDIA_PERSIST_REQUEST,
|
||||||
|
MEDIA_PERSIST_SUCCESS,
|
||||||
|
MEDIA_PERSIST_FAILURE,
|
||||||
|
MEDIA_DELETE_REQUEST,
|
||||||
|
MEDIA_DELETE_SUCCESS,
|
||||||
|
MEDIA_DELETE_FAILURE,
|
||||||
|
} from '../actions/mediaLibrary';
|
||||||
|
|
||||||
|
const mediaLibrary = (state = Map({ isVisible: false, controlMedia: Map() }), action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case MEDIA_LIBRARY_OPEN: {
|
||||||
|
const { controlID, forImage } = action.payload || {};
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('isVisible', true);
|
||||||
|
map.set('forImage', forImage);
|
||||||
|
map.set('controlID', controlID);
|
||||||
|
map.set('canInsert', !!controlID);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case MEDIA_LIBRARY_CLOSE:
|
||||||
|
return state.set('isVisible', false);
|
||||||
|
case MEDIA_INSERT: {
|
||||||
|
const controlID = state.get('controlID');
|
||||||
|
const mediaPath = get(action, ['payload', 'mediaPath']);
|
||||||
|
return state.setIn(['controlMedia', controlID], mediaPath);
|
||||||
|
}
|
||||||
|
case MEDIA_LOAD_REQUEST:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('isLoading', true);
|
||||||
|
map.set('isPaginating', action.payload.page > 1);
|
||||||
|
});
|
||||||
|
case MEDIA_LOAD_SUCCESS: {
|
||||||
|
const { files, page, canPaginate, dynamicSearch, dynamicSearchQuery } = action.payload;
|
||||||
|
const filesWithKeys = files.map(file => ({ ...file, key: uuid() }));
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('isLoading', false);
|
||||||
|
map.set('isPaginating', false);
|
||||||
|
map.set('page', page);
|
||||||
|
map.set('hasNextPage', canPaginate && files && files.length > 0);
|
||||||
|
map.set('dynamicSearch', dynamicSearch);
|
||||||
|
map.set('dynamicSearchQuery', dynamicSearchQuery);
|
||||||
|
map.set('dynamicSearchActive', !!dynamicSearchQuery);
|
||||||
|
if (page && page > 1) {
|
||||||
|
const updatedFiles = map.get('files').concat(filesWithKeys);
|
||||||
|
map.set('files', updatedFiles);
|
||||||
|
} else {
|
||||||
|
map.set('files', filesWithKeys);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case MEDIA_LOAD_FAILURE:
|
||||||
|
return state.set('isLoading', false);
|
||||||
|
case MEDIA_PERSIST_REQUEST:
|
||||||
|
return state.set('isPersisting', true);
|
||||||
|
case MEDIA_PERSIST_SUCCESS: {
|
||||||
|
const { file } = action.payload;
|
||||||
|
return state.withMutations(map => {
|
||||||
|
const fileWithKey = { ...file, key: uuid() };
|
||||||
|
const updatedFiles = [fileWithKey, ...map.get('files')];
|
||||||
|
map.set('files', updatedFiles);
|
||||||
|
map.set('isPersisting', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case MEDIA_PERSIST_FAILURE:
|
||||||
|
return state.set('isPersisting', false);
|
||||||
|
case MEDIA_DELETE_REQUEST:
|
||||||
|
return state.set('isDeleting', true);
|
||||||
|
case MEDIA_DELETE_SUCCESS: {
|
||||||
|
const { key } = action.payload.file;
|
||||||
|
return state.withMutations(map => {
|
||||||
|
const updatedFiles = map.get('files').filter(file => file.key !== key);
|
||||||
|
map.set('files', updatedFiles);
|
||||||
|
map.set('isDeleting', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case MEDIA_DELETE_FAILURE:
|
||||||
|
return state.set('isDeleting', false);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mediaLibrary;
|
@ -8,7 +8,7 @@ export const setStore = (storeObj) => {
|
|||||||
store = storeObj;
|
store = storeObj;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AssetProxy(value, fileObj, uploaded = false) {
|
export default function AssetProxy(value, fileObj, uploaded = false, asset) {
|
||||||
const config = store.getState().config;
|
const config = store.getState().config;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.fileObj = fileObj;
|
this.fileObj = fileObj;
|
||||||
@ -16,6 +16,7 @@ export default function AssetProxy(value, fileObj, uploaded = false) {
|
|||||||
this.sha = null;
|
this.sha = null;
|
||||||
this.path = config.get('media_folder') && !uploaded ? resolvePath(value, config.get('media_folder')) : value;
|
this.path = config.get('media_folder') && !uploaded ? resolvePath(value, config.get('media_folder')) : value;
|
||||||
this.public_path = !uploaded ? resolvePath(value, config.get('public_folder')) : value;
|
this.public_path = !uploaded ? resolvePath(value, config.get('public_folder')) : value;
|
||||||
|
this.asset = asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetProxy.prototype.toString = function () {
|
AssetProxy.prototype.toString = function () {
|
||||||
@ -46,7 +47,7 @@ export function createAssetProxy(value, fileObj, uploaded = false, privateUpload
|
|||||||
const provider = integration && getIntegrationProvider(state.integrations, currentBackend(state.config).getToken, integration);
|
const provider = integration && getIntegrationProvider(state.integrations, currentBackend(state.config).getToken, integration);
|
||||||
return provider.upload(fileObj, privateUpload).then(
|
return provider.upload(fileObj, privateUpload).then(
|
||||||
response => (
|
response => (
|
||||||
new AssetProxy(response.assetURL.replace(/^(https?):/, ''), null, true)
|
new AssetProxy(response.asset.url.replace(/^(https?):/, ''), null, true, response.asset)
|
||||||
),
|
),
|
||||||
error => new AssetProxy(value, fileObj, false)
|
error => new AssetProxy(value, fileObj, false)
|
||||||
);
|
);
|
||||||
|
20
yarn.lock
20
yarn.lock
@ -1476,6 +1476,10 @@ bytes@3.0.0:
|
|||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
|
||||||
|
|
||||||
|
bytes@^2.5.0:
|
||||||
|
version "2.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.5.0.tgz#4c9423ea2d252c270c41b2bdefeff9bb6b62c06a"
|
||||||
|
|
||||||
caller-path@^0.1.0:
|
caller-path@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
|
resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
|
||||||
@ -3379,6 +3383,18 @@ flatten@^1.0.2:
|
|||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
|
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
|
||||||
|
|
||||||
|
focus-trap-react@^3.0.3:
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/focus-trap-react/-/focus-trap-react-3.0.3.tgz#96f934fba39dae9b80a5d2b4839a919c0bc4fa7e"
|
||||||
|
dependencies:
|
||||||
|
focus-trap "^2.0.1"
|
||||||
|
|
||||||
|
focus-trap@^2.0.1:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.3.0.tgz#07c91964867d346315f4f5f8df88bf96455316e2"
|
||||||
|
dependencies:
|
||||||
|
tabbable "^1.0.3"
|
||||||
|
|
||||||
for-each@~0.3.2:
|
for-each@~0.3.2:
|
||||||
version "0.3.2"
|
version "0.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4"
|
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4"
|
||||||
@ -8975,6 +8991,10 @@ synesthesia@^1.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
css-color-names "0.0.3"
|
css-color-names "0.0.3"
|
||||||
|
|
||||||
|
tabbable@^1.0.3:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.0.6.tgz#7c26a87ea6f4a25edf5edb619745a0ae740724fc"
|
||||||
|
|
||||||
table@^3.7.8:
|
table@^3.7.8:
|
||||||
version "3.8.3"
|
version "3.8.3"
|
||||||
resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
|
resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
|
||||||
|
Reference in New Issue
Block a user