Netlify Large Media integration (#2124)
This commit is contained in:
committed by
Shawn Erquhart
parent
17ae6f3045
commit
da2249c651
@ -223,17 +223,18 @@ export default class Bitbucket {
|
||||
|
||||
return this.api.listAllFiles(this.config.get('media_folder')).then(files =>
|
||||
files.map(({ id, name, path }) => {
|
||||
const getBlobPromise = () =>
|
||||
const getDisplayURL = () =>
|
||||
new Promise((resolve, reject) =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(path, id, { parseText: false })
|
||||
.then(blob => URL.createObjectURL(blob))
|
||||
.then(resolve, reject)
|
||||
.finally(() => sem.leave()),
|
||||
),
|
||||
);
|
||||
|
||||
return { id, name, getBlobPromise, path };
|
||||
return { id, name, getDisplayURL, path };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -245,8 +246,7 @@ export default class Bitbucket {
|
||||
async persistMedia(mediaFile, options = {}) {
|
||||
await this.api.persistFiles([mediaFile], options);
|
||||
const { value, path, fileObj } = mediaFile;
|
||||
const getBlobPromise = () => Promise.resolve(fileObj);
|
||||
return { name: value, size: fileObj.size, getBlobPromise, path: trimStart(path, '/k') };
|
||||
return { name: value, size: fileObj.size, path: trimStart(path, '/k') };
|
||||
}
|
||||
|
||||
deleteFile(path, commitMessage, options) {
|
||||
|
@ -21,7 +21,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"gotrue-js": "^0.9.22",
|
||||
"jwt-decode": "^2.2.0"
|
||||
"ini": "^1.3.5",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^5.2.0",
|
||||
|
@ -127,7 +127,7 @@ export default class GitGatewayAuthenticationPage extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
AuthenticationPage.authClient
|
||||
GitGatewayAuthenticationPage.authClient
|
||||
.login(this.state.email, this.state.password, true)
|
||||
.then(user => {
|
||||
this.props.onLogin(user);
|
||||
|
@ -1,13 +1,20 @@
|
||||
import GoTrue from 'gotrue-js';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { get, pick, intersection } from 'lodash';
|
||||
import { APIError, unsentRequest } from 'netlify-cms-lib-util';
|
||||
import { fromPairs, get, pick, intersection, unzip } from 'lodash';
|
||||
import ini from 'ini';
|
||||
import { APIError, getBlobSHA, unsentRequest } from 'netlify-cms-lib-util';
|
||||
import { GitHubBackend } from 'netlify-cms-backend-github';
|
||||
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
|
||||
import { BitBucketBackend, API as BitBucketAPI } from 'netlify-cms-backend-bitbucket';
|
||||
import GitHubAPI from './GitHubAPI';
|
||||
import GitLabAPI from './GitLabAPI';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import {
|
||||
parsePointerFile,
|
||||
createPointerFile,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
getClient,
|
||||
} from './netlify-lfs-client';
|
||||
|
||||
const localHosts = {
|
||||
localhost: true,
|
||||
@ -17,6 +24,7 @@ const localHosts = {
|
||||
const defaults = {
|
||||
identity: '/.netlify/identity',
|
||||
gateway: '/.netlify/git',
|
||||
largeMedia: '/.netlify/large-media',
|
||||
};
|
||||
|
||||
function getEndpoint(endpoint, netlifySiteURL) {
|
||||
@ -58,7 +66,10 @@ export default class GitGateway {
|
||||
config.getIn(['backend', 'gateway_url'], defaults.gateway),
|
||||
netlifySiteURL,
|
||||
);
|
||||
|
||||
this.netlifyLargeMediaURL = getEndpoint(
|
||||
config.getIn(['backend', 'large_media_url'], defaults.largeMedia),
|
||||
netlifySiteURL,
|
||||
);
|
||||
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
|
||||
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
|
||||
if (backendTypeMatches) {
|
||||
@ -196,14 +207,136 @@ export default class GitGateway {
|
||||
getEntry(collection, slug, path) {
|
||||
return this.backend.getEntry(collection, slug, path);
|
||||
}
|
||||
|
||||
getMedia() {
|
||||
return this.backend.getMedia();
|
||||
return Promise.all([this.backend.getMedia(), this.getLargeMediaClient()]).then(
|
||||
async ([mediaFiles, largeMediaClient]) => {
|
||||
if (!largeMediaClient.enabled) {
|
||||
return mediaFiles;
|
||||
}
|
||||
const largeMediaURLThunks = await this.getLargeMedia(mediaFiles);
|
||||
return mediaFiles.map(({ id, url, getDisplayURL, ...rest }) => ({
|
||||
...rest,
|
||||
id,
|
||||
url,
|
||||
urlIsPublicPath: false,
|
||||
getDisplayURL: largeMediaURLThunks[id] ? largeMediaURLThunks[id] : getDisplayURL,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// this method memoizes this._getLargeMediaClient so that there can
|
||||
// only be one client at a time
|
||||
getLargeMediaClient() {
|
||||
if (this._largeMediaClientPromise) {
|
||||
return this._largeMediaClientPromise;
|
||||
}
|
||||
this._largeMediaClientPromise = this._getLargeMediaClient();
|
||||
return this._largeMediaClientPromise;
|
||||
}
|
||||
_getLargeMediaClient() {
|
||||
const netlifyLargeMediaEnabledPromise = this.api
|
||||
.readFile('.lfsconfig')
|
||||
.then(ini.decode)
|
||||
.then(({ lfs: { url } }) => new URL(url))
|
||||
.then(lfsURL => ({ enabled: lfsURL.hostname.endsWith('netlify.com') }))
|
||||
.catch(err => ({ enabled: false, err }));
|
||||
|
||||
const lfsPatternsPromise = this.api
|
||||
.readFile('.gitattributes')
|
||||
.then(getLargeMediaPatternsFromGitAttributesFile)
|
||||
.then(patterns => ({ patterns }))
|
||||
.catch(err => (err.message.includes('404') ? [] : { err }));
|
||||
|
||||
return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
|
||||
([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
|
||||
const enabled = maybeEnabled && !patternsErr;
|
||||
|
||||
// We expect LFS patterns to exist when the .lfsconfig states
|
||||
// that we're using Netlify Large Media
|
||||
if (maybeEnabled && patternsErr) {
|
||||
console.error(patternsErr);
|
||||
}
|
||||
|
||||
return getClient({
|
||||
enabled,
|
||||
rootURL: this.netlifyLargeMediaURL,
|
||||
makeAuthorizedRequest: this.requestFunction,
|
||||
patterns,
|
||||
transformImages: this.config.getIn(
|
||||
['backend', 'use_large_media_transforms_in_media_library'],
|
||||
true,
|
||||
)
|
||||
? { nf_resize: 'fit', w: 280, h: 160 }
|
||||
: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
getLargeMedia(mediaFiles) {
|
||||
return this.getLargeMediaClient().then(client => {
|
||||
const largeMediaItems = mediaFiles
|
||||
.filter(({ path }) => client.matchPath(path))
|
||||
.map(({ id, path }) => ({ path, sha: id }));
|
||||
return this.backend
|
||||
.fetchFiles(largeMediaItems)
|
||||
.then(items =>
|
||||
items.map(({ file: { sha }, data }) => {
|
||||
const parsedPointerFile = parsePointerFile(data);
|
||||
return [
|
||||
{
|
||||
pointerId: sha,
|
||||
resourceId: parsedPointerFile.sha,
|
||||
},
|
||||
parsedPointerFile,
|
||||
];
|
||||
}),
|
||||
)
|
||||
.then(unzip)
|
||||
.then(async ([idMaps, files]) => [
|
||||
idMaps,
|
||||
await client.getResourceDownloadURLThunks(files).then(fromPairs),
|
||||
])
|
||||
.then(([idMaps, resourceMap]) =>
|
||||
idMaps.map(({ pointerId, resourceId }) => [pointerId, resourceMap[resourceId]]),
|
||||
)
|
||||
.then(fromPairs);
|
||||
});
|
||||
}
|
||||
persistEntry(entry, mediaFiles, options) {
|
||||
return this.backend.persistEntry(entry, mediaFiles, options);
|
||||
}
|
||||
persistMedia(mediaFile, options) {
|
||||
return this.backend.persistMedia(mediaFile, options);
|
||||
const { fileObj, path, value } = mediaFile;
|
||||
const { name, size } = fileObj;
|
||||
return this.getLargeMediaClient().then(client => {
|
||||
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
if (!client.enabled || !client.matchPath(fixedPath)) {
|
||||
return this.backend.persistMedia(mediaFile, options);
|
||||
}
|
||||
|
||||
return getBlobSHA(fileObj).then(async sha => {
|
||||
await client.uploadResource({ sha, size }, fileObj);
|
||||
const pointerFileString = createPointerFile({ sha, size });
|
||||
const pointerFileBlob = new Blob([pointerFileString]);
|
||||
const pointerFile = new File([pointerFileBlob], name, { type: 'text/plain' });
|
||||
const pointerFileSHA = await getBlobSHA(pointerFile);
|
||||
const persistMediaArgument = {
|
||||
fileObj: pointerFile,
|
||||
size: pointerFileBlob.size,
|
||||
path,
|
||||
sha: pointerFileSHA,
|
||||
raw: pointerFileString,
|
||||
value,
|
||||
};
|
||||
const persistedMediaFile = await this.backend.persistMedia(persistMediaArgument, options);
|
||||
return {
|
||||
...persistedMediaFile,
|
||||
urlIsPublicPath: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
deleteFile(path, commitMessage, options) {
|
||||
return this.backend.deleteFile(path, commitMessage, options);
|
||||
|
@ -0,0 +1,189 @@
|
||||
import { filter, flow, fromPairs, map } from 'lodash/fp';
|
||||
import minimatch from 'minimatch';
|
||||
|
||||
//
|
||||
// Pointer file parsing
|
||||
|
||||
const splitIntoLines = str => str.split('\n');
|
||||
const splitIntoWords = str => str.split(/\s+/g);
|
||||
const isNonEmptyString = str => str !== '';
|
||||
const withoutEmptyLines = flow([map(str => str.trim()), filter(isNonEmptyString)]);
|
||||
export const parsePointerFile = flow([
|
||||
splitIntoLines,
|
||||
withoutEmptyLines,
|
||||
map(splitIntoWords),
|
||||
fromPairs,
|
||||
({ size, oid, ...rest }) => ({
|
||||
size: parseInt(size),
|
||||
sha: oid.split(':')[1],
|
||||
...rest,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const createPointerFile = ({ size, sha }) => `\
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:${sha}
|
||||
size ${size}
|
||||
`;
|
||||
|
||||
//
|
||||
// .gitattributes file parsing
|
||||
|
||||
const removeGitAttributesCommentsFromLine = line => line.split('#')[0];
|
||||
|
||||
const parseGitPatternAttribute = attributeString => {
|
||||
// There are three kinds of attribute settings:
|
||||
// - a key=val pair sets an attribute to a specific value
|
||||
// - a key without a value and a leading hyphen sets an attribute to false
|
||||
// - a key without a value and no leading hyphen sets an attribute
|
||||
// to true
|
||||
if (attributeString.includes('=')) {
|
||||
return attributeString.split('=');
|
||||
}
|
||||
if (attributeString.startsWith('-')) {
|
||||
return [attributeString.slice(1), false];
|
||||
}
|
||||
return [attributeString, true];
|
||||
};
|
||||
|
||||
const parseGitPatternAttributes = flow([map(parseGitPatternAttribute), fromPairs]);
|
||||
|
||||
const parseGitAttributesPatternLine = flow([
|
||||
splitIntoWords,
|
||||
([pattern, ...attributes]) => [pattern, parseGitPatternAttributes(attributes)],
|
||||
]);
|
||||
|
||||
const parseGitAttributesFileToPatternAttributePairs = flow([
|
||||
splitIntoLines,
|
||||
map(removeGitAttributesCommentsFromLine),
|
||||
withoutEmptyLines,
|
||||
map(parseGitAttributesPatternLine),
|
||||
]);
|
||||
|
||||
export const getLargeMediaPatternsFromGitAttributesFile = flow([
|
||||
parseGitAttributesFileToPatternAttributePairs,
|
||||
filter(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
([pattern, attributes]) =>
|
||||
attributes.filter === 'lfs' && attributes.diff === 'lfs' && attributes.merge === 'lfs',
|
||||
),
|
||||
map(([pattern]) => pattern),
|
||||
]);
|
||||
|
||||
export const matchPath = ({ patterns }, path) =>
|
||||
patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
|
||||
|
||||
//
|
||||
// API interactions
|
||||
|
||||
const defaultContentHeaders = {
|
||||
Accept: 'application/vnd.git-lfs+json',
|
||||
['Content-Type']: 'application/vnd.git-lfs+json',
|
||||
};
|
||||
|
||||
const resourceExists = async ({ rootURL, makeAuthorizedRequest }, { sha, size }) => {
|
||||
const response = await makeAuthorizedRequest({
|
||||
url: `${rootURL}/verify`,
|
||||
method: 'POST',
|
||||
headers: defaultContentHeaders,
|
||||
body: JSON.stringify({ oid: sha, size }),
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: what kind of error to throw here? APIError doesn't seem
|
||||
// to fit
|
||||
};
|
||||
|
||||
const getDownloadURLThunkFromSha = (
|
||||
{ rootURL, makeAuthorizedRequest, transformImages: t },
|
||||
sha,
|
||||
) => () =>
|
||||
makeAuthorizedRequest(
|
||||
`${rootURL}/origin/${sha}${
|
||||
t && Object.keys(t).length > 0 ? `?nf_resize=${t.nf_resize}&w=${t.w}&h=${t.h}` : ''
|
||||
}`,
|
||||
)
|
||||
.then(res => (res.ok ? res : Promise.reject(res)))
|
||||
.then(res => res.blob())
|
||||
.then(blob => URL.createObjectURL(blob))
|
||||
.catch(err => console.error(err) || Promise.resolve(''));
|
||||
|
||||
// We allow users to get thunks which load the blobs instead of fully
|
||||
// resolved blob URLs so that media clients can download the blobs
|
||||
// lazily. This behaves more similarly to the behavior of string
|
||||
// URLs, which only trigger an image download when the DOM element for
|
||||
// that image is created.
|
||||
const getResourceDownloadURLThunks = (clientConfig, objects) =>
|
||||
Promise.resolve(objects.map(({ sha }) => [sha, getDownloadURLThunkFromSha(clientConfig, sha)]));
|
||||
|
||||
const getResourceDownloadURLs = (clientConfig, objects) =>
|
||||
getResourceDownloadURLThunks(clientConfig, objects)
|
||||
.then(map(([sha, thunk]) => Promise.all([sha, thunk()])))
|
||||
.then(Promise.all.bind(Promise));
|
||||
|
||||
const uploadOperation = objects => ({
|
||||
operation: 'upload',
|
||||
transfers: ['basic'],
|
||||
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
|
||||
});
|
||||
|
||||
const getResourceUploadURLs = async ({ rootURL, makeAuthorizedRequest }, objects) => {
|
||||
const response = await makeAuthorizedRequest({
|
||||
url: `${rootURL}/objects/batch`,
|
||||
method: 'POST',
|
||||
headers: defaultContentHeaders,
|
||||
body: JSON.stringify(uploadOperation(objects)),
|
||||
});
|
||||
return (await response.json()).objects.map(object => {
|
||||
if (object.error) {
|
||||
throw new Error(object.error.message);
|
||||
}
|
||||
return object.actions.upload.href;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadBlob = (clientConfig, uploadURL, blob) =>
|
||||
fetch(uploadURL, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
});
|
||||
|
||||
const uploadResource = async (clientConfig, { sha, size }, resource) => {
|
||||
const existingFile = await resourceExists(clientConfig, { sha, size });
|
||||
if (existingFile) {
|
||||
return sha;
|
||||
}
|
||||
const [uploadURL] = await getResourceUploadURLs(clientConfig, [{ sha, size }]);
|
||||
await uploadBlob(clientConfig, uploadURL, resource);
|
||||
return sha;
|
||||
};
|
||||
|
||||
//
|
||||
// Create Large Media client
|
||||
|
||||
const configureFn = (config, fn) => (...args) => fn(config, ...args);
|
||||
const clientFns = {
|
||||
resourceExists,
|
||||
getResourceUploadURLs,
|
||||
getResourceDownloadURLs,
|
||||
getResourceDownloadURLThunks,
|
||||
uploadResource,
|
||||
matchPath,
|
||||
};
|
||||
export const getClient = clientConfig => {
|
||||
return flow([
|
||||
Object.keys,
|
||||
map(key => [key, configureFn(clientConfig, clientFns[key])]),
|
||||
fromPairs,
|
||||
configuredFns => ({
|
||||
...configuredFns,
|
||||
patterns: clientConfig.patterns,
|
||||
enabled: clientConfig.enabled,
|
||||
}),
|
||||
])(clientFns);
|
||||
};
|
@ -164,7 +164,7 @@ export default class GitHub {
|
||||
if (url.pathname.match(/.svg$/)) {
|
||||
url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true';
|
||||
}
|
||||
return { id: sha, name, size, url: url.href, path };
|
||||
return { id: sha, name, size, url: url.href, urlIsPublicPath: true, path };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -147,7 +147,7 @@ export default class GitLab {
|
||||
|
||||
return this.api.listAllFiles(this.config.get('media_folder')).then(files =>
|
||||
files.map(({ id, name, path }) => {
|
||||
const getBlobPromise = () =>
|
||||
const getDisplayURL = () =>
|
||||
new Promise((resolve, reject) =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
@ -159,12 +159,13 @@ export default class GitLab {
|
||||
}
|
||||
return blob;
|
||||
})
|
||||
.then(blob => URL.createObjectURL(blob))
|
||||
.then(resolve, reject)
|
||||
.finally(() => sem.leave()),
|
||||
),
|
||||
);
|
||||
|
||||
return { id, name, getBlobPromise, path };
|
||||
return { id, name, getDisplayURL, path };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -176,8 +177,7 @@ export default class GitLab {
|
||||
async persistMedia(mediaFile, options = {}) {
|
||||
await this.api.persistFiles([mediaFile], options);
|
||||
const { value, path, fileObj } = mediaFile;
|
||||
const getBlobPromise = () => Promise.resolve(fileObj);
|
||||
return { name: value, size: fileObj.size, getBlobPromise, path: trimStart(path, '/') };
|
||||
return { name: value, size: fileObj.size, path: trimStart(path, '/') };
|
||||
}
|
||||
|
||||
deleteFile(path, commitMessage, options) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Map } from 'immutable';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { getBlobSHA } from 'netlify-cms-lib-util';
|
||||
import { currentBackend } from 'src/backend';
|
||||
import { createAssetProxy } from 'ValueObjects/AssetProxy';
|
||||
import { selectIntegration } from 'Reducers';
|
||||
@ -119,7 +120,11 @@ export function loadMedia(opts = {}) {
|
||||
backend
|
||||
.getMedia()
|
||||
.then(files => dispatch(mediaLoaded(files)))
|
||||
.catch(error => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed())),
|
||||
.catch(
|
||||
error =>
|
||||
console.error(error) ||
|
||||
dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, delay);
|
||||
@ -153,13 +158,17 @@ export function persistMedia(file, opts = {}) {
|
||||
dispatch(mediaPersisting());
|
||||
|
||||
try {
|
||||
const id = await getBlobSHA(file);
|
||||
const getDisplayURL = () => URL.createObjectURL(file);
|
||||
const assetProxy = await createAssetProxy(fileName, file, false, privateUpload);
|
||||
dispatch(addAsset(assetProxy));
|
||||
if (!integration) {
|
||||
const asset = await backend.persistMedia(state.config, assetProxy);
|
||||
return dispatch(mediaPersisted(asset));
|
||||
return dispatch(mediaPersisted({ id, getDisplayURL, ...asset }));
|
||||
}
|
||||
return dispatch(mediaPersisted(assetProxy.asset, { privateUpload }));
|
||||
return dispatch(
|
||||
mediaPersisted({ id, getDisplayURL, ...assetProxy.asset }, { privateUpload }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
@ -221,16 +230,26 @@ export function deleteMedia(file, opts = {}) {
|
||||
}
|
||||
|
||||
export function loadMediaDisplayURL(file) {
|
||||
return async dispatch => {
|
||||
const { getBlobPromise, id } = file;
|
||||
|
||||
if (id && getBlobPromise) {
|
||||
return async (dispatch, getState) => {
|
||||
const { getDisplayURL, id, url, urlIsPublicPath } = file;
|
||||
const { mediaLibrary: mediaLibraryState } = getState();
|
||||
const displayURLPath = ['displayURLs', id];
|
||||
const shouldLoadDisplayURL =
|
||||
id &&
|
||||
((url && urlIsPublicPath) ||
|
||||
(getDisplayURL &&
|
||||
!mediaLibraryState.getIn([...displayURLPath, 'url']) &&
|
||||
!mediaLibraryState.getIn([...displayURLPath, 'isFetching']) &&
|
||||
!mediaLibraryState.getIn([...displayURLPath, 'err'])));
|
||||
if (shouldLoadDisplayURL) {
|
||||
try {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
const blob = await getBlobPromise();
|
||||
const newURL = window.URL.createObjectURL(blob);
|
||||
dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
return newURL;
|
||||
const newURL = (urlIsPublicPath && url) || (await getDisplayURL());
|
||||
if (newURL) {
|
||||
dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
return newURL;
|
||||
}
|
||||
throw new Error('No display URL was returned!');
|
||||
} catch (err) {
|
||||
dispatch(mediaDisplayURLFailure(id, err));
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { orderBy, map } from 'lodash';
|
||||
import { Map } from 'immutable';
|
||||
import { translate } from 'react-polyglot';
|
||||
import fuzzy from 'fuzzy';
|
||||
import { resolvePath, fileExtension } from 'netlify-cms-lib-util';
|
||||
@ -26,11 +25,13 @@ const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
||||
|
||||
const fileShape = {
|
||||
key: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
size: PropTypes.number,
|
||||
queryOrder: PropTypes.number,
|
||||
url: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
urlIsPublicPath: PropTypes.bool,
|
||||
getDisplayURL: PropTypes.func,
|
||||
};
|
||||
|
||||
class MediaLibrary extends React.Component {
|
||||
@ -97,28 +98,9 @@ class MediaLibrary extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getDisplayURL = file => {
|
||||
const { isVisible, loadMediaDisplayURL, displayURLs } = this.props;
|
||||
|
||||
if (!isVisible) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (file && file.url) {
|
||||
return file.url;
|
||||
}
|
||||
|
||||
const { url, isFetching } = displayURLs.get(file.id, Map()).toObject();
|
||||
|
||||
if (url && url !== '') {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (!isFetching) {
|
||||
loadMediaDisplayURL(file);
|
||||
}
|
||||
|
||||
return '';
|
||||
loadDisplayURL = file => {
|
||||
const { loadMediaDisplayURL } = this.props;
|
||||
loadMediaDisplayURL(file);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -137,7 +119,7 @@ class MediaLibrary extends React.Component {
|
||||
toTableData = files => {
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, getBlobPromise }) => {
|
||||
files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, getDisplayURL }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
@ -148,7 +130,7 @@ class MediaLibrary extends React.Component {
|
||||
queryOrder,
|
||||
url,
|
||||
urlIsPublicPath,
|
||||
getBlobPromise,
|
||||
getDisplayURL,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
};
|
||||
@ -291,6 +273,7 @@ class MediaLibrary extends React.Component {
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload,
|
||||
displayURLs,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
@ -322,7 +305,8 @@ class MediaLibrary extends React.Component {
|
||||
setScrollContainerRef={ref => (this.scrollContainerRef = ref)}
|
||||
handleAssetClick={this.handleAssetClick}
|
||||
handleLoadMore={this.handleLoadMore}
|
||||
getDisplayURL={this.getDisplayURL}
|
||||
displayURLs={displayURLs}
|
||||
loadDisplayURL={this.loadDisplayURL}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from 'react-emotion';
|
||||
import { colors, borders, lengths } from 'netlify-cms-ui-default';
|
||||
|
||||
@ -43,32 +44,35 @@ const CardText = styled.p`
|
||||
line-height: 1.3 !important;
|
||||
`;
|
||||
|
||||
const MediaLibraryCard = ({
|
||||
isSelected,
|
||||
displayURL,
|
||||
text,
|
||||
onClick,
|
||||
width,
|
||||
margin,
|
||||
isPrivate,
|
||||
type,
|
||||
}) => (
|
||||
<Card
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
margin={margin}
|
||||
tabIndex="-1"
|
||||
isPrivate={isPrivate}
|
||||
>
|
||||
<div>{displayURL ? <CardImage src={displayURL} /> : <CardFileIcon>{type}</CardFileIcon>}</div>
|
||||
<CardText>{text}</CardText>
|
||||
</Card>
|
||||
);
|
||||
class MediaLibraryCard extends React.Component {
|
||||
render() {
|
||||
const { isSelected, displayURL, text, onClick, width, margin, isPrivate, type } = this.props;
|
||||
const url = displayURL.get('url');
|
||||
return (
|
||||
<Card
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
margin={margin}
|
||||
tabIndex="-1"
|
||||
isPrivate={isPrivate}
|
||||
>
|
||||
<div>{url ? <CardImage src={url} /> : <CardFileIcon>{type}</CardFileIcon>}</div>
|
||||
<CardText>{text}</CardText>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
UNSAFE_componentWillMount() {
|
||||
const { displayURL, loadDisplayURL } = this.props;
|
||||
if (!displayURL || (!displayURL.url && !displayURL.isFetching && !displayURL.err)) {
|
||||
loadDisplayURL();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaLibraryCard.propTypes = {
|
||||
isSelected: PropTypes.bool,
|
||||
displayURL: PropTypes.string,
|
||||
displayURL: ImmutablePropTypes.map.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import styled from 'react-emotion';
|
||||
import Waypoint from 'react-waypoint';
|
||||
import MediaLibraryCard from './MediaLibraryCard';
|
||||
import { Map } from 'immutable';
|
||||
import { colors } from 'netlify-cms-ui-default';
|
||||
|
||||
const CardGridContainer = styled.div`
|
||||
@ -33,7 +34,8 @@ const MediaLibraryCardGrid = ({
|
||||
cardWidth,
|
||||
cardMargin,
|
||||
isPrivate,
|
||||
getDisplayURL,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
}) => (
|
||||
<CardGridContainer innerRef={setScrollContainerRef}>
|
||||
<CardGrid>
|
||||
@ -46,7 +48,8 @@ const MediaLibraryCardGrid = ({
|
||||
width={cardWidth}
|
||||
margin={cardMargin}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={file.isViewableImage && getDisplayURL(file)}
|
||||
displayURL={displayURLs.get(file.id, Map())}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
/>
|
||||
))}
|
||||
@ -76,7 +79,7 @@ MediaLibraryCardGrid.propTypes = {
|
||||
paginatingMessage: PropTypes.string,
|
||||
cardWidth: PropTypes.string.isRequired,
|
||||
cardMargin: PropTypes.string.isRequired,
|
||||
getDisplayURL: PropTypes.func.isRequired,
|
||||
loadDisplayURL: PropTypes.func.isRequired,
|
||||
isPrivate: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
@ -93,7 +93,8 @@ const MediaLibraryModal = ({
|
||||
setScrollContainerRef,
|
||||
handleAssetClick,
|
||||
handleLoadMore,
|
||||
getDisplayURL,
|
||||
loadDisplayURL,
|
||||
displayURLs,
|
||||
t,
|
||||
}) => {
|
||||
const filteredFiles = forImage ? handleFilter(files) : files;
|
||||
@ -171,7 +172,8 @@ const MediaLibraryModal = ({
|
||||
cardWidth={cardWidth}
|
||||
cardMargin={cardMargin}
|
||||
isPrivate={privateUpload}
|
||||
getDisplayURL={getDisplayURL}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
displayURLs={displayURLs}
|
||||
/>
|
||||
</StyledModal>
|
||||
);
|
||||
@ -179,11 +181,13 @@ const MediaLibraryModal = ({
|
||||
|
||||
const fileShape = {
|
||||
key: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
size: PropTypes.number,
|
||||
queryOrder: PropTypes.number,
|
||||
url: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
urlIsPublicPath: PropTypes.bool,
|
||||
getDisplayURL: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
MediaLibraryModal.propTypes = {
|
||||
@ -213,7 +217,7 @@ MediaLibraryModal.propTypes = {
|
||||
setScrollContainerRef: PropTypes.func.isRequired,
|
||||
handleAssetClick: PropTypes.func.isRequired,
|
||||
handleLoadMore: PropTypes.func.isRequired,
|
||||
getDisplayURL: PropTypes.func.isRequired,
|
||||
loadDisplayURL: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
@ -169,10 +169,14 @@ const mediaLibrary = (state = Map(defaultState), action) => {
|
||||
|
||||
case MEDIA_DISPLAY_URL_FAILURE: {
|
||||
const displayURLPath = ['displayURLs', action.payload.key];
|
||||
return state
|
||||
.setIn([...displayURLPath, 'isFetching'], false)
|
||||
.setIn([...displayURLPath, 'err'], action.payload.err)
|
||||
.deleteIn([...displayURLPath, 'url']);
|
||||
return (
|
||||
state
|
||||
.setIn([...displayURLPath, 'isFetching'], false)
|
||||
// make sure that err is set so the CMS won't attempt to load
|
||||
// the image again
|
||||
.setIn([...displayURLPath, 'err'], action.payload.err || true)
|
||||
.deleteIn([...displayURLPath, 'url'])
|
||||
);
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
|
@ -16,7 +16,8 @@
|
||||
"build": "cross-env NODE_ENV=production webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"localforage": "^1.4.2"
|
||||
"localforage": "^1.4.2",
|
||||
"js-sha256": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^5.2.0",
|
||||
|
12
packages/netlify-cms-lib-util/src/getBlobSHA.js
Normal file
12
packages/netlify-cms-lib-util/src/getBlobSHA.js
Normal file
@ -0,0 +1,12 @@
|
||||
import sha256 from 'js-sha256';
|
||||
|
||||
export default blob =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = ({ target: { result } }) => resolve(sha256(result));
|
||||
fr.onerror = err => {
|
||||
fr.abort();
|
||||
reject(err);
|
||||
};
|
||||
fr.readAsArrayBuffer(blob);
|
||||
});
|
@ -7,3 +7,4 @@ export { filterPromises, resolvePromiseProperties, then } from './promise';
|
||||
export unsentRequest from './unsentRequest';
|
||||
export { filterByPropExtension, parseResponse, responseParser } from './backendUtil';
|
||||
export loadScript from './loadScript';
|
||||
export getBlobSHA from './getBlobSHA';
|
||||
|
Reference in New Issue
Block a user