merges master
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
72
src/client/components/elements/FileDetails/Meta/index.js
Normal file
72
src/client/components/elements/FileDetails/Meta/index.js
Normal 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) && (
|
||||
<>
|
||||
-
|
||||
{width}
|
||||
x
|
||||
{height}
|
||||
</>
|
||||
)}
|
||||
{mimeType && (
|
||||
<>
|
||||
-
|
||||
{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;
|
||||
23
src/client/components/elements/FileDetails/Meta/index.scss
Normal file
23
src/client/components/elements/FileDetails/Meta/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
130
src/client/components/elements/FileDetails/index.js
Normal file
130
src/client/components/elements/FileDetails/index.js
Normal 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;
|
||||
110
src/client/components/elements/FileDetails/index.scss
Normal file
110
src/client/components/elements/FileDetails/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }), []);
|
||||
|
||||
32
src/client/components/elements/Status/reducer.js
Normal file
32
src/client/components/elements/Status/reducer.js
Normal 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;
|
||||
@@ -11,6 +11,7 @@
|
||||
color: white;
|
||||
line-height: base(.75);
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
213
src/client/components/forms/field-types/File/index.js
Normal file
213
src/client/components/forms/field-types/File/index.js
Normal 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;
|
||||
74
src/client/components/forms/field-types/File/index.scss
Normal file
74
src/client/components/forms/field-types/File/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
30
src/client/components/graphics/File/index.js
Normal file
30
src/client/components/graphics/File/index.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -38,6 +38,14 @@ const formatFields = (config) => {
|
||||
]);
|
||||
}
|
||||
|
||||
if (config.upload) {
|
||||
fields = fields.concat({
|
||||
name: 'filename',
|
||||
label: 'Filename',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
|
||||
@@ -94,6 +94,10 @@ a {
|
||||
color: $color-dark-gray;
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
|
||||
Reference in New Issue
Block a user