merges master

This commit is contained in:
Jarrod Flesch
2020-06-26 15:20:29 -04:00
38 changed files with 871 additions and 105 deletions

View File

@@ -1,5 +1,5 @@
const deepmerge = require('deepmerge');
const combineMerge = require('../../utilities/combineMerge');
const overwriteMerge = require('../../utilities/overwriteMerge');
const { NotFound, Forbidden } = require('../../errors');
const executePolicy = require('../executePolicy');
const performFieldOperations = require('../../fields/performFieldOperations');
@@ -62,7 +62,7 @@ const update = async (args) => {
// 3. Merge updates into existing data
// /////////////////////////////////////
options.data = deepmerge(userJSON, options.data, { arrayMerge: combineMerge });
options.data = deepmerge(userJSON, options.data, { arrayMerge: overwriteMerge });
// /////////////////////////////////////
// 4. Execute field-level hooks, policies, and validation

View File

@@ -37,7 +37,7 @@
}
span, svg {
vertical-align: middle;
vertical-align: top;
}
&--size-medium {
@@ -74,6 +74,7 @@
&--style-none {
padding: 0;
margin: 0;
}
&--round {

View File

@@ -7,7 +7,7 @@ import './index.scss';
const baseClass = 'copy-to-clipboard';
const CopyToClipboard = ({ value }) => {
const CopyToClipboard = ({ value, defaultMessage, successMessage }) => {
const ref = useRef(null);
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
@@ -45,8 +45,8 @@ const CopyToClipboard = ({ value }) => {
>
<Copy />
<Tooltip>
{copied && 'Copied'}
{!copied && 'Copy'}
{copied && successMessage}
{!copied && defaultMessage}
</Tooltip>
<textarea
readOnly
@@ -62,10 +62,14 @@ const CopyToClipboard = ({ value }) => {
CopyToClipboard.defaultProps = {
value: '',
defaultMessage: 'Copy',
successMessage: 'Copied',
};
CopyToClipboard.propTypes = {
value: PropTypes.string,
defaultMessage: PropTypes.string,
successMessage: PropTypes.string,
};
export default CopyToClipboard;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import config from '../../../../config';
import CopyToClipboard from '../../CopyToClipboard';
import formatFilesize from '../../../../../uploads/formatFilesize';
import './index.scss';
const { serverURL } = config;
const baseClass = 'file-meta';
const Meta = (props) => {
const {
filename, filesize, width, height, mimeType, staticURL,
} = props;
const fileURL = `${serverURL}${staticURL}/${filename}`;
return (
<div className={baseClass}>
<div className={`${baseClass}__url`}>
<a
href={fileURL}
target="_blank"
rel="noopener noreferrer"
>
{filename}
</a>
<CopyToClipboard
value={fileURL}
defaultMessage="Copy URL"
/>
</div>
<div className={`${baseClass}__size-type`}>
{formatFilesize(filesize)}
{(width && height) && (
<>
&nbsp;-&nbsp;
{width}
x
{height}
</>
)}
{mimeType && (
<>
&nbsp;-&nbsp;
{mimeType}
</>
)}
</div>
</div>
);
};
Meta.defaultProps = {
width: undefined,
height: undefined,
sizes: undefined,
};
Meta.propTypes = {
filename: PropTypes.string.isRequired,
mimeType: PropTypes.string.isRequired,
filesize: PropTypes.number.isRequired,
staticURL: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sizes: PropTypes.shape({}),
};
export default Meta;

View File

@@ -0,0 +1,23 @@
@import '../../../../scss/styles.scss';
.file-meta {
&__url {
display: flex;
a {
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__size-type,
&__url a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import FileGraphic from '../../graphics/File';
import config from '../../../config';
import getThumbnail from '../../../../uploads/getThumbnail';
import Button from '../Button';
import Meta from './Meta';
import Chevron from '../../icons/Chevron';
import './index.scss';
const { serverURL } = config;
const baseClass = 'file-details';
const FileDetails = (props) => {
const {
filename, mimeType, filesize, staticURL, adminThumbnail, sizes, handleRemove, width, height,
} = props;
const [moreInfoOpen, setMoreInfoOpen] = useState(false);
const thumbnail = getThumbnail(mimeType, staticURL, filename, sizes, adminThumbnail);
return (
<div className={baseClass}>
<header>
<div className={`${baseClass}__thumbnail`}>
{thumbnail && (
<img
src={`${serverURL}${thumbnail}`}
alt={filename}
/>
)}
{!thumbnail && (
<FileGraphic />
)}
</div>
<div className={`${baseClass}__main-detail`}>
<Meta
staticURL={staticURL}
filename={filename}
filesize={filesize}
width={width}
height={height}
mimeType={mimeType}
/>
{sizes && (
<Button
className={`${baseClass}__toggle-more-info${moreInfoOpen ? ' open' : ''}`}
buttonStyle="none"
onClick={() => setMoreInfoOpen(!moreInfoOpen)}
>
{!moreInfoOpen && (
<>
More info
<Chevron />
</>
)}
{moreInfoOpen && (
<>
Less info
<Chevron />
</>
)}
</Button>
)}
</div>
{handleRemove && (
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={handleRemove}
className={`${baseClass}__remove`}
/>
)}
</header>
{sizes && (
<AnimateHeight
className={`${baseClass}__more-info`}
height={moreInfoOpen ? 'auto' : 0}
>
<ul className={`${baseClass}__sizes`}>
{Object.entries(sizes).map(([key, val]) => {
return (
<li key={key}>
<div className={`${baseClass}__size-label`}>
{key}
</div>
<Meta
{...val}
mimeType={mimeType}
staticURL={staticURL}
/>
</li>
);
})}
</ul>
</AnimateHeight>
)}
</div>
);
};
FileDetails.defaultProps = {
adminThumbnail: undefined,
handleRemove: undefined,
width: undefined,
height: undefined,
sizes: undefined,
};
FileDetails.propTypes = {
filename: PropTypes.string.isRequired,
mimeType: PropTypes.string.isRequired,
filesize: PropTypes.number.isRequired,
staticURL: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sizes: PropTypes.shape({}),
adminThumbnail: PropTypes.string,
handleRemove: PropTypes.func,
};
export default FileDetails;

View File

@@ -0,0 +1,110 @@
@import '../../../scss/styles.scss';
.file-details {
header {
display: flex;
align-items: flex-start;
border-bottom: $style-stroke-width-m solid white;
}
&__remove {
margin: $baseline $baseline $baseline 0;
}
&__thumbnail {
max-height: base(6);
min-height: 100%;
width: base(7);
flex-shrink: 0;
align-self: stretch;
img, svg {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__main-detail {
padding: $baseline base(1.5);
width: auto;
flex-grow: 1;
min-width: 0;
align-self: center;
}
&__toggle-more-info {
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__toggle-more-info.open {
.icon--chevron {
transform: rotate(180deg);
}
}
&__sizes {
margin: 0;
padding: base(1.5) $baseline 0;
list-style: none;
display: flex;
flex-wrap: wrap;
li {
width: 50%;
padding: 0 base(.5);
margin-bottom: $baseline;
}
}
&__size-label {
color: $color-gray;
}
@include large-break {
&__thumbnail {
width: base(5);
}
&__main-detail {
padding: $baseline;
}
&__sizes {
display: block;
padding: $baseline $baseline base(.5);
li {
padding: 0;
width: 100%;
}
}
}
@include mid-break {
header {
flex-wrap: wrap;
}
&__thumbnail {
width: 50%;
order: 1;
}
&__remove {
order: 2;
margin-left: auto;
margin-right: $baseline;
}
&__main-detail {
border-top: $style-stroke-width-m solid white;
order: 3;
}
}
}

View File

@@ -4,50 +4,17 @@ import React, {
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import X from '../../icons/X';
import reducer from './reducer';
import './index.scss';
const baseClass = 'status-list';
const Context = createContext({});
const initialStatus = [];
const statusReducer = (state, action) => {
switch (action.type) {
case 'ADD': {
const newState = [
...state,
action.payload,
];
return newState;
}
case 'REMOVE': {
const statusList = [...state];
statusList.splice(action.payload, 1);
return statusList;
}
case 'CLEAR': {
return [];
}
case 'REPLACE': {
return action.payload;
}
default:
return state;
}
};
const useStatusList = () => useContext(Context);
const StatusListProvider = ({ children }) => {
const [statusList, dispatchStatus] = useReducer(statusReducer, initialStatus);
const [statusList, dispatchStatus] = useReducer(reducer, []);
const { pathname, state } = useLocation();
const removeStatus = useCallback(i => dispatchStatus({ type: 'REMOVE', payload: i }), []);

View File

@@ -0,0 +1,32 @@
const statusReducer = (state, action) => {
switch (action.type) {
case 'ADD': {
const newState = [
...state,
action.payload,
];
return newState;
}
case 'REMOVE': {
const statusList = [...state];
statusList.splice(action.payload, 1);
return statusList;
}
case 'CLEAR': {
return [];
}
case 'REPLACE': {
return action.payload;
}
default:
return state;
}
};
export default statusReducer;

View File

@@ -11,6 +11,7 @@
color: white;
line-height: base(.75);
font-weight: normal;
white-space: nowrap;
span {
position: absolute;

View File

@@ -17,7 +17,7 @@ import './index.scss';
const baseClass = 'form';
const reduceFieldsToValues = (fields) => {
const reduceFieldsToValues = (fields, flatten) => {
const data = {};
Object.keys(fields).forEach((key) => {
@@ -26,8 +26,11 @@ const reduceFieldsToValues = (fields) => {
}
});
const unflattened = unflatten(data, { safe: true });
return unflattened;
if (flatten) {
return unflatten(data, { safe: true });
}
return data;
};
const Form = (props) => {
@@ -62,7 +65,7 @@ const Form = (props) => {
}, [fields]);
const getData = useCallback(() => {
return reduceFieldsToValues(fields);
return reduceFieldsToValues(fields, true);
}, [fields]);
const getSiblingData = useCallback((path) => {
@@ -84,26 +87,31 @@ const Form = (props) => {
}, {});
}
return reduceFieldsToValues(siblingFields);
return reduceFieldsToValues(siblingFields, true);
}, [fields]);
const getDataByPath = useCallback((path) => {
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
const name = path.split('.').pop();
const rows = Object.keys(fields).reduce((matchedRows, key) => {
const data = Object.keys(fields).reduce((matchedData, key) => {
if (key.indexOf(`${path}.`) === 0) {
return {
...matchedRows,
...matchedData,
[key.replace(pathPrefixToRemove, '')]: fields[key],
};
}
return matchedRows;
return matchedData;
}, {});
const rowValues = reduceFieldsToValues(rows);
const unflattenedRows = unflatten(rowValues);
return unflattenedRows;
const values = reduceFieldsToValues(data, true);
const unflattenedData = unflatten(values);
return unflattenedData?.[name];
}, [fields]);
const getUnflattenedValues = useCallback(() => {
return reduceFieldsToValues(fields);
}, [fields]);
const validateForm = useCallback(() => {
@@ -112,6 +120,17 @@ const Form = (props) => {
});
}, [fields]);
const createFormData = useCallback(() => {
const formData = new FormData();
const data = reduceFieldsToValues(fields);
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
}, [fields]);
const submit = useCallback((e) => {
setSubmitted(true);
@@ -137,7 +156,6 @@ const Form = (props) => {
// If submit handler comes through via props, run that
if (onSubmit) {
e.preventDefault();
return onSubmit(fields);
}
@@ -150,28 +168,25 @@ const Form = (props) => {
behavior: 'smooth',
});
const data = getData();
const formData = createFormData();
setProcessing(true);
// Make the API call from the action
return requests[method.toLowerCase()](action, {
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
body: formData,
}).then((res) => {
setModified(false);
if (typeof handleAjaxResponse === 'function') return handleAjaxResponse(res);
return res.json().then((json) => {
setProcessing(false);
setModified(false);
clearStatus();
if (res.status < 400) {
if (typeof onSuccess === 'function') onSuccess(json);
if (redirect) {
return history.push(redirect, data);
return history.push(redirect, json);
}
if (!disableSuccessStatus) {
@@ -250,11 +265,11 @@ const Form = (props) => {
method,
onSubmit,
redirect,
getData,
clearStatus,
validateForm,
onSuccess,
replaceStatus,
createFormData,
]);
useThrottledEffect(() => {
@@ -288,6 +303,7 @@ const Form = (props) => {
getData,
getSiblingData,
validateForm,
getUnflattenedValues,
modified,
setModified,
}}

View File

@@ -2,8 +2,8 @@
@import '../shared.scss';
.auth-fields {
margin: base(1.5) 0 base(2);
padding: base(2) base(2) base(1.5);
margin-bottom: base(2);
background: $color-background-gray;
.btn {

View File

@@ -0,0 +1,213 @@
import React, {
useState, useRef, useEffect, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import Button from '../../../elements/Button';
import FileDetails from '../../../elements/FileDetails';
import Error from '../../Error';
import './index.scss';
const baseClass = 'file-field';
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
};
const validate = (value) => {
if (!value && value !== undefined) {
return 'A file is required.';
}
return true;
};
const File = (props) => {
const inputRef = useRef();
const dropRef = useRef();
const [fileList, setFileList] = useState(undefined);
const [selectingFile, setSelectingFile] = useState(false);
const [dragging, setDragging] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [replacingFile, setReplacingFile] = useState(false);
const {
initialData = {}, adminThumbnail, staticURL,
} = props;
const { filename } = initialData;
const {
value,
setValue,
showError,
errorMessage,
} = useFieldType({
path: 'file',
validate,
});
const handleDragIn = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setDragCounter(dragCounter + 1);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setDragging(true);
}
}, [dragCounter]);
const handleDragOut = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setDragCounter(dragCounter - 1);
if (dragCounter > 1) return;
setDragging(false);
}, [dragCounter]);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
setFileList(e.dataTransfer.files);
setDragging(false);
e.dataTransfer.clearData();
setDragCounter(0);
} else {
setDragging(false);
}
}, []);
const handleInputChange = useCallback(() => {
setSelectingFile(false);
setFileList(inputRef?.current?.files || null);
setValue(inputRef?.current?.files?.[0] || null);
}, [inputRef, setValue]);
useEffect(() => {
if (selectingFile) {
inputRef.current.click();
setSelectingFile(false);
}
}, [selectingFile, inputRef, setSelectingFile]);
useEffect(() => {
const div = dropRef.current;
if (div) {
div.addEventListener('dragenter', handleDragIn);
div.addEventListener('dragleave', handleDragOut);
div.addEventListener('dragover', handleDrag);
div.addEventListener('drop', handleDrop);
return () => {
div.removeEventListener('dragenter', handleDragIn);
div.removeEventListener('dragleave', handleDragOut);
div.removeEventListener('dragover', handleDrag);
div.removeEventListener('drop', handleDrop);
};
}
return null;
}, [handleDragIn, handleDragOut, handleDrop, dropRef]);
useEffect(() => {
if (inputRef.current && fileList !== undefined) {
inputRef.current.files = fileList;
}
}, [fileList]);
useEffect(() => {
setReplacingFile(false);
}, [initialData]);
const classes = [
baseClass,
dragging && `${baseClass}--dragging`,
'field-type',
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Error
showError={showError}
message={errorMessage}
/>
{(filename && !replacingFile) && (
<FileDetails
{...initialData}
staticURL={staticURL}
adminThumbnail={adminThumbnail}
handleRemove={() => {
setReplacingFile(true);
setFileList(null);
setValue(null);
}}
/>
)}
{(!filename || replacingFile) && (
<div className={`${baseClass}__upload`}>
{value && (
<div className={`${baseClass}__file-selected`}>
<span
className={`${baseClass}__filename`}
>
{value.name}
</span>
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => setFileList(null)}
/>
</div>
)}
{!value && (
<>
<div
className={`${baseClass}__drop-zone`}
ref={dropRef}
>
<Button
size="small"
buttonStyle="secondary"
onClick={() => setSelectingFile(true)}
>
Select a file
</Button>
<span className={`${baseClass}__drag-label`}>or drag and drop a file here</span>
</div>
</>
)}
<input
ref={inputRef}
type="file"
onChange={handleInputChange}
/>
</div>
)}
</div>
);
};
File.defaultProps = {
initialData: undefined,
adminThumbnail: undefined,
};
File.propTypes = {
fieldTypes: PropTypes.shape({}).isRequired,
initialData: PropTypes.shape({
filename: PropTypes.string,
mimeType: PropTypes.string,
filesize: PropTypes.number,
}),
staticURL: PropTypes.string.isRequired,
adminThumbnail: PropTypes.string,
};
export default File;

View File

@@ -0,0 +1,74 @@
@import '../../../../scss/styles.scss';
.file-field {
position: relative;
margin: base(1.5) 0 base(2);
background: $color-background-gray;
.tooltip.error-message {
z-index: 3;
bottom: calc(100% - #{base(.5)});
}
&__upload {
position: relative;
input[type=file] {
position: absolute;
top: 0;
z-index: -1;
}
}
&__file-selected,
&__drop-zone {
background: $color-background-gray;
position: relative;
z-index: 2;
padding: base(2);
display: flex;
align-items: center;
}
&__drop-zone {
border: 1px dotted $color-gray;
.btn {
margin: 0 $baseline 0 0;
}
}
&__file-selected {
.btn {
margin: 0 0 0 $baseline;
}
}
&__filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--dragging {
.file-field__drop-zone {
border-color: $color-green;
background: rgba($color-green, .15);
}
}
@include mid-break {
&__drop-zone {
display: block;
text-align: center;
.btn {
margin: 0 auto;
}
.file-field__drag-label {
display: none;
}
}
}
}

View File

@@ -72,7 +72,7 @@ const Flexible = (props) => {
const addRow = (index, blockType) => {
setAddRowIndex(current => current + 1);
const data = getDataByPath(path)?.[name];
const data = getDataByPath(path);
dispatchRows({
type: 'ADD', index, data, initialRowData: { blockType },
@@ -82,7 +82,7 @@ const Flexible = (props) => {
};
const removeRow = (index) => {
const data = getDataByPath(path)?.[name];
const data = getDataByPath(path);
dispatchRows({
type: 'REMOVE',
@@ -94,7 +94,7 @@ const Flexible = (props) => {
};
const moveRow = (moveFromIndex, moveToIndex) => {
const data = getDataByPath(path)?.[name];
const data = getDataByPath(path);
dispatchRows({
type: 'MOVE', index: moveFromIndex, moveToIndex, data,

View File

@@ -60,7 +60,7 @@ const Repeater = (props) => {
});
const addRow = (rowIndex) => {
const data = getDataByPath(path)?.[name];
const data = getDataByPath(path);
dispatchRows({
type: 'ADD', index: rowIndex, data,
@@ -70,7 +70,7 @@ const Repeater = (props) => {
};
const removeRow = (rowIndex) => {
const data = getDataByPath(path)?.[name];
const data = getDataByPath(path);
dispatchRows({
type: 'REMOVE',
@@ -82,7 +82,7 @@ const Repeater = (props) => {
};
const moveRow = (moveFromIndex, moveToIndex) => {
const data = getDataByPath(path)?.[name];
const data = getDataByPath(path);
dispatchRows({
type: 'MOVE', index: moveFromIndex, moveToIndex, data,
@@ -141,6 +141,7 @@ const Repeater = (props) => {
rowIndex={i}
fieldSchema={fields}
initialData={row.data}
initNull={row.initNull}
dispatchRows={dispatchRows}
customComponentsPath={`${customComponentsPath}${name}.fields.`}
positionHandleVerticalAlignment="sticky"

View File

@@ -1,4 +1,5 @@
export { default as auth } from './Auth';
export { default as file } from './File';
export { default as email } from './Email';
export { default as hidden } from './HiddenInput';

View File

@@ -45,7 +45,7 @@ const useFieldType = (options) => {
const sendField = useCallback(async (valueToSend) => {
const fieldToDispatch = { path, value: valueToSend };
fieldToDispatch.valid = typeof validate === 'function' ? await validate(valueToSend || '') : true;
fieldToDispatch.valid = typeof validate === 'function' ? await validate(valueToSend) : true;
if (typeof fieldToDispatch.valid === 'string') {
fieldToDispatch.errorMessage = fieldToDispatch.valid;

View File

@@ -0,0 +1,30 @@
import React from 'react';
const File = () => {
return (
<svg
width="150"
height="120"
viewBox="0 0 150 120"
xmlns="http://www.w3.org/2000/svg"
>
<rect
width="150"
height="120"
transform="translate(0 0.5)"
fill="#333333"
/>
<path
d="M82.8876 35.5H55.5555V85.5H94.4444V46.9818H82.8876V35.5Z"
fill="white"
/>
<path
d="M82.8876 46.9818H94.4444L82.8876 35.5V46.9818Z"
fill="#9A9A9A"
/>
</svg>
);
};
export default File;

View File

@@ -6,7 +6,6 @@ import usePayloadAPI from '../../../hooks/usePayloadAPI';
import formatFields from '../collections/Edit/formatFields';
import DefaultAccount from './Default';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
const { serverURL, routes: { api } } = config;

View File

@@ -23,7 +23,7 @@ const Login = () => {
const onSuccess = (data) => {
if (data.token) {
setToken(data.token);
history.push(`${admin}`);
history.push(admin);
}
};
@@ -58,10 +58,10 @@ const Login = () => {
<Logo />
</div>
<Form
disableSuccessStatus
onSuccess={onSuccess}
method="POST"
action={`${serverURL}${api}/${userSlug}/login`}
redirect={admin}
>
<Email
label="Email Address"

View File

@@ -13,6 +13,17 @@ const formatFields = (config, isEditing) => {
fields = authFields.concat(fields);
}
if (config.upload) {
const uploadFields = [
{
type: 'file',
...config.upload,
},
];
fields = uploadFields.concat(fields);
}
return fields;
};

View File

@@ -28,14 +28,15 @@ const EditView = (props) => {
useAsTitle,
} = collection;
const onSave = !isEditing ? (json) => {
const onSave = (json) => {
history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`, {
status: {
message: json.message,
type: 'success',
},
data: json.doc,
});
} : null;
};
const [{ data }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),

View File

@@ -38,6 +38,14 @@ const formatFields = (config) => {
]);
}
if (config.upload) {
fields = fields.concat({
name: 'filename',
label: 'Filename',
type: 'text',
});
}
return fields;
};

View File

@@ -94,6 +94,10 @@ a {
color: $color-dark-gray;
}
svg {
vertical-align: middle;
}
dialog {
width: 100%;
border: 0;

View File

@@ -5,6 +5,8 @@ const executePolicy = require('../../auth/executePolicy');
const { MissingFile } = require('../../errors');
const resizeAndSave = require('../../uploads/imageResizer');
const getSafeFilename = require('../../uploads/getSafeFilename');
const getImageSize = require('../../uploads/getImageSize');
const imageMIMETypes = require('../../uploads/imageMIMETypes');
const performFieldOperations = require('../../fields/performFieldOperations');
@@ -53,14 +55,20 @@ const create = async (args) => {
await options.req.files.file.mv(`${staticDir}/${fsSafeName}`);
if (imageMIMETypes.indexOf(options.req.files.file.mimetype) > -1) {
const dimensions = await getImageSize(`${staticDir}/${fsSafeName}`);
fileData.width = dimensions.width;
fileData.height = dimensions.height;
if (Array.isArray(imageSizes)) {
fileData.sizes = await resizeAndSave(options.config, fsSafeName, fileData.mimeType);
}
}
fileData.filename = fsSafeName;
fileData.filesize = options.req.files.file.size;
fileData.mimeType = options.req.files.file.mimetype;
if (imageSizes) {
fileData.sizes = await resizeAndSave(options.config, fsSafeName, fileData.mimeType);
}
options.data = {
...options.data,
...fileData,

View File

@@ -1,8 +1,11 @@
const deepmerge = require('deepmerge');
const combineMerge = require('../../utilities/combineMerge');
const overwriteMerge = require('../../utilities/overwriteMerge');
const executePolicy = require('../../auth/executePolicy');
const { NotFound, Forbidden } = require('../../errors');
const performFieldOperations = require('../../fields/performFieldOperations');
const imageMIMETypes = require('../../uploads/imageMIMETypes');
const getImageSize = require('../../uploads/getImageSize');
const getSafeFilename = require('../../uploads/getSafeFilename');
const resizeAndSave = require('../../uploads/imageResizer');
@@ -64,7 +67,7 @@ const update = async (args) => {
// 3. Merge updates into existing data
// /////////////////////////////////////
options.data = deepmerge(docJSON, options.data, { arrayMerge: combineMerge });
options.data = deepmerge(docJSON, options.data, { arrayMerge: overwriteMerge });
// /////////////////////////////////////
// 4. Execute field-level hooks, policies, and validation
@@ -81,19 +84,35 @@ const update = async (args) => {
const { staticDir, imageSizes } = args.config.upload;
if (args.req.files || args.req.files.file) {
await options.req.files.file.mv(`${staticDir}/${options.req.files.file.name}`);
if (options.req.files && options.req.files.file) {
const fsSafeName = await getSafeFilename(staticDir, options.req.files.file.name);
fileData.filename = options.req.files.file.name;
await options.req.files.file.mv(`${staticDir}/${fsSafeName}`);
if (imageSizes) {
fileData.sizes = await resizeAndSave(options.config, options.req.files.file.name);
fileData.filename = fsSafeName;
fileData.filesize = options.req.files.file.size;
fileData.mimeType = options.req.files.file.mimetype;
if (imageMIMETypes.indexOf(options.req.files.file.mimetype) > -1) {
const dimensions = await getImageSize(`${staticDir}/${fsSafeName}`);
fileData.width = dimensions.width;
fileData.height = dimensions.height;
if (Array.isArray(imageSizes)) {
fileData.sizes = await resizeAndSave(options.config, fsSafeName, fileData.mimeType);
}
}
options.data = {
...options.data,
...fileData,
};
} else if (options.data.file === null) {
options.data = {
...options.data,
filename: null,
sizes: null,
};
}
}

View File

@@ -1,5 +1,4 @@
const express = require('express');
const fileUpload = require('express-fileupload');
const requestHandlers = require('./requestHandlers');
const bindCollectionMiddleware = require('./bindCollection');
@@ -11,15 +10,7 @@ const {
const router = express.Router();
const registerRoutes = ({ Model, config }) => {
const middleware = [
bindCollectionMiddleware({ Model, config }),
];
if (config.upload) {
middleware.push(fileUpload());
}
router.all(`/${config.slug}*`, ...middleware);
router.all(`/${config.slug}*`, bindCollectionMiddleware({ Model, config }));
router.route(`/${config.slug}`)
.get(find)

View File

@@ -6,6 +6,7 @@ const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const cookieParser = require('cookie-parser');
const qsMiddleware = require('qs-middleware');
const fileUpload = require('express-fileupload');
const localizationMiddleware = require('../../localization/middleware');
const authenticate = require('./authenticate');
const identifyAPI = require('./identifyAPI');
@@ -25,6 +26,9 @@ const middleware = (config) => {
localizationMiddleware(config.localization),
authenticate(config),
identifyAPI('REST'),
fileUpload({
parseNested: true,
}),
(req, res, next) => {
if (config.cors) {
if (config.cors.indexOf(req.headers.origin) > -1) {

View File

@@ -3,7 +3,7 @@ const express = require('express');
function initStatic() {
this.config.collections.forEach((collection) => {
if (collection.upload) {
this.express.use(`${collection.upload.staticURL}*`, express.static(collection.upload.staticDir));
this.express.use(`${collection.upload.staticURL}`, express.static(collection.upload.staticDir));
}
});
}

View File

@@ -0,0 +1,13 @@
function formatBytes(bytes, decimals = 0) {
if (bytes === 0) return '0 bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = [' bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / (k ** i)).toFixed(dm))}${sizes[i]}`;
}
module.exports = formatBytes;

View File

@@ -0,0 +1,6 @@
const imageSize = require('image-size');
const { promisify } = require('util');
const getImageSize = promisify(imageSize);
module.exports = getImageSize;

View File

@@ -0,0 +1,15 @@
const imageMIMETypes = require('./imageMIMETypes');
const getThumbnail = (mimeType, staticURL, filename, sizes, adminThumbnail) => {
if (imageMIMETypes.indexOf(mimeType) > -1) {
if (sizes?.[adminThumbnail]?.filename) {
return `${staticURL}/${sizes[adminThumbnail].filename}`;
}
return `${staticURL}/${filename}`;
}
return false;
};
module.exports = getThumbnail;

View File

@@ -0,0 +1,3 @@
const types = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
module.exports = types;

View File

@@ -1,12 +1,9 @@
const fs = require('fs');
const sharp = require('sharp');
const sanitize = require('sanitize-filename');
const { promisify } = require('util');
const imageSize = require('image-size');
const getImageSize = require('./getImageSize');
const fileExists = require('./fileExists');
const sizeOf = promisify(imageSize);
function getOutputImage(sourceImage, size) {
const extension = sourceImage.split('.').pop();
const name = sanitize(sourceImage.substr(0, sourceImage.lastIndexOf('.')) || sourceImage);
@@ -33,15 +30,16 @@ module.exports = async function resizeAndSave(config, savedFilename, mimeType) {
const sourceImage = `${staticDir}/${savedFilename}`;
let sizes;
try {
const dimensions = await sizeOf(sourceImage);
const dimensions = await getImageSize(sourceImage);
sizes = imageSizes
.filter(desiredSize => desiredSize.width < dimensions.width)
.map(async (desiredSize) => {
const outputImage = getOutputImage(savedFilename, desiredSize);
const imageNameWithDimensions = `${outputImage.name}-${outputImage.width}x${outputImage.height}.${outputImage.extension}`;
const imagePath = `${staticDir}/${imageNameWithDimensions}`;
const fileAlreadyExists = await fileExists(imagePath);
if (fileExists(imagePath)) {
if (fileAlreadyExists) {
fs.unlinkSync(imagePath);
}

View File

@@ -0,0 +1,3 @@
const overwriteMerge = (destinationArray, sourceArray, options) => sourceArray;
module.exports = overwriteMerge;