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:
parent
2a4af64a71
commit
6b45a46a39
12
.babelrc
12
.babelrc
@ -1,11 +1,21 @@
|
||||
{
|
||||
"presets": [["env", { "modules": false }], "stage-1", "react"],
|
||||
"presets": [
|
||||
["env", {
|
||||
"modules": false
|
||||
}],
|
||||
"stage-1",
|
||||
"react"
|
||||
],
|
||||
"plugins": [
|
||||
"react-hot-loader/babel",
|
||||
"lodash",
|
||||
["babel-plugin-transform-builtin-extend", {
|
||||
"globals": ["Error"]
|
||||
}],
|
||||
["transform-runtime", {
|
||||
"useBuiltIns": true,
|
||||
"useESModules": true
|
||||
}]
|
||||
],
|
||||
"env": {
|
||||
"test": {
|
||||
|
@ -1,5 +1,7 @@
|
||||
backend:
|
||||
name: test-repo
|
||||
name: github
|
||||
repo: netlify/netlify-cms
|
||||
branch: pr-554-backend
|
||||
|
||||
media_folder: "assets/uploads"
|
||||
|
||||
|
@ -82,6 +82,7 @@
|
||||
"babel-loader": "^7.0.0",
|
||||
"babel-plugin-lodash": "^3.2.0",
|
||||
"babel-plugin-transform-builtin-extend": "^1.1.0",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-env": "^1.6.0",
|
||||
"babel-preset-react": "^6.23.0",
|
||||
"babel-preset-stage-1": "^6.22.0",
|
||||
@ -108,8 +109,8 @@
|
||||
"postcss-cssnext": "^3.0.2",
|
||||
"postcss-import": "^11.0.0",
|
||||
"postcss-loader": "^2.0.7",
|
||||
"react-test-renderer": "^16.0.0",
|
||||
"react-hot-loader": "^3.0.0-beta.7",
|
||||
"react-test-renderer": "^16.0.0",
|
||||
"style-loader": "^0.18.2",
|
||||
"stylefmt": "^4.3.1",
|
||||
"stylelint": "^7.9.0",
|
||||
@ -126,6 +127,7 @@
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.5",
|
||||
"create-react-class": "^15.6.0",
|
||||
"focus-trap-react": "^3.0.3",
|
||||
"fuzzy": "^0.1.1",
|
||||
"gotrue-js": "^0.9.11",
|
||||
"gray-matter": "^3.0.6",
|
||||
@ -184,6 +186,7 @@
|
||||
"unified": "^6.1.4",
|
||||
"unist-builder": "^1.0.2",
|
||||
"unist-util-visit-parents": "^1.1.1",
|
||||
"url": "^0.11.0",
|
||||
"uuid": "^3.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
@ -121,6 +121,7 @@ function unpublishedEntryPersistedFail(error, transactionID) {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
|
||||
payload: { error },
|
||||
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) {
|
||||
return (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) {
|
||||
const path = selectEntryPath(collection, slug);
|
||||
|
||||
@ -255,6 +266,11 @@ class Backend {
|
||||
return this.implementation.deleteFile(path, commitMessage);
|
||||
}
|
||||
|
||||
deleteMedia(path) {
|
||||
const commitMessage = `Delete ${path}`;
|
||||
return this.implementation.deleteFile(path, commitMessage);
|
||||
}
|
||||
|
||||
persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) {
|
||||
return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true });
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import GithubAPI from "../github/API";
|
||||
import { APIError } from "../../valueObjects/errors";
|
||||
|
||||
export default class API extends GithubAPI {
|
||||
constructor(config) {
|
||||
@ -44,15 +45,20 @@ export default class API extends GithubAPI {
|
||||
|
||||
request(path, options = {}) {
|
||||
const url = this.urlFor(path, options);
|
||||
let responseStatus;
|
||||
return this.getRequestHeaders(options.headers || {})
|
||||
.then(headers => fetch(url, { ...options, headers }))
|
||||
.then((response) => {
|
||||
responseStatus = response.status;
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
})
|
||||
.catch(error => {
|
||||
throw new APIError(error.message, responseStatus, 'Git Gateway');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,13 @@
|
||||
import LocalForage from "localforage";
|
||||
import { Base64 } from "js-base64";
|
||||
import _ from "lodash";
|
||||
import { uniq, initial, last, get, find } from "lodash";
|
||||
import { filterPromises, resolvePromiseProperties } from "../../lib/promiseHelper";
|
||||
import AssetProxy from "../../valueObjects/AssetProxy";
|
||||
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
|
||||
import { APIError, EditorialWorkflowError } from "../../valueObjects/errors";
|
||||
|
||||
const CMS_BRANCH_PREFIX = 'cms/';
|
||||
|
||||
export default class API {
|
||||
constructor(config) {
|
||||
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() {
|
||||
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
|
||||
cache: "no-store",
|
||||
@ -249,7 +255,7 @@ export default class API {
|
||||
|
||||
persistFiles(entry, mediaFiles, options) {
|
||||
const uploadPromises = [];
|
||||
const files = mediaFiles.concat(entry);
|
||||
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
@ -273,43 +279,51 @@ export default class API {
|
||||
|
||||
deleteFile(path, message, options={}) {
|
||||
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 }`;
|
||||
// We need to request the file first to get the SHA
|
||||
return this.request(fileURL, {
|
||||
params: { ref: branch },
|
||||
cache: "no-store",
|
||||
}).then(({ sha }) => this.request(fileURL, {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
sha,
|
||||
message,
|
||||
branch,
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* We need to request the tree first to get the SHA. We use extended SHA-1
|
||||
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
|
||||
* through the tree.
|
||||
*/
|
||||
return this.request(fileDataURL, { cache: 'no-store' })
|
||||
.then(resp => {
|
||||
const { sha } = resp.tree.find(file => file.path === filename);
|
||||
const opts = { method: 'DELETE', params: { sha, message, branch } };
|
||||
return this.request(fileURL, opts);
|
||||
});
|
||||
}
|
||||
|
||||
editorialWorkflowGit(fileTree, entry, filesList, options) {
|
||||
const contentKey = entry.slug;
|
||||
const branchName = `cms/${ contentKey }`;
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
const unpublished = options.unpublished || false;
|
||||
if (!unpublished) {
|
||||
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
|
||||
const contentKey = entry.slug;
|
||||
const branchName = `cms/${ contentKey }`;
|
||||
let prResponse;
|
||||
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||
.then(branchResponse => this.createPR(options.commitMessage, branchName))
|
||||
.then(prResponse => this.user().then(user => user.name ? user.name : user.login)
|
||||
.then(username => this.storeMetadata(contentKey, {
|
||||
.then(pr => {
|
||||
prResponse = pr;
|
||||
return this.user();
|
||||
})
|
||||
.then(user => {
|
||||
return this.storeMetadata(contentKey, {
|
||||
type: "PR",
|
||||
pr: {
|
||||
number: prResponse.number,
|
||||
head: prResponse.head && prResponse.head.sha,
|
||||
},
|
||||
user: username,
|
||||
user: user.name || user.login,
|
||||
status: status.first(),
|
||||
branch: branchName,
|
||||
collection: options.collectionName,
|
||||
@ -323,44 +337,176 @@ export default class API {
|
||||
files: filesList,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
}
|
||||
)));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
||||
let newHead;
|
||||
return this.getBranch(branchName)
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then((response) => {
|
||||
const contentKey = entry.slug;
|
||||
const branchName = `cms/${ 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(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commit => {
|
||||
newHead = commit;
|
||||
return this.retrieveMetadata(contentKey);
|
||||
})
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata))
|
||||
.then(this.patchBranch(branchName, response.sha));
|
||||
});
|
||||
.then(metadata => {
|
||||
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) {
|
||||
const contentKey = slug;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
@ -373,9 +519,10 @@ export default class API {
|
||||
|
||||
deleteUnpublishedEntry(collection, slug) {
|
||||
const contentKey = slug;
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.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 -
|
||||
// deletion should be idempotent, so we can consider this a
|
||||
// success.
|
||||
@ -389,10 +536,11 @@ export default class API {
|
||||
|
||||
publishUnpublishedEntry(collection, slug) {
|
||||
const contentKey = slug;
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
let prNumber;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.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) }`, {
|
||||
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);
|
||||
}
|
||||
|
||||
patchBranch(branchName, sha) {
|
||||
return this.patchRef("heads", branchName, sha);
|
||||
assertCmsBranch(branchName) {
|
||||
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) {
|
||||
@ -487,7 +644,28 @@ export default class API {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -542,19 +720,40 @@ export default class API {
|
||||
);
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then(updates => this.request(`${ this.repoURL }/git/trees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ base_tree: sha, tree: updates }),
|
||||
})).then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
|
||||
.then(tree => this.createTree(sha, tree))
|
||||
.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) {
|
||||
const tree = changeTree.sha;
|
||||
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`, {
|
||||
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 AuthenticationPage from "./AuthenticationPage";
|
||||
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 = {}) {
|
||||
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) {
|
||||
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 { fileExtension } from '../../lib/pathHelper'
|
||||
|
||||
@ -24,6 +26,7 @@ function nameFromEmail(email) {
|
||||
export default class TestRepo {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.assets = [];
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
@ -99,10 +102,32 @@ export default class TestRepo {
|
||||
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) {
|
||||
const folder = path.substring(0, path.lastIndexOf('/'));
|
||||
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
delete window.repoFiles[folder][fileName];
|
||||
const assetIndex = this.assets.findIndex(asset => asset.path === path);
|
||||
if (assetIndex > -1) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -7,13 +7,22 @@
|
||||
|
||||
/* Gross stuff below, React Toolbox hacks */
|
||||
|
||||
.nc-appHeader-homeLink,
|
||||
.nc-appHeader-button,
|
||||
.nc-appHeader-iconMenu {
|
||||
margin-left: 2%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.nc-appHeader-homeLink &icon {
|
||||
vertical-align: top;
|
||||
.nc-appHeader-button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
width: 36px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
|
||||
& .nc-appHeader-icon {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-appHeader-icon,
|
||||
|
@ -20,11 +20,6 @@ export default class AppHeader extends React.Component {
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
createMenuActive: false,
|
||||
userMenuActive: false,
|
||||
};
|
||||
|
||||
handleCreatePostClick = (collectionName) => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
@ -32,18 +27,6 @@ export default class AppHeader extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateButtonClick = () => {
|
||||
this.setState({
|
||||
createMenuActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleCreateMenuHide = () => {
|
||||
this.setState({
|
||||
createMenuActive: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
@ -51,6 +34,7 @@ export default class AppHeader extends React.Component {
|
||||
runCommand,
|
||||
toggleDrawer,
|
||||
onLogoutClick,
|
||||
openMediaLibrary,
|
||||
} = this.props;
|
||||
|
||||
const avatarStyle = {
|
||||
@ -59,7 +43,6 @@ export default class AppHeader extends React.Component {
|
||||
|
||||
const theme = {
|
||||
appBar: 'nc-appHeader-appBar',
|
||||
homeLink: 'nc-appHeader-homeLink',
|
||||
iconMenu: 'nc-appHeader-iconMenu',
|
||||
icon: 'nc-appHeader-icon',
|
||||
leftIcon: 'nc-appHeader-leftIcon',
|
||||
@ -76,17 +59,14 @@ export default class AppHeader extends React.Component {
|
||||
theme={theme}
|
||||
leftIcon="menu"
|
||||
onLeftIconClick={toggleDrawer}
|
||||
onRightIconClick={this.handleRightIconClick}
|
||||
>
|
||||
<Link to="/" className="nc-appHeader-homeLink">
|
||||
<Link to="/" className="nc-appHeader-button">
|
||||
<FontIcon value="home" className="nc-appHeader-icon" />
|
||||
</Link>
|
||||
<IconMenu
|
||||
theme={theme}
|
||||
icon="add"
|
||||
onClick={this.handleCreateButtonClick}
|
||||
onHide={this.handleCreateMenuHide}
|
||||
>
|
||||
<button onClick={openMediaLibrary} className="nc-appHeader-button">
|
||||
<FontIcon value="perm_media" className="nc-appHeader-icon" />
|
||||
</button>
|
||||
<IconMenu icon="add" theme={theme}>
|
||||
{
|
||||
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
||||
<MenuItem
|
||||
|
@ -7,39 +7,15 @@
|
||||
position: relative;
|
||||
padding: 20px 0 10px 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.nc-controlPane-control input,
|
||||
.nc-controlPane-control textarea,
|
||||
.nc-controlPane-control select,
|
||||
.nc-controlPane-control div[contenteditable=true] {
|
||||
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);
|
||||
& input,
|
||||
& textarea,
|
||||
& select,
|
||||
& div[contenteditable=true] {
|
||||
@apply --input;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-controlPane-control input,
|
||||
.nc-controlPane-control textarea,
|
||||
.nc-controlPane-control select {
|
||||
font-family: var(--fontFamilyMono);
|
||||
}
|
||||
|
||||
|
||||
.nc-controlPane-label {
|
||||
display: block;
|
||||
color: var(--controlLabelColor);
|
||||
|
@ -24,7 +24,17 @@ export default class ControlPane extends Component {
|
||||
};
|
||||
|
||||
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 fieldName = field.get('name');
|
||||
const value = entry.getIn(['data', fieldName]);
|
||||
@ -48,9 +58,11 @@ export default class ControlPane extends Component {
|
||||
controlComponent={widget.control}
|
||||
field={field}
|
||||
value={value}
|
||||
mediaPaths={mediaPaths}
|
||||
metadata={metadata}
|
||||
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
|
||||
onValidate={this.props.onValidate.bind(this, fieldName)}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
@ -87,7 +99,9 @@ ControlPane.propTypes = {
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
|
@ -7,7 +7,7 @@
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
z-index: 299;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@ -41,7 +41,7 @@
|
||||
height: 55px;
|
||||
padding: 10px 20px;
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
z-index: 299;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
@ -52,12 +52,14 @@ class EntryEditor extends Component {
|
||||
fields,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
onChange,
|
||||
enableSave,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onValidate,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
onCancelEdit,
|
||||
@ -102,9 +104,11 @@ class EntryEditor extends Component {
|
||||
fields={fields}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
mediaPaths={mediaPaths}
|
||||
getAsset={getAsset}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
ref={c => this.controlPaneRef = c} // eslint-disable-line
|
||||
@ -166,7 +170,9 @@ EntryEditor.propTypes = {
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: 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 CSSTransition from 'react-transition-group/CSSTransition';
|
||||
import classnames from 'classnames';
|
||||
import c from 'classnames';
|
||||
|
||||
export default class Loader extends React.Component {
|
||||
|
||||
@ -50,8 +50,8 @@ export default class Loader extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { active } = this.props;
|
||||
const className = classnames('nc-loader-root', { 'nc-loader-active': active });
|
||||
return <div className={className}>{this.renderChild()}</div>;
|
||||
const { active, className } = this.props;
|
||||
const combinedClassName = c('nc-loader-root', { 'nc-loader-active': active }, className);
|
||||
return <div className={combinedClassName}>{this.renderChild()}</div>;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
: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;
|
||||
--defaultColor: #333;
|
||||
--defaultColorLight: #fff;
|
||||
@ -32,6 +32,27 @@
|
||||
--borderWidth: 2px;
|
||||
--border: solid var(--borderWidth) var(--secondaryColor);
|
||||
--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 {
|
||||
|
@ -16,21 +16,36 @@ class ControlHOC extends Component {
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]),
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
metadata: ImmutablePropTypes.map,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* Allow widgets to provide their own `shouldComponentUpdate` method.
|
||||
*/
|
||||
if (this.wrappedControlShouldComponentUpdate) {
|
||||
return this.wrappedControlShouldComponentUpdate(nextProps);
|
||||
}
|
||||
return this.props.value !== nextProps.value;
|
||||
}
|
||||
|
||||
processInnerControlRef = (wrappedControl) => {
|
||||
if (!wrappedControl) return;
|
||||
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) => {
|
||||
@ -113,12 +128,25 @@ class ControlHOC extends Component {
|
||||
};
|
||||
|
||||
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, {
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
|
@ -1,115 +1,76 @@
|
||||
import PropTypes from 'prop-types';
|
||||
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 { Loader } from '../UI';
|
||||
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_DISPLAY_LENGTH = 50;
|
||||
|
||||
export default class FileControl extends React.Component {
|
||||
state = {
|
||||
processing: false,
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.controlID = uuid();
|
||||
}
|
||||
|
||||
promise = null;
|
||||
|
||||
isValid = () => {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* 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) => {
|
||||
this._fileInput = el;
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { mediaPaths, value } = nextProps;
|
||||
const mediaPath = mediaPaths.get(this.controlID);
|
||||
if (mediaPath && mediaPath !== value) {
|
||||
this.props.onChange(mediaPath);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
this._fileInput.click();
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
const { field, onOpenMediaLibrary } = this.props;
|
||||
return onOpenMediaLibrary({ controlID: this.controlID, privateUpload: field.private });
|
||||
};
|
||||
|
||||
renderFileName = () => {
|
||||
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);
|
||||
}
|
||||
const { value } = this.props;
|
||||
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { processing } = this.state;
|
||||
const fileName = this.renderFileName();
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<span className="nc-fileControl-message">
|
||||
<Loader active />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="nc-fileControl-imageUpload"
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleChange}
|
||||
>
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<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>
|
||||
<input
|
||||
type="file"
|
||||
onChange={this.handleChange}
|
||||
className="nc-fileControl-input"
|
||||
ref={this.handleFileInputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileControl.propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
field: PropTypes.object,
|
||||
};
|
||||
|
@ -1,119 +1,74 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import uuid from 'uuid/v4';
|
||||
import { truncateMiddle } from '../../lib/textHelper';
|
||||
import { Loader } from '../UI';
|
||||
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_DISPLAY_LENGTH = 50;
|
||||
|
||||
export default class ImageControl extends React.Component {
|
||||
state = {
|
||||
processing: false,
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.controlID = uuid();
|
||||
}
|
||||
|
||||
promise = null;
|
||||
|
||||
isValid = () => {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* 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) => {
|
||||
this._fileInput = el;
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { mediaPaths, value } = nextProps;
|
||||
const mediaPath = mediaPaths.get(this.controlID);
|
||||
if (mediaPath && mediaPath !== value) {
|
||||
this.props.onChange(mediaPath);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
this._fileInput.click();
|
||||
const { field, onOpenMediaLibrary } = this.props;
|
||||
return onOpenMediaLibrary({ controlID: this.controlID, forImage: true, 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\//;
|
||||
|
||||
// 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);
|
||||
}
|
||||
renderFileName = () => {
|
||||
const { value } = this.props;
|
||||
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { processing } = this.state;
|
||||
const imageName = this.renderImageName();
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<span className="nc-fileControl-message">
|
||||
<Loader active />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const fileName = this.renderFileName();
|
||||
return (
|
||||
<div
|
||||
className="nc-fileControl-imageUpload"
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleChange}
|
||||
>
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<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>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={this.handleChange}
|
||||
className="nc-fileControl-input"
|
||||
ref={this.handleFileInputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImageControl.propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { List, Map, fromJS } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
||||
import FontIcon from 'react-toolbox/lib/font_icon';
|
||||
import ObjectControl from './ObjectControl';
|
||||
@ -36,7 +37,9 @@ export default class ListControl extends Component {
|
||||
value: PropTypes.node,
|
||||
field: PropTypes.node,
|
||||
forID: PropTypes.string,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
@ -47,6 +50,16 @@ export default class ListControl extends Component {
|
||||
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() {
|
||||
const { field } = this.props;
|
||||
if (field.get('fields')) {
|
||||
@ -147,7 +160,7 @@ export default class ListControl extends Component {
|
||||
};
|
||||
|
||||
renderItem = (item, index) => {
|
||||
const { field, getAsset, onAddAsset, onRemoveAsset } = this.props;
|
||||
const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
const collapsed = itemsCollapsed.get(index);
|
||||
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
|
||||
@ -167,6 +180,8 @@ export default class ListControl extends Component {
|
||||
className="nc-listControl-objectControl"
|
||||
onChange={this.handleChangeFor(index)}
|
||||
getAsset={getAsset}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
mediaPaths={mediaPaths}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
/>
|
||||
|
@ -1,10 +1,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { connect } from 'react-redux';
|
||||
import { Map } from 'immutable';
|
||||
import { Button } from 'react-toolbox/lib/button';
|
||||
import { openMediaLibrary } from '../../../../../actions/mediaLibrary';
|
||||
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
|
||||
|
||||
export default class ToolbarPluginForm extends React.Component {
|
||||
class ToolbarPluginForm extends React.Component {
|
||||
static propTypes = {
|
||||
plugin: PropTypes.object.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
@ -12,6 +15,8 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@ -37,6 +42,8 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
mediaPaths,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -54,6 +61,8 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
onChange={(val) => {
|
||||
this.setState({ data: this.state.data.set(field.get('name'), val) });
|
||||
}}
|
||||
mediaPaths={mediaPaths}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
/>
|
||||
))}
|
||||
</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 React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { resolveWidget } from '../../../../Widgets';
|
||||
|
||||
const ToolbarPluginFormControl = ({
|
||||
@ -10,11 +11,13 @@ const ToolbarPluginFormControl = ({
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
onChange,
|
||||
mediaPaths,
|
||||
onOpenMediaLibrary,
|
||||
}) => {
|
||||
const widget = resolveWidget(field.get('widget') || 'string');
|
||||
const key = `field-${ field.get('name') }`;
|
||||
const Control = widget.control;
|
||||
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange };
|
||||
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange, mediaPaths, onOpenMediaLibrary };
|
||||
|
||||
return (
|
||||
<div className="nc-controlPane-control" key={key}>
|
||||
@ -34,6 +37,8 @@ ToolbarPluginFormControl.propTypes = {
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ToolbarPluginFormControl;
|
||||
|
@ -18,7 +18,7 @@
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
min-height: var(--richTextEditorMinHeight);
|
||||
font-family: var(--fontFamily);
|
||||
font-family: var(--fontFamilyPrimary);
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h1 {
|
||||
|
@ -1,11 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Map } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ControlHOC from './ControlHOC';
|
||||
import { resolveWidget } from '../Widgets';
|
||||
|
||||
export default class ObjectControl extends Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: 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
|
||||
* `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) {
|
||||
const { onAddAsset, onRemoveAsset, getAsset, value, onChange } = this.props;
|
||||
const { onAddAsset, onOpenMediaLibrary, mediaPaths, onRemoveAsset, getAsset, value, onChange } = this.props;
|
||||
if (field.get('widget') === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
@ -37,20 +56,18 @@ export default class ObjectControl extends Component {
|
||||
return (<div className="nc-controlPane-widget" 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>
|
||||
{
|
||||
React.createElement(widget.control, {
|
||||
id: field.get('name'),
|
||||
field,
|
||||
value: fieldValue,
|
||||
onChange: (val, metadata) => {
|
||||
onChange((this.getObjectValue() || Map()).set(field.get('name'), val), metadata);
|
||||
},
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
forID: field.get('name'),
|
||||
})
|
||||
}
|
||||
<ControlHOC
|
||||
controlComponent={widget.control}
|
||||
field={field}
|
||||
value={fieldValue}
|
||||
onChange={this.onChange.bind(this, field.get('name'))}
|
||||
mediaPaths={mediaPaths}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
forID={field.get('name')}
|
||||
/>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
@ -17,7 +17,9 @@ import {
|
||||
navigateToCollection as actionNavigateToCollection,
|
||||
createNewEntryInCollection as actionCreateNewEntryInCollection,
|
||||
} from '../actions/findbar';
|
||||
import { openMediaLibrary as actionOpenMediaLibrary } from '../actions/mediaLibrary';
|
||||
import AppHeader from '../components/AppHeader/AppHeader';
|
||||
import MediaLibrary from '../components/MediaLibrary/MediaLibrary';
|
||||
import { Loader, Toast } from '../components/UI/index';
|
||||
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
|
||||
import { SIMPLE, EDITORIAL_WORKFLOW } from '../constants/publishModes';
|
||||
@ -114,6 +116,7 @@ class App extends React.Component {
|
||||
logoutUser,
|
||||
isFetching,
|
||||
publishMode,
|
||||
openMediaLibrary,
|
||||
} = this.props;
|
||||
|
||||
|
||||
@ -190,6 +193,7 @@ class App extends React.Component {
|
||||
onCreateEntryClick={createNewEntryInCollection}
|
||||
onLogoutClick={logoutUser}
|
||||
toggleDrawer={toggleSidebar}
|
||||
openMediaLibrary={openMediaLibrary}
|
||||
/>
|
||||
<div className="nc-app-entriesPanel">
|
||||
{ isFetching && <TopBarProgress /> }
|
||||
@ -202,6 +206,7 @@ class App extends React.Component {
|
||||
<Route path="/search/:searchTerm" component={SearchPage} />
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
<MediaLibrary/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -231,6 +236,9 @@ function mapDispatchToProps(dispatch) {
|
||||
createNewEntryInCollection: (collectionName) => {
|
||||
dispatch(actionCreateNewEntryInCollection(collectionName));
|
||||
},
|
||||
openMediaLibrary: () => {
|
||||
dispatch(actionOpenMediaLibrary());
|
||||
},
|
||||
logoutUser: () => {
|
||||
dispatch(actionLogoutUser());
|
||||
},
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
import { closeEntry } from '../actions/editor';
|
||||
import { deserializeValues } from '../lib/serializeEntryValues';
|
||||
import { addAsset, removeAsset } from '../actions/media';
|
||||
import { openMediaLibrary } from '../actions/mediaLibrary';
|
||||
import { openSidebar } from '../actions/globalUI';
|
||||
import { selectEntry, getAsset } from '../reducers';
|
||||
import { selectFields } from '../reducers/collections';
|
||||
@ -34,11 +35,13 @@ class EntryPage extends React.Component {
|
||||
createEmptyDraft: PropTypes.func.isRequired,
|
||||
discardDraft: PropTypes.func.isRequired,
|
||||
entry: ImmutablePropTypes.map,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
entryDraft: ImmutablePropTypes.map.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
persistEntry: PropTypes.func.isRequired,
|
||||
deleteEntry: PropTypes.func.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
removeAsset: PropTypes.func.isRequired,
|
||||
closeEntry: PropTypes.func.isRequired,
|
||||
openSidebar: PropTypes.func.isRequired,
|
||||
@ -125,10 +128,12 @@ class EntryPage extends React.Component {
|
||||
entry,
|
||||
entryDraft,
|
||||
fields,
|
||||
mediaPaths,
|
||||
boundGetAsset,
|
||||
collection,
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
openMediaLibrary,
|
||||
addAsset,
|
||||
removeAsset,
|
||||
closeEntry,
|
||||
@ -150,8 +155,10 @@ class EntryPage extends React.Component {
|
||||
fields={fields}
|
||||
fieldsMetaData={entryDraft.get('fieldsMetaData')}
|
||||
fieldsErrors={entryDraft.get('fieldsErrors')}
|
||||
mediaPaths={mediaPaths}
|
||||
onChange={changeDraftField}
|
||||
onValidate={changeDraftFieldValidation}
|
||||
onOpenMediaLibrary={openMediaLibrary}
|
||||
onAddAsset={addAsset}
|
||||
onRemoveAsset={removeAsset}
|
||||
onPersist={this.handlePersistEntry}
|
||||
@ -165,18 +172,20 @@ class EntryPage extends React.Component {
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections, entryDraft } = state;
|
||||
const { collections, entryDraft, mediaLibrary } = state;
|
||||
const slug = ownProps.match.params.slug;
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const newEntry = ownProps.newRecord === true;
|
||||
const fields = selectFields(collection, slug);
|
||||
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
|
||||
const boundGetAsset = getAsset.bind(null, state);
|
||||
const mediaPaths = mediaLibrary.get('controlMedia');
|
||||
return {
|
||||
collection,
|
||||
collections,
|
||||
newEntry,
|
||||
entryDraft,
|
||||
mediaPaths,
|
||||
boundGetAsset,
|
||||
fields,
|
||||
slug,
|
||||
@ -189,6 +198,7 @@ export default connect(
|
||||
{
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
openMediaLibrary,
|
||||
addAsset,
|
||||
removeAsset,
|
||||
loadEntry,
|
||||
|
@ -16,6 +16,7 @@
|
||||
@import "./components/UI/icon/Icon.css";
|
||||
@import "./components/UI/loader/Loader.css";
|
||||
@import "./components/UI/toast/Toast.css";
|
||||
@import "./components/UI/Dialog/Dialog.css";
|
||||
@import "./components/UnpublishedListing/UnpublishedListing.css";
|
||||
@import "./components/UnpublishedListing/UnpublishedListingCardMeta.css";
|
||||
@import "./components/Widgets/BooleanControl.css";
|
||||
@ -32,6 +33,7 @@
|
||||
@import "./containers/App.css";
|
||||
@import "./containers/CollectionPage.css";
|
||||
@import "./containers/Sidebar.css";
|
||||
@import "./components/MediaLibrary/MediaLibrary.css";
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
@ -45,7 +47,7 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--fontFamily);
|
||||
font-family: var(--fontFamilyPrimary);
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
color: #7c8382;
|
||||
@ -54,7 +56,7 @@ body {
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p {
|
||||
margin: 0;
|
||||
font-family: var(--fontFamily);
|
||||
font-family: var(--fontFamilyPrimary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
@ -1,3 +1,6 @@
|
||||
import { pickBy } from 'lodash';
|
||||
import { addParams } from '../../../lib/urlHelper';
|
||||
|
||||
export default class AssetStore {
|
||||
constructor(config, getToken) {
|
||||
this.config = config;
|
||||
@ -52,21 +55,44 @@ export default class AssetStore {
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
request(path, options = {}) {
|
||||
async request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers }).then((response) => {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
const isJson = contentType && contentType.match(/json/);
|
||||
const content = isJson ? await this.parseJsonResponse(response) : response.text();
|
||||
return content;
|
||||
}
|
||||
|
||||
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 = {
|
||||
name: file.name,
|
||||
size: file.size
|
||||
@ -79,33 +105,35 @@ export default class AssetStore {
|
||||
fileData.visibility = 'private';
|
||||
}
|
||||
|
||||
return this.getToken()
|
||||
.then(token => this.request(this.getSignedFormURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ token }`,
|
||||
},
|
||||
body: JSON.stringify(fileData),
|
||||
}))
|
||||
.then((response) => {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await this.request(this.getSignedFormURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ token }`,
|
||||
},
|
||||
body: JSON.stringify(fileData),
|
||||
});
|
||||
const formURL = response.form.url;
|
||||
const formFields = response.form.fields;
|
||||
const assetID = response.asset.id;
|
||||
const assetURL = response.asset.url;
|
||||
const { id, name, size, url } = response.asset;
|
||||
|
||||
const formData = new FormData();
|
||||
Object.keys(formFields).forEach(key => formData.append(key, formFields[key]));
|
||||
formData.append('file', file, file.name);
|
||||
|
||||
return this.request(formURL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(() => {
|
||||
if (this.shouldConfirmUpload) this.confirmRequest(assetID);
|
||||
return { success: true, assetURL };
|
||||
});
|
||||
});
|
||||
await this.request(formURL, { method: 'POST', body: formData });
|
||||
|
||||
if (this.shouldConfirmUpload) {
|
||||
await this.confirmRequest(id);
|
||||
}
|
||||
|
||||
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 { isString, escapeRegExp, flow, partialRight } from 'lodash';
|
||||
|
||||
@ -13,6 +14,12 @@ export function getNewEntryUrl(collectionName, 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.
|
||||
* 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 _ - . ~).
|
||||
|
@ -85,7 +85,11 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
});
|
||||
|
||||
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:
|
||||
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 collections from './collections';
|
||||
import search from './search';
|
||||
import mediaLibrary from './mediaLibrary';
|
||||
import medias, * as fromMedias from './medias';
|
||||
import globalUI from './globalUI';
|
||||
|
||||
@ -20,6 +21,7 @@ const reducers = {
|
||||
entries,
|
||||
editorialWorkflow,
|
||||
entryDraft,
|
||||
mediaLibrary,
|
||||
medias,
|
||||
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;
|
||||
};
|
||||
|
||||
export default function AssetProxy(value, fileObj, uploaded = false) {
|
||||
export default function AssetProxy(value, fileObj, uploaded = false, asset) {
|
||||
const config = store.getState().config;
|
||||
this.value = value;
|
||||
this.fileObj = fileObj;
|
||||
@ -16,6 +16,7 @@ export default function AssetProxy(value, fileObj, uploaded = false) {
|
||||
this.sha = null;
|
||||
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.asset = asset;
|
||||
}
|
||||
|
||||
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);
|
||||
return provider.upload(fileObj, privateUpload).then(
|
||||
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)
|
||||
);
|
||||
|
20
yarn.lock
20
yarn.lock
@ -1476,6 +1476,10 @@ bytes@3.0.0:
|
||||
version "3.0.0"
|
||||
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:
|
||||
version "0.1.0"
|
||||
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"
|
||||
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:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4"
|
||||
@ -8975,6 +8991,10 @@ synesthesia@^1.0.1:
|
||||
dependencies:
|
||||
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:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
|
||||
|
Loading…
x
Reference in New Issue
Block a user