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:
Shawn Erquhart 2017-08-14 09:00:47 -04:00
parent 2a4af64a71
commit 6b45a46a39
43 changed files with 1662 additions and 362 deletions

View File

@ -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": {

View File

@ -1,5 +1,7 @@
backend:
name: test-repo
name: github
repo: netlify/netlify-cms
branch: pr-554-backend
media_folder: "assets/uploads"

View File

@ -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": {

View File

@ -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
View 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 };
}

View File

@ -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 });
}

View File

@ -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');
});
}

View File

@ -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 }),
});
}
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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,

View File

@ -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

View File

@ -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);

View File

@ -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,

View File

@ -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;

View File

@ -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,

View 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;
}

View 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);

View 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;
}

View 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;

View 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%;
}

View 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;

View 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;

View File

@ -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>;
}
}

View File

@ -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 {

View File

@ -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,

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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}
/>

View File

@ -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);

View File

@ -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;

View File

@ -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 {

View File

@ -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>);
}

View File

@ -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());
},

View File

@ -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,

View File

@ -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 {

View File

@ -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);
}
}
}

View File

@ -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 _ - . ~).

View File

@ -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));

View File

@ -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,
};

View 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;

View File

@ -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)
);

View File

@ -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"