diff --git a/demo/client/static/assets/images/generic-block-image.svg b/demo/client/static/assets/images/generic-block-image.svg new file mode 100644 index 0000000000..eb8029428f --- /dev/null +++ b/demo/client/static/assets/images/generic-block-image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/demo/collections/AllFields.js b/demo/collections/AllFields.js index afb7e31d22..7f0b2cc322 100644 --- a/demo/collections/AllFields.js +++ b/demo/collections/AllFields.js @@ -1,4 +1,8 @@ const checkRole = require('../policies/checkRole'); +const Email = require('../content-blocks/Email'); +const Quote = require('../content-blocks/Quote'); +const NumberBlock = require('../content-blocks/Number'); +const CallToAction = require('../content-blocks/CallToAction'); const AllFields = { slug: 'all-fields', @@ -164,6 +168,7 @@ const AllFields = { required: true, policies: { read: ({ req: { user } }) => Boolean(user), + update: () => false, }, }, ], @@ -176,6 +181,17 @@ const AllFields = { }, ], }, + { + type: 'flexible', + label: 'Flexible Content', + name: 'flexible', + minRows: 2, + singularLabel: 'Block', + blocks: [Email, NumberBlock, Quote, CallToAction], + localized: true, + required: true, + timestamps: true, + }, { type: 'relationship', label: 'Relationship to One Collection', diff --git a/demo/collections/FlexibleContent.js b/demo/collections/FlexibleContent.js index 6fb734f8bc..ec1c764554 100644 --- a/demo/collections/FlexibleContent.js +++ b/demo/collections/FlexibleContent.js @@ -1,6 +1,7 @@ const Email = require('../content-blocks/Email'); const Quote = require('../content-blocks/Quote'); const NumberBlock = require('../content-blocks/Number'); +const CallToAction = require('../content-blocks/CallToAction'); module.exports = { slug: 'flexible-content', @@ -14,7 +15,7 @@ module.exports = { label: 'Layout Blocks', singularLabel: 'Block', type: 'flexible', - blocks: [Email, NumberBlock, Quote], + blocks: [Email, NumberBlock, Quote, CallToAction], localized: true, required: true, }, diff --git a/demo/collections/NestedRepeater.js b/demo/collections/NestedRepeater.js index 83ad08e636..127cadeeca 100644 --- a/demo/collections/NestedRepeater.js +++ b/demo/collections/NestedRepeater.js @@ -12,6 +12,10 @@ const NestedRepeater = { type: 'repeater', label: 'Repeater', name: 'repeater', + labels: { + singular: 'Parent Row', + plural: 'Parent Rows', + }, required: true, minRows: 2, maxRows: 4, @@ -19,13 +23,17 @@ const NestedRepeater = { { name: 'parentIdentifier', label: 'Parent Identifier', - defaultValue: 'test', + defaultValue: '', type: 'text', required: true, }, { type: 'repeater', name: 'nestedRepeater', + labels: { + singular: 'Child Row', + plural: 'Child Rows', + }, required: true, fields: [ { @@ -37,6 +45,10 @@ const NestedRepeater = { { type: 'repeater', name: 'deeplyNestedRepeater', + labels: { + singular: 'Grandchild Row', + plural: 'Grandchild Rows', + }, required: true, fields: [ { diff --git a/demo/content-blocks/CallToAction.js b/demo/content-blocks/CallToAction.js index 3cd21d18ae..b07dc60943 100644 --- a/demo/content-blocks/CallToAction.js +++ b/demo/content-blocks/CallToAction.js @@ -1,23 +1,23 @@ module.exports = { - slug: 'cta', - labels: { - singular: 'Call to Action', - plural: 'Calls to Action', - }, - fields: [ - { - name: 'label', - label: 'Label', - type: 'text', - maxLength: 100, - required: true, - }, - { - name: 'url', - label: 'URL', - type: 'text', - height: 100, - required: true, - }, - ], + slug: 'cta', + labels: { + singular: 'Call to Action', + plural: 'Calls to Action', + }, + fields: [ + { + name: 'label', + label: 'Label', + type: 'text', + maxLength: 100, + required: true, + }, + { + name: 'url', + label: 'URL', + type: 'text', + height: 100, + required: true, + }, + ], }; diff --git a/demo/content-blocks/Quote.js b/demo/content-blocks/Quote.js index 3b6d2bb6bf..9d4d095a91 100644 --- a/demo/content-blocks/Quote.js +++ b/demo/content-blocks/Quote.js @@ -1,4 +1,5 @@ module.exports = { + blockImage: '/static/assets/images/generic-block-image.svg', slug: 'quote', labels: { singular: 'Quote', @@ -26,11 +27,5 @@ module.exports = { maxLength: 7, required: true, }, - { - name: 'photo', - label: 'Photo', - type: 'upload', - relationTo: 'media', - }, ], }; diff --git a/demo/server.js b/demo/server.js index ad4a9a8ed7..4c5dcebc23 100644 --- a/demo/server.js +++ b/demo/server.js @@ -1,8 +1,11 @@ const express = require('express'); +const path = require('path'); const Payload = require('../src'); const expressApp = express(); +expressApp.use('/static', express.static(path.resolve(__dirname, 'client/static'))); + const payload = new Payload({ email: { provider: 'mock', diff --git a/src/auth/operations/policies.js b/src/auth/operations/policies.js index dfeffcdbe7..c6c43d823c 100644 --- a/src/auth/operations/policies.js +++ b/src/auth/operations/policies.js @@ -13,11 +13,11 @@ const policies = async (args) => { const isLoggedIn = !!(user); const userCollectionConfig = (user && user.collection) ? config.collections.find(collection => collection.slug === user.collection) : null; - const createPolicyPromise = async (obj, policy, operation) => { + const createPolicyPromise = async (obj, policy, operation, disableWhere = false) => { const updatedObj = obj; const result = await policy({ req }); - if (typeof result === 'object') { + if (typeof result === 'object' && !disableWhere) { updatedObj[operation] = { permission: true, where: result, @@ -37,7 +37,7 @@ const policies = async (args) => { if (!updatedObj[field.name]) updatedObj[field.name] = {}; if (field.policies && typeof field.policies[operation] === 'function') { - promises.push(createPolicyPromise(updatedObj[field.name], field.policies[operation], operation)); + promises.push(createPolicyPromise(updatedObj[field.name], field.policies[operation], operation, true)); } else { updatedObj[field.name][operation] = { permission: isLoggedIn, diff --git a/src/client/assets/images/generic-block-image.svg b/src/client/assets/images/generic-block-image.svg new file mode 100644 index 0000000000..eb8029428f --- /dev/null +++ b/src/client/assets/images/generic-block-image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/client/components/elements/Popup/PopupButton/index.js b/src/client/components/elements/Popup/PopupButton/index.js new file mode 100644 index 0000000000..8645d38e63 --- /dev/null +++ b/src/client/components/elements/Popup/PopupButton/index.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './index.scss'; + +const baseClass = 'popup-button'; + +const PopupButton = (props) => { + const { + buttonType, + button, + setActive, + active, + } = props; + + const classes = [ + baseClass, + `${baseClass}--${buttonType}`, + ].filter(Boolean).join(' '); + + if (buttonType === 'custom') { + return ( +
setActive(!active)} + className={classes} + > + {button} +
+ ); + } + + return ( + + ); +}; + +PopupButton.defaultProps = { + buttonType: null, +}; + +PopupButton.propTypes = { + buttonType: PropTypes.oneOf(['custom', 'default']), + button: PropTypes.node.isRequired, + setActive: PropTypes.func.isRequired, + active: PropTypes.bool.isRequired, +}; + +export default PopupButton; diff --git a/src/client/components/elements/Popup/PopupButton/index.scss b/src/client/components/elements/Popup/PopupButton/index.scss new file mode 100644 index 0000000000..27c47e294e --- /dev/null +++ b/src/client/components/elements/Popup/PopupButton/index.scss @@ -0,0 +1,5 @@ +@import '../../../../scss/styles.scss'; + +.popup-button { + display: inline-flex; +} diff --git a/src/client/components/elements/Popup/index.js b/src/client/components/elements/Popup/index.js index 72ca2f8de7..4482309df8 100644 --- a/src/client/components/elements/Popup/index.js +++ b/src/client/components/elements/Popup/index.js @@ -2,48 +2,27 @@ import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { useWindowInfo } from '@trbl/react-window-info'; import { useScrollInfo } from '@trbl/react-scroll-info'; + import useThrottledEffect from '../../../hooks/useThrottledEffect'; +import PopupButton from './PopupButton'; import './index.scss'; const baseClass = 'popup'; -const ClickableButton = ({ - buttonType, button, setActive, active, -}) => { - if (buttonType === 'custom') { - return ( -
setActive(!active)} - > - {button} -
- ); - } - - return ( - - ); -}; - const Popup = (props) => { const { - render, align, size, color, pointerAlignment, button, buttonType, children, showOnHover, + render, align, size, color, button, buttonType, children, showOnHover, horizontalAlign, } = props; - const [active, setActive] = useState(false); - const [verticalAlign, setVerticalAlign] = useState('top'); - const { height: windowHeight } = useWindowInfo(); - const { y: scrollY } = useScrollInfo(); const buttonRef = useRef(null); const contentRef = useRef(null); + const [active, setActive] = useState(false); + const [verticalAlign, setVerticalAlign] = useState('top'); + const [forceHorizontalAlign, setForceHorizontalAlign] = useState(null); + + const { y: scrollY } = useScrollInfo(); + const { height: windowHeight } = useWindowInfo(); const handleClickOutside = (e) => { if (contentRef.current.contains(e.target)) { @@ -55,10 +34,29 @@ const Popup = (props) => { useThrottledEffect(() => { if (contentRef.current && buttonRef.current) { - const { height: contentHeight } = contentRef.current.getBoundingClientRect(); - const { y: buttonOffsetTop } = buttonRef.current.getBoundingClientRect(); + const { + height: contentHeight, + width: contentWidth, + right: contentRightEdge, + } = contentRef.current.getBoundingClientRect(); + const { y: buttonYCoord } = buttonRef.current.getBoundingClientRect(); - if (buttonOffsetTop > contentHeight) { + const windowWidth = window.innerWidth; + const distanceToRightEdge = windowWidth - contentRightEdge; + const distanceToLeftEdge = contentRightEdge - contentWidth; + + if (horizontalAlign === 'left' && distanceToRightEdge <= 0) { + setForceHorizontalAlign('right'); + } else if (horizontalAlign === 'right' && distanceToLeftEdge <= 0) { + setForceHorizontalAlign('left'); + } else if (horizontalAlign === 'center' && (distanceToLeftEdge <= contentWidth / 2 || distanceToRightEdge <= contentWidth / 2)) { + if (distanceToRightEdge > distanceToLeftEdge) setForceHorizontalAlign('left'); + else setForceHorizontalAlign('right'); + } else { + setForceHorizontalAlign(null); + } + + if (buttonYCoord > contentHeight) { setVerticalAlign('top'); } else { setVerticalAlign('bottom'); @@ -83,14 +81,18 @@ const Popup = (props) => { `${baseClass}--align-${align}`, `${baseClass}--size-${size}`, `${baseClass}--color-${color}`, - `${baseClass}--pointer-alignment-${pointerAlignment}`, - `${baseClass}--vertical-align-${verticalAlign}`, + `${baseClass}--v-align-${verticalAlign}`, + `${baseClass}--h-align-${horizontalAlign}`, + forceHorizontalAlign && `${baseClass}--force-h-align-${forceHorizontalAlign}`, active && `${baseClass}--active`, ].filter(Boolean).join(' '); return (
-
+
{showOnHover ? (
{ onMouseEnter={() => setActive(true)} onMouseLeave={() => setActive(false)} > - {
) : ( - { - const { - addRow, removeRow, singularLabel, verticalAlignment, - } = props; - - const classes = [ - baseClass, - `${baseClass}--vertical-alignment-${verticalAlignment}`, - ].filter(Boolean).join(' '); - - return ( -
-
-
- removeRow()} - /> - )} - > - Remove  - {singularLabel} - - - addRow()} - /> - )} - > - Add  - {singularLabel} - -
-
-
- ); -}; - -ActionHandle.defaultProps = { - singularLabel: 'Row', - verticalAlignment: 'center', -}; - -ActionHandle.propTypes = { - singularLabel: PropTypes.string, - addRow: PropTypes.func.isRequired, - removeRow: PropTypes.func.isRequired, - verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), -}; - -export default ActionHandle; diff --git a/src/client/components/forms/DraggableSection/ActionPanel/index.js b/src/client/components/forms/DraggableSection/ActionPanel/index.js new file mode 100644 index 0000000000..2ef155390c --- /dev/null +++ b/src/client/components/forms/DraggableSection/ActionPanel/index.js @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Button from '../../../elements/Button'; +import Popup from '../../../elements/Popup'; +import BlockSelector from '../../field-types/Flexible/BlockSelector'; + +import './index.scss'; + +const baseClass = 'action-panel'; + +const ActionPanel = (props) => { + const { + addRow, + removeRow, + singularLabel, + verticalAlignment, + blockType, + blocks, + rowIndex, + isHovered, + } = props; + + const classes = [ + baseClass, + `${baseClass}--vertical-alignment-${verticalAlignment}`, + ].filter(Boolean).join(' '); + + return ( +
+
+
+ + )} + > + Remove  + {singularLabel} + + + {blockType === 'flexible' + ? ( + + )} + render={({ close }) => ( + + )} + /> + ) + : ( + + )} + > + Add  + {singularLabel} + + ) + } +
+
+
+ ); +}; + +ActionPanel.defaultProps = { + singularLabel: 'Row', + verticalAlignment: 'center', + blockType: null, + isHovered: false, +}; + +ActionPanel.propTypes = { + singularLabel: PropTypes.string, + addRow: PropTypes.func.isRequired, + removeRow: PropTypes.func.isRequired, + blockType: PropTypes.oneOf(['flexible', 'repeater']), + verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), + isHovered: PropTypes.bool, + rowIndex: PropTypes.number.isRequired, +}; + +export default ActionPanel; diff --git a/src/client/components/forms/DraggableSection/ActionHandle/index.scss b/src/client/components/forms/DraggableSection/ActionPanel/index.scss similarity index 59% rename from src/client/components/forms/DraggableSection/ActionHandle/index.scss rename to src/client/components/forms/DraggableSection/ActionPanel/index.scss index 2952b27525..2a771ad5f8 100644 --- a/src/client/components/forms/DraggableSection/ActionHandle/index.scss +++ b/src/client/components/forms/DraggableSection/ActionPanel/index.scss @@ -1,9 +1,13 @@ @import '../../../../scss/styles'; -.action-handle { - padding: base(.5); +.action-panel { + padding: 0 base(.5); padding-left: base(.75); - margin-bottom: base(.5); + margin-bottom: base(1); + + &:hover { + z-index: $z-nav; + } &__controls-container { position: relative; @@ -16,16 +20,18 @@ height: 100%; flex-direction: column; color: $color-gray; + opacity: 0; + visibility: hidden; } &--vertical-alignment-top { - .action-handle__controls { + .action-panel__controls { justify-content: flex-start; } } &--vertical-alignment-sticky { - .action-handle__controls { + .action-panel__controls { position: sticky; top: 120px; height: unset; @@ -34,11 +40,22 @@ &__remove-row { margin: 0 0 base(.3); - opacity: 0; } &__add-row { margin: base(.3) 0 0; - opacity: 0; + } + + @include mid-break { + &__controls { + opacity: 1; + visibility: visible; + } + + &--vertical-alignment-sticky { + .action-panel__controls { + top: 100px; + } + } } } diff --git a/src/client/components/forms/DraggableSection/PositionHandle/index.js b/src/client/components/forms/DraggableSection/PositionPanel/index.js similarity index 72% rename from src/client/components/forms/DraggableSection/PositionHandle/index.js rename to src/client/components/forms/DraggableSection/PositionPanel/index.js index c126e783c2..8b8c54214b 100644 --- a/src/client/components/forms/DraggableSection/PositionHandle/index.js +++ b/src/client/components/forms/DraggableSection/PositionPanel/index.js @@ -5,11 +5,11 @@ import Button from '../../../elements/Button'; import './index.scss'; -const baseClass = 'position-handle'; +const baseClass = 'position-panel'; -const PositionHandle = (props) => { +const PositionPanel = (props) => { const { - dragHandleProps, moveRow, positionIndex, verticalAlignment, + dragHandleProps, moveRow, positionIndex, verticalAlignment, rowCount, } = props; const adjustedIndex = positionIndex + 1; @@ -26,8 +26,9 @@ const PositionHandle = (props) => { >
+
); }; -PositionHandle.defaultProps = { +PositionPanel.defaultProps = { verticalAlignment: 'center', }; -PositionHandle.propTypes = { +PositionPanel.propTypes = { dragHandleProps: PropTypes.shape({}).isRequired, positionIndex: PropTypes.number.isRequired, moveRow: PropTypes.func.isRequired, verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), + rowCount: PropTypes.number.isRequired, }; -export default PositionHandle; +export default PositionPanel; diff --git a/src/client/components/forms/DraggableSection/PositionHandle/index.scss b/src/client/components/forms/DraggableSection/PositionPanel/index.scss similarity index 54% rename from src/client/components/forms/DraggableSection/PositionHandle/index.scss rename to src/client/components/forms/DraggableSection/PositionPanel/index.scss index 10ff121c22..61b92d3e12 100644 --- a/src/client/components/forms/DraggableSection/PositionHandle/index.scss +++ b/src/client/components/forms/DraggableSection/PositionPanel/index.scss @@ -1,17 +1,19 @@ @import '../../../../scss/styles'; -.position-handle { +$controls-top-adjustment: base(.1); + +.position-panel { padding-right: base(1); - margin-bottom: base(.5); + margin-bottom: base(1); &__controls-container { position: relative; - height: 100%; + min-height: 100%; box-shadow: #{$style-stroke-width-s} 0px 0px 0px $color-light-gray; } &__controls { - padding-right: base(.75); + padding-right: base(.65); display: flex; flex-direction: column; justify-content: center; @@ -19,13 +21,13 @@ } &--vertical-alignment-top { - .position-handle__controls { + .position-panel__controls { justify-content: flex-start; } } &--vertical-alignment-sticky { - .position-handle__controls { + .position-panel__controls { position: sticky; top: 120px; height: unset; @@ -34,12 +36,12 @@ &__move-backward { transform: rotate(.5turn); - margin: 0 0 base(.25); + margin: 0; opacity: 0; } &__move-forward { - margin: base(.25) 0 0; + margin: 0; opacity: 0; } @@ -47,4 +49,26 @@ text-align: center; color: $color-gray; } + + @include large-break { + padding-right: base(1); + + &__controls { + padding-right: base(.75); + } + } +} + + +// External scopes +.field-type.flexible { + .position-panel { + &__controls-container { + min-height: calc(100% + #{$controls-top-adjustment}); + } + + &__controls { + margin-top: - $controls-top-adjustment; + } + } } diff --git a/src/client/components/forms/DraggableSection/index.js b/src/client/components/forms/DraggableSection/index.js index eec6b1ad01..a5d33f5b7a 100644 --- a/src/client/components/forms/DraggableSection/index.js +++ b/src/client/components/forms/DraggableSection/index.js @@ -1,14 +1,15 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -// import AnimateHeight from 'react-animate-height'; +import AnimateHeight from 'react-animate-height'; import { Draggable } from 'react-beautiful-dnd'; -import ActionHandle from './ActionHandle'; +import ActionPanel from './ActionPanel'; import SectionTitle from './SectionTitle'; -import PositionHandle from './PositionHandle'; +import PositionPanel from './PositionPanel'; import RenderFields from '../RenderFields'; import './index.scss'; +import Button from '../../elements/Button'; const baseClass = 'draggable-section'; @@ -18,35 +19,27 @@ const DraggableSection = (props) => { addRow, removeRow, rowIndex, + rowCount, parentPath, fieldSchema, initialData, - // dispatchRows, singularLabel, blockType, fieldTypes, customComponentsPath, isOpen, id, - positionHandleVerticalAlignment, - actionHandleVerticalAlignment, + positionPanelVerticalAlignment, + actionPanelVerticalAlignment, + toggleRowCollapse, permissions, } = props; - // const draggableRef = useRef(null); const [isHovered, setIsHovered] = useState(false); - // const handleCollapseClick = () => { - // draggableRef.current.focus(); - // dispatchRows({ - // type: 'UPDATE_COLLAPSIBLE_STATUS', - // index: rowIndex, - // }); - // }; - const classes = [ baseClass, - isOpen && 'is-open', + isOpen ? 'is-open' : 'is-closed', isHovered && 'is-hovered', ].filter(Boolean).join(' '); @@ -60,50 +53,71 @@ const DraggableSection = (props) => {
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onMouseOver={() => setIsHovered(true)} + onFocus={() => setIsHovered(true)} {...providedDrag.draggableProps} > - +
+ -
+
- {blockType === 'flexible' && ( - - )} + {blockType === 'flexible' && ( +
+ - {/* Render fields */} - { - return ({ - ...field, - path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`, - }); - })} +
+ )} + + + { + return ({ + ...field, + path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`, + }); + })} + /> + +
+ +
- -
); }} @@ -112,34 +126,37 @@ const DraggableSection = (props) => { }; DraggableSection.defaultProps = { + toggleRowCollapse: undefined, rowCount: null, initialData: undefined, singularLabel: '', blockType: '', customComponentsPath: '', isOpen: true, - positionHandleVerticalAlignment: 'center', - actionHandleVerticalAlignment: 'center', + positionPanelVerticalAlignment: 'sticky', + actionPanelVerticalAlignment: 'sticky', + permissions: {}, }; DraggableSection.propTypes = { moveRow: PropTypes.func.isRequired, addRow: PropTypes.func.isRequired, removeRow: PropTypes.func.isRequired, + toggleRowCollapse: PropTypes.func, rowIndex: PropTypes.number.isRequired, parentPath: PropTypes.string.isRequired, singularLabel: PropTypes.string, fieldSchema: PropTypes.arrayOf(PropTypes.shape({})).isRequired, rowCount: PropTypes.number, initialData: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})]), - dispatchRows: PropTypes.func.isRequired, isOpen: PropTypes.bool, blockType: PropTypes.string, fieldTypes: PropTypes.shape({}).isRequired, customComponentsPath: PropTypes.string, id: PropTypes.string.isRequired, - positionHandleVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), - actionHandleVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), + positionPanelVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), + actionPanelVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']), + permissions: PropTypes.shape({}), }; export default DraggableSection; diff --git a/src/client/components/forms/DraggableSection/index.scss b/src/client/components/forms/DraggableSection/index.scss index ede6cbab7d..2d56121e43 100644 --- a/src/client/components/forms/DraggableSection/index.scss +++ b/src/client/components/forms/DraggableSection/index.scss @@ -4,25 +4,25 @@ // HELPER MIXINS ////////////////////// -@mixin realtively-position-handles { - .position-handle { +@mixin realtively-position-panels { + .position-panel { position: relative; right: 0; } - .action-handle { + .action-panel { position: relative; left: 0; } } -@mixin absolutely-position-handles { - .position-handle { +@mixin absolutely-position-panels { + .position-panel { position: absolute; top: 0; right: 100%; bottom: 0; } - .action-handle { + .action-panel { position: absolute; top: 0; bottom: 0; left: 100%; } @@ -33,43 +33,96 @@ ////////////////////// .draggable-section { - display: flex; - position: relative; - padding-top: base(.75); - padding-bottom: base(.75); - margin-bottom: base(.75); + padding-bottom: base(.5); + + .draggable-section { + padding-bottom: 0; + } + + &__content-wrapper { + display: flex; + position: relative; + padding-top: base(.75); + } + + &.is-closed { + .draggable-section__content-wrapper { + margin-bottom: base(1.75); + } + } + + &__section-header { + display: flex; + + .toggle-collapse { + margin: 0 0 0 auto; + transform: rotate(.5turn); + + &--is-closed { + transform: rotate(0turn); + } + } + } &__render-fields-wrapper { width: 100%; } - &.is-hovered, - &:focus-within { - > .position-handle { - .position-handle__controls-container { + &.is-hovered > div { + > .position-panel { + .position-panel__controls-container { box-shadow: #{$style-stroke-width-m} 0px 0px 0px $color-dark-gray; } - .position-handle__move-forward, - .position-handle__move-backward { + .position-panel__move-forward, + .position-panel__move-backward { opacity: 1; + + &.first-row, + &.last-row { + opacity: .15; + pointer-events: none; + } } - .position-handle__current-position { + .position-panel__current-position { color: $color-dark-gray; } } - > .action-handle { - .action-handle__add-row, - .action-handle__remove-row { + > .action-panel { + .action-panel__controls { opacity: 1; + visibility: visible; + z-index: $z-nav; + } + } + + .toggle-collapse { + @include color-svg(white); + + .btn__icon { + background-color: $color-gray; + + &:hover { + background-color: $color-dark-gray; + } } } } + label.field-label { + line-height: 1; + padding-bottom: base(.75) + } + @include mid-break { - @include realtively-position-handles(); + @include realtively-position-panels(); + + .position-panel__move-forward, + .position-panel__move-backward { + opacity: 1; + } } } @@ -78,12 +131,23 @@ ////////////////////// .collection-edit { - @include absolutely-position-handles(); + @include absolutely-position-panels(); + + @include mid-break { + @include realtively-position-panels(); + } } .field-type.repeater .field-type.repeater { - @include realtively-position-handles(); + @include realtively-position-panels(); } - - +// remove padding above repeater rows to level +// the line with the top of the input label +.field-type.repeater { + .draggable-section { + &__content-wrapper { + padding-top: 0; + } + } +} diff --git a/src/client/components/forms/RenderFields/index.js b/src/client/components/forms/RenderFields/index.js index 4bf6d9b991..de9e679dc4 100644 --- a/src/client/components/forms/RenderFields/index.js +++ b/src/client/components/forms/RenderFields/index.js @@ -17,16 +17,17 @@ const RenderFields = (props) => { filter, permissions, readOnly: readOnlyOverride, - operation, + operation: operationFromProps, } = props; - const { customComponentsPath: customComponentsPathFromContext } = useRenderedFields(); + const { customComponentsPath: customComponentsPathFromContext, operation: operationFromContext } = useRenderedFields(); const customComponentsPath = customComponentsPathFromProps || customComponentsPathFromContext; + const operation = operationFromProps || operationFromContext; if (fieldSchema) { return ( - + {fieldSchema.map((field, i) => { if (field?.hidden !== 'api' && field?.hidden !== true) { if ((filter && typeof filter === 'function' && filter(field)) || !filter) { @@ -117,7 +118,6 @@ RenderFields.propTypes = { filter: PropTypes.func, permissions: PropTypes.shape({}), readOnly: PropTypes.bool, - operation: PropTypes.oneOf(['create', 'read', 'update', 'delete']), }; export default RenderFields; diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.js b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.js index d1b49fdfb9..0d2efb4f7f 100644 --- a/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.js +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.js @@ -1,22 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; +import SearchIcon from '../../../../../graphics/Search'; + import './index.scss'; const baseClass = 'block-search'; const BlockSearch = (props) => { - const {} = props; + const { setSearchTerm } = props; + + const handleChange = (e) => { + setSearchTerm(e.target.value); + }; return (
- Search... + +
); }; -BlockSearch.defaultProps = {}; - -BlockSearch.propTypes = {}; +BlockSearch.propTypes = { + setSearchTerm: PropTypes.func.isRequired, +}; export default BlockSearch; diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.scss b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.scss index aaee5a0a9d..ae0f2f8aa1 100644 --- a/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.scss +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSearch/index.scss @@ -1 +1,30 @@ -@import '../../../../../../scss/styles'; +@import '../../../shared.scss'; + +$icon-width: base(1); +$icon-margin: base(.25); + +.block-search { + position: sticky; + top: 0; + display: flex; + align-items: center; + z-index: 1; + + &__input { + @include formInput; + padding-right: calc(#{$icon-width} + #{$icon-margin} * 2); + } + + .search { + position: absolute; + right: 0; + width: $icon-width; + margin: 0 $icon-margin; + } + + @include mid-break { + &__input { + margin-bottom: 0; + } + } +} diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSelection/index.js b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSelection/index.js new file mode 100644 index 0000000000..b077ec2d04 --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSelection/index.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import DefaultBlockImage from '../../../../../graphics/DefaultBlockImage'; + +import './index.scss'; + +const baseClass = 'block-selection'; + +const BlockSelection = (props) => { + const { + addRow, addRowIndex, block, close, + } = props; + + const { + labels, slug, blockImage, blockImageAltText, + } = block; + + const handleBlockSelection = () => { + console.log('adding'); + close(); + addRow(addRowIndex, slug); + }; + + return ( +
+
+ {blockImage + ? ( + {blockImageAltText} + ) + : + } +
+
{labels.singular}
+
+ ); +}; + +BlockSelection.defaultProps = { + addRow: undefined, + addRowIndex: 0, +}; + +BlockSelection.propTypes = { + addRow: PropTypes.func, + addRowIndex: PropTypes.number, + block: PropTypes.shape({ + labels: PropTypes.shape({ + singular: PropTypes.string, + }), + slug: PropTypes.string, + blockImage: PropTypes.string, + blockImageAltText: PropTypes.string, + }).isRequired, + close: PropTypes.func.isRequired, +}; + +export default BlockSelection; diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSelection/index.scss b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSelection/index.scss new file mode 100644 index 0000000000..50b5ae7fea --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/BlockSelection/index.scss @@ -0,0 +1,33 @@ +@import '../../../../../../scss/styles'; + +.block-selection { + display: inline-flex; + flex-direction: column; + flex-wrap: wrap; + width: 33%; + padding: base(.75) base(.5); + cursor: pointer; + align-items: center; + + &:hover { + background-color: $color-background-gray; + } + + &__image { + svg, + img { + max-width: 100%; + } + } + + &__label { + margin-top: base(.25); + font-weight: 600; + text-align: center; + white-space: initial; + } + + @include mid-break { + width: unset; + } +} diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.js b/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.js index 38c2f6f8f6..6ad791b401 100644 --- a/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.js +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import SelectableBlock from '../SelectableBlock'; +import BlockSelection from '../BlockSelection'; import './index.scss'; @@ -14,14 +14,13 @@ const BlocksContainer = (props) => {
{blocks?.map((block, index) => { return ( - ); })} - Blocks to choose from...
); }; diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.scss b/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.scss index aaee5a0a9d..c3014d2f5d 100644 --- a/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.scss +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/BlocksContainer/index.scss @@ -1 +1,14 @@ @import '../../../../../../scss/styles'; + +.blocks-container { + width: 100%; + margin-top: base(1); + margin-bottom: base(.5); + display: flex; + flex-wrap: wrap; + align-items: center; + min-width: 450px; + max-width: 80vw; + max-height: 300px; + position: relative; +} diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/SelectableBlock/index.js b/src/client/components/forms/field-types/Flexible/BlockSelector/SelectableBlock/index.js deleted file mode 100644 index b1bb343f96..0000000000 --- a/src/client/components/forms/field-types/Flexible/BlockSelector/SelectableBlock/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -const baseClass = 'selectable-block'; - -const SelectableBlock = (props) => { - const { addRow, addRowIndex, block } = props; - - const { labels, slug } = block; - - return ( -
addRow(addRowIndex, slug)} - > - {labels.singular} -
- ); -}; - -SelectableBlock.defaultProps = { - addRow: undefined, - addRowIndex: 0, -}; - -SelectableBlock.propTypes = { - addRow: PropTypes.func, - addRowIndex: PropTypes.number, -}; - -export default SelectableBlock; diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/index.js b/src/client/components/forms/field-types/Flexible/BlockSelector/index.js index add1d0152e..ae5dca6a27 100644 --- a/src/client/components/forms/field-types/Flexible/BlockSelector/index.js +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import BlockSearch from './BlockSearch'; @@ -7,16 +7,54 @@ import BlocksContainer from './BlocksContainer'; const baseClass = 'block-selector'; const BlockSelector = (props) => { + const { + blocks, close, parentIsHovered, watchParentHover, ...remainingProps + } = props; + + const [searchTerm, setSearchTerm] = useState(''); + const [filteredBlocks, setFilteredBlocks] = useState(blocks); + const [isBlockSelectorHovered, setBlockSelectorHovered] = useState(false); + + useEffect(() => { + const matchingBlocks = blocks.reduce((matchedBlocks, block) => { + if (block.slug.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1) matchedBlocks.push(block); + return matchedBlocks; + }, []); + + setFilteredBlocks(matchingBlocks); + }, [searchTerm, blocks]); + + useEffect(() => { + if (!parentIsHovered && !isBlockSelectorHovered && close && watchParentHover) close(); + }, [isBlockSelectorHovered, parentIsHovered, close, watchParentHover]); + return ( -
- - +
setBlockSelectorHovered(true)} + onMouseLeave={() => setBlockSelectorHovered(false)} + > + +
); }; -BlockSelector.defaultProps = {}; +BlockSelector.defaultProps = { + close: null, + parentIsHovered: false, + watchParentHover: false, +}; -BlockSelector.propTypes = {}; +BlockSelector.propTypes = { + blocks: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + close: PropTypes.func, + watchParentHover: PropTypes.bool, + parentIsHovered: PropTypes.bool, +}; export default BlockSelector; diff --git a/src/client/components/forms/field-types/Flexible/BlockSelector/index.scss b/src/client/components/forms/field-types/Flexible/BlockSelector/index.scss new file mode 100644 index 0000000000..6c5e738d1a --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/BlockSelector/index.scss @@ -0,0 +1,8 @@ +@import '../../../../../scss/styles.scss'; + +.block-selector { + + @include mid-break { + min-width: 80vw; + } +} diff --git a/src/client/components/forms/field-types/Flexible/index.js b/src/client/components/forms/field-types/Flexible/index.js index 84a397d046..df27ef8dde 100644 --- a/src/client/components/forms/field-types/Flexible/index.js +++ b/src/client/components/forms/field-types/Flexible/index.js @@ -1,5 +1,5 @@ import React, { - useEffect, useReducer, useState, useCallback, + useEffect, useReducer, useCallback, } from 'react'; import PropTypes from 'prop-types'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; @@ -14,7 +14,7 @@ import { useRenderedFields } from '../../RenderFields'; import Error from '../../Error'; import useFieldType from '../../useFieldType'; import Popup from '../../../elements/Popup'; -import BlocksContainer from './BlockSelector/BlocksContainer'; +import BlockSelector from './BlockSelector'; import { flexible } from '../../../../../fields/validations'; import './index.scss'; @@ -65,14 +65,11 @@ const Flexible = (props) => { }); const dataToInitialize = initialData || defaultValue; - const [addRowIndex, setAddRowIndex] = useState(null); const [rows, dispatchRows] = useReducer(reducer, []); const { customComponentsPath } = useRenderedFields(); const { getDataByPath } = useForm(); const addRow = (index, blockType) => { - setAddRowIndex(current => current + 1); - const data = getDataByPath(path); dispatchRows({ @@ -102,6 +99,12 @@ const Flexible = (props) => { }); }; + const toggleCollapse = (index) => { + dispatchRows({ + type: 'TOGGLE_COLLAPSE', index, rows, + }); + }; + const onDragEnd = (result) => { if (!result.destination) return; const sourceIndex = result.source.index; @@ -121,102 +124,106 @@ const Flexible = (props) => { }, ]), []), }); - }, [dataToInitialize, setValue]); + }, [dataToInitialize]); return ( - <> - -
-
-

{label}

- -
- - {provided => ( -
- {rows.length > 0 && rows.map((row, i) => { - let { blockType } = row.data; + +
+
+

{label}

- if (!blockType) { - blockType = dataToInitialize?.[i]?.blockType; - } + +
- const blockToRender = blocks.find(block => block.slug === blockType); - - if (blockToRender) { - return ( - addRow(i, blockType)} - removeRow={() => removeRow(i)} - rowIndex={i} - permissions={permissions.fields} - fieldSchema={[ - ...blockToRender.fields, - { - name: 'blockType', - type: 'text', - hidden: 'admin', - }, { - name: 'blockName', - type: 'text', - hidden: 'admin', - }, - ]} - singularLabel={blockToRender?.labels?.singular} - initialData={row.data} - dispatchRows={dispatchRows} - blockType="flexible" - customComponentsPath={`${customComponentsPath}${name}.fields.`} - positionHandleVerticalAlignment="sticky" - actionHandleVerticalAlignment="sticky" - /> - ); - } - - return null; - }) - } - {provided.placeholder} -
- )} - - -
- - {`Add ${singularLabel}`} - - )} + + {provided => ( +
- 0 && rows.map((row, i) => { + let { blockType } = row.data; + + if (!blockType) { + blockType = dataToInitialize?.[i]?.blockType; + } + + const blockToRender = blocks.find(block => block.slug === blockType); + + if (blockToRender) { + return ( + removeRow(i)} + moveRow={moveRow} + toggleRowCollapse={() => toggleCollapse(i)} + parentPath={path} + initialData={row.data} + customComponentsPath={`${customComponentsPath}${name}.fields.`} + fieldTypes={fieldTypes} + permissions={permissions.fields} + fieldSchema={[ + ...blockToRender.fields, + { + name: 'blockType', + type: 'text', + hidden: 'admin', + }, { + name: 'blockName', + type: 'text', + hidden: 'admin', + }, + ]} + /> + ); + } + + return null; + }) + } + {provided.placeholder} +
+ )} +
+ +
+ + {`Add ${singularLabel}`} + + )} + render={({ close }) => ( + - -
+ )} + />
-
- +
+ ); }; diff --git a/src/client/components/forms/field-types/Flexible/index.scss b/src/client/components/forms/field-types/Flexible/index.scss index f380bd853f..8d5702e684 100644 --- a/src/client/components/forms/field-types/Flexible/index.scss +++ b/src/client/components/forms/field-types/Flexible/index.scss @@ -3,6 +3,7 @@ .field-type.flexible { &__add-button-wrap { + .btn { color: $color-gray; margin: 0; diff --git a/src/client/components/forms/field-types/Repeater/index.js b/src/client/components/forms/field-types/Repeater/index.js index a0bef0d61a..f4464950f6 100644 --- a/src/client/components/forms/field-types/Repeater/index.js +++ b/src/client/components/forms/field-types/Repeater/index.js @@ -25,12 +25,15 @@ const Repeater = (props) => { fields, defaultValue, initialData, - singularLabel, fieldTypes, validate, required, maxRows, minRows, + labels: { + singular: singularLabel, + }, + permissions, } = props; const dataToInitialize = initialData || defaultValue; @@ -129,23 +132,23 @@ const Repeater = (props) => { {rows.length > 0 && rows.map((row, i) => { return ( addRow(i)} - moveRow={moveRow} - removeRow={() => removeRow(i)} + isOpen={row.open} + rowCount={rows.length} rowIndex={i} - fieldSchema={fields} + addRow={() => addRow(i)} + removeRow={() => removeRow(i)} + moveRow={moveRow} + parentPath={path} initialData={row.data} initNull={row.initNull} - dispatchRows={dispatchRows} customComponentsPath={`${customComponentsPath}${name}.fields.`} - positionHandleVerticalAlignment="sticky" - actionHandleVerticalAlignment="sticky" + fieldTypes={fieldTypes} + fieldSchema={fields} + permissions={permissions.fields} /> ); }) @@ -173,13 +176,16 @@ const Repeater = (props) => { Repeater.defaultProps = { label: '', - singularLabel: 'Row', defaultValue: [], initialData: [], validate: repeater, required: false, maxRows: undefined, minRows: undefined, + labels: { + singular: 'Row', + }, + permissions: {}, }; Repeater.propTypes = { @@ -193,7 +199,9 @@ Repeater.propTypes = { PropTypes.shape({}), ).isRequired, label: PropTypes.string, - singularLabel: PropTypes.string, + labels: PropTypes.shape({ + singular: PropTypes.string, + }), name: PropTypes.string.isRequired, path: PropTypes.string.isRequired, fieldTypes: PropTypes.shape({}).isRequired, @@ -201,6 +209,9 @@ Repeater.propTypes = { required: PropTypes.bool, maxRows: PropTypes.number, minRows: PropTypes.number, + permissions: PropTypes.shape({ + fields: PropTypes.shape({}), + }), }; export default withCondition(Repeater); diff --git a/src/client/components/forms/field-types/Repeater/index.scss b/src/client/components/forms/field-types/Repeater/index.scss index f865bfafb0..1ea46f8133 100644 --- a/src/client/components/forms/field-types/Repeater/index.scss +++ b/src/client/components/forms/field-types/Repeater/index.scss @@ -4,7 +4,7 @@ background: white; &__add-button-wrap { - margin-left: 0; + margin-left: base(0); .btn { color: $color-gray; @@ -28,7 +28,11 @@ .field-type.repeater { .field-type.repeater__add-button-wrap { - margin-left: base(2.25); + margin-left: base(2.65); + } + + .field-type.repeater__header { + display: none; } } } diff --git a/src/client/components/forms/field-types/Row/index.js b/src/client/components/forms/field-types/Row/index.js index 0edc2b4a10..13646dea09 100644 --- a/src/client/components/forms/field-types/Row/index.js +++ b/src/client/components/forms/field-types/Row/index.js @@ -7,14 +7,13 @@ import './index.scss'; const Row = (props) => { const { - fields, fieldTypes, initialData, path: pathFromProps, name, + fields, fieldTypes, initialData, path, permissions, } = props; - const path = pathFromProps || name; - return (
{ @@ -31,6 +30,7 @@ const Row = (props) => { Row.defaultProps = { path: '', initialData: undefined, + permissions: {}, }; Row.propTypes = { @@ -39,8 +39,8 @@ Row.propTypes = { ).isRequired, fieldTypes: PropTypes.shape({}).isRequired, path: PropTypes.string, - name: PropTypes.string.isRequired, initialData: PropTypes.shape({}), + permissions: PropTypes.shape({}), }; export default withCondition(Row); diff --git a/src/client/components/forms/field-types/Row/index.scss b/src/client/components/forms/field-types/Row/index.scss index 7a2ba4a715..bf54b74058 100644 --- a/src/client/components/forms/field-types/Row/index.scss +++ b/src/client/components/forms/field-types/Row/index.scss @@ -9,7 +9,6 @@ > * { margin-left: base(.5); margin-right: base(.5); - margin-bottom: 0; } @include mid-break { diff --git a/src/client/components/forms/field-types/rowReducer.js b/src/client/components/forms/field-types/rowReducer.js index 7f06517e71..4faeeb5ddc 100644 --- a/src/client/components/forms/field-types/rowReducer.js +++ b/src/client/components/forms/field-types/rowReducer.js @@ -11,6 +11,10 @@ const reducer = (currentState, action) => { case 'SET_ALL': return rows; + case 'TOGGLE_COLLAPSE': + stateCopy[index].open = !stateCopy[index].open; + return stateCopy; + case 'ADD': stateCopy.splice(index + 1, 0, { open: true, @@ -33,10 +37,6 @@ const reducer = (currentState, action) => { stateCopy.splice(index, 1); return stateCopy; - case 'UPDATE_COLLAPSIBLE_STATUS': - stateCopy[index].open = !stateCopy[index].open; - return stateCopy; - case 'MOVE': { const stateCopyWithNewData = stateCopy.map((row, i) => { return { diff --git a/src/client/components/graphics/DefaultBlockImage/index.js b/src/client/components/graphics/DefaultBlockImage/index.js new file mode 100644 index 0000000000..13ef649576 --- /dev/null +++ b/src/client/components/graphics/DefaultBlockImage/index.js @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import { v4 as uuid } from 'uuid'; + +const DefaultBlockImage = () => { + const [patternID] = useState(`pattern${uuid()}`); + const [imageID] = useState(`image${uuid()}`); + + return ( + + + + + + + + + + ); +}; + +export default DefaultBlockImage; diff --git a/src/client/components/graphics/Search/index.js b/src/client/components/graphics/Search/index.js new file mode 100644 index 0000000000..dc8e356d96 --- /dev/null +++ b/src/client/components/graphics/Search/index.js @@ -0,0 +1,34 @@ +import React from 'react'; + +const Search = () => { + return ( + + + + + ); +}; + +export default Search; diff --git a/src/client/components/views/collections/Edit/index.scss b/src/client/components/views/collections/Edit/index.scss index 70259d03fe..daac97294b 100644 --- a/src/client/components/views/collections/Edit/index.scss +++ b/src/client/components/views/collections/Edit/index.scss @@ -136,6 +136,10 @@ } @include mid-break { + &__sidebar { + width: unset; + } + &__form { display: block; } diff --git a/src/client/scss/vars.scss b/src/client/scss/vars.scss index 8cc940b3b2..aebc565b0f 100644 --- a/src/client/scss/vars.scss +++ b/src/client/scss/vars.scss @@ -61,6 +61,14 @@ $style-stroke-width-m : 2px; box-shadow: 0 2px 3px 0 rgba(0, 2, 4, 0.1), 0 6px 4px -4px rgba(0, 2, 4, 0.02); } +@mixin shadow-lg { + box-shadow: 0 2px 20px 7px rgba(0, 2, 4, 0.1), 0 6px 4px -4px rgba(0, 2, 4, 0.02); +} + +@mixin shadow-lg-top { + box-shadow: 0 -2px 20px 7px rgba(0, 2, 4, 0.1), 0 6px 4px -4px rgba(0, 2, 4, 0.02); +} + @mixin shadow { box-shadow: 0 12px 45px rgba(0,0,0,.03);