renames client to admin, sets up component library

This commit is contained in:
James
2020-10-10 18:28:17 -04:00
parent e88be6b251
commit 84191ec8fd
397 changed files with 2042 additions and 579 deletions

48
src/admin/api.js Normal file
View File

@@ -0,0 +1,48 @@
/* eslint-disable import/prefer-default-export */
import qs from 'qs';
export const requests = {
get: (url, params) => {
const query = qs.stringify(params, { addQueryPrefix: true, depth: 10 });
return fetch(`${url}${query}`);
},
post: (url, options = {}) => {
const headers = options && options.headers ? { ...options.headers } : {};
const formattedOptions = {
...options,
method: 'post',
headers: {
...headers,
},
};
return fetch(`${url}`, formattedOptions);
},
put: (url, options = {}) => {
const headers = options && options.headers ? { ...options.headers } : {};
const formattedOptions = {
...options,
method: 'put',
headers: {
...headers,
},
};
return fetch(url, formattedOptions);
},
delete: (url, options = {}) => {
const headers = options && options.headers ? { ...options.headers } : {};
return fetch(url, {
...options,
method: 'delete',
headers: {
...headers,
},
});
},
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,15 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
path {
fill: #333333;
}
@media (prefers-color-scheme: dark) {
path {
fill: white;
}
}
</style>
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -0,0 +1,9 @@
<svg width="82" height="53" viewBox="0 0 82 53" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="0.713013" width="80.574" height="52.7791" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0" transform="scale(0.00387597 0.00591716)"/>
</pattern>
<image id="image0" width="258" height="169" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQIAAACpCAYAAADA4zPJAAALpklEQVR4Ae2dSY/UOhSF+///DCSGHQuEgAVI7BBITAI2zCCGZuhmpqEBP53W8yOvK1UdO3Zyr/NFilJdXalyTo4/X984zlZgQQEUWLwCW4tXAAFQAAUCIMAEKIACgAAPoAAKBECACVAABQABHkABFAiAABOgAAoAAjyAAiggBbhqgA9QAAUAAR5AARQgIsADKIACdA3wAAqggBQgR4APUAAFAAEeQAEUICLAAyiAAnQN8AAKoIAUIEeAD1AABQABHkABFCAiwAMogAJ0DfAACqCAFCBHgA9QAAUAAR5AARQgIsADKIACdA3wAAqggBQgR4APUAAFAAEeQAEUICLAAyiAAnQN8AAKoIAUIEeAD1AABQABHkABFCAiwAMogAJ0DfAACqCAFCBHgA9QAAUAAR5AARQgIsADKIACdA3wAAqggBQgR4APUAAFAAEeQAEUICLAAyiAAnQN8AAKoIAUIEeAD1AABQABHkABFCAiwAMLVmB/fz98+/YtfPr0KXz48CHs7u6GnZ2dg+3Hjx/D169fw48fP8KfP3+aV4muQfOnmAPsKqDKr4r/9u3bsL29PXgVKPb29pqFAiDouoTXzSrw8+fPg5Y+pfL3ffbdu3fh+/fvzekECJo7pRxQVwGF9YoA+ir1mPfUhVB00coCCFo5kxzHigKqqGrBx1T4Tfu+efOmmegAEKzYhzdaUEBdAVXUTRW51P+UVPS+AALvZ5DyryigSGAqCESYeIcBIFixEW94VuDXr1/JVwRiZR671VUFrwsg8HrmKPeKAkoMKok3tkLn7q8oRCDyuAACj2eNMvcqoMFBuZW41H4alORxAQQezxplXlHg9+/fk+cF1sHDYxcBEKxYijc8KvDly5fZo4EIhvfv37uTEBC4O2UU+LACyg2kDhmOlbbWVpcvPS2AwNPZoqy9CigUr1Whc79Xoxk9LYDA09mirL0K1BhCnAuAuJ8iFE8LIPB0tihrrwI1hxHHip2z9XQpERD0Wos3vSig/EBOJZ1iH09XDwCBF8dTzl4FNJx4ikqd8xu6kuFlAQRezhTl7FVAMwjlVNIp9vn8+XNvmS2+aR4E6md56mtZPMktl8niFYMIGU9XDkyDQCdZ47e1eupvtVzxrB0bEUGZM2ISBEoAafLISNa4FWH1PxYUiApo4E70h7UtOYJ4ljK2OrGbLgfpfy1NEZUhEbt0FNA9BtYAEMvjaW5DMxGBWnpN7hBFPGrrfSKIjpd5OVIBa8OLo3c9DTM2AQJRXbdvRgGHbrWP9mVZtgKaanyoZ6b6nPJanrqxs4MgJgRzTxCJxGVDQEevEDzXP7X2E5w8LbOBQLQsOUZ8TCLx9evX4fHjx+HRo0esTjW4efNmKL3eu3cvyBs5sPB2lWsWENSaZlr3gacmEi9fvhyOHTvGiga9Hjh79mwyDJSz8NQtUOQyOQhSEoI5JNY+mrJqyPLy5cvekw8YAGPXA7du3UqKCob6b4hHp/rMZCDITQjmwmBIIvH+/fuAgEjgSA9cuXJlMAh0edtbNCDYTAICjf6a4xKPflO/vW4BBLT83ZZ/3esUEHi6ZNitF1VBIDLqxovcVr3UfipDH6UBASBYV/m77w8FgaeRhF0I6HU1EChpp+Rdqco89nv6EomAABB0K/y610NAoCHxfY3N4Qpn9e8qILAwv3wfODTmoJvIef78+ZH9w3Xm4P3lQOTGjRsbGzSNGfAMgeIRgRKCFkd5HYaCyqiyaj1//jwwIGG41gOnT58Ourp02EPx7zHjVyxFB8UigrkSgvGEpG5jIlEw0ECiO3fusDaowe3bt8PVq1eDwvvU9fr162sh0NqI1tEgsJIQTAVB/Py6RKIlWlOWcQrIo+oSlrpypShADUhLyygQaOYgSwnBWLlTtzoGZkFqydb9xxKBkONZQUSNRqs+yQaBbvRQeJRa6ax+Xsfi6f7xfqvz7lAFVKEVJSjbLzCookc/a6uBQcol6ZKgxgZ4TwYepUsyCBQSeUgI5gJHxmgt7DvKBPwfBZJAIDKW6mflVtQp9tMxeh0hhqVRIEeBQSBQWKQQaYpKaOk3dMyth4Q5pmGf9hQ4EgTqS+3s7CwOAhFIOvZWE0Tt2ZkjylVgIwhaSwjGyp26JZGYay/286JALwiULOubTjy1ArX2ee/jyb2YknJOr8AKCJQk06WT1ipxqeORNiQSpzcqv1hXgf9AsNSEYC4gSCTWNSbfPq0CByBYekIwFwYkEqc1K79WT4GtsdOJ51aiVvZTItHbjLX17MQ3e1Vgq5UKOfdxtHI7ajSy7iYlFxLVaH8LCLa3iyVGlUhMnU7dmsWUK+pOL6dZpxlUZe0slS8PICgIghiV6F4M3ct+6dIlV+vFixcPJmo5d+5c6K4XLlwI+p/l47l27RpdtBF8AAQVQHDmzJm1M94wxVm9Kc6kOzeM5dEAEBQGgR6dRmWvV9mP0lbdM5Z0BQBBYRA8ePAAEMw4B6KeVciSrgAgAARNgQsQpENAewACQAAI8upOU3sBgsIgePjwYVMV66g+ubX/ExHk8QkQFAaB5sA/ceIEMJghT3D8+HHmnczjAF2DeO2/5Pbu3bvh1KlTwGBCGJw8eTI8efIksxqwGxFB4YggAuXVq1fh2bNn4enTp0euL168OJj/QaP4Sq26O1JzLw75/Rqf0czApY5lyPcwfmAczABBJRBEIAzd6ual7nMZx5xWK3eTxkfLjTkW9p1GAUBgBAQRGGMrj7W7SRWV6AYmFtsKAAJjIBAQciqPbgyyPL0cj5YDBMXu7out5lK2QyuPl+nleLScXRgQERiMCLqg2zQLkqIAJdK6n7f+mhmhbcIAEBgHgSp2X+VRlnx3d9cVBLqQGpsLsVmd/JYKEDgAQaxA8bmM1hKCsXypWx4tZwccgMARCFTRFB2kVjjrn2dG6PmBAAicgcB6pc4t36ZcyPzVpP0SAAJAYCbC6MuFtF8FbRwhIAAEZkAQowkeLTc9HAABIDAHAgGBR8tNCwNAAAhMgiBGByQSpwECIAAEpkEgIJBIrA8DQAAIzINAMFAikUfL1QMCIAAELkAQuwqtPVquXtVO+2ZAAAhcgUBAaOHRcmnVtP6nAQEgcAeCGB3ohiuWMgoAAkDgFgQCgm68Ypqy8TAABIDANQgEAxKJgMC9iWOYy3b84+lJJOYDgYiAiKApmGoWpP39/fwasdA9AQEgaAoEMbIqNSP0UrgACABBkyAQEEgkDscYIAAEzYJAMMiZEXp49Wnnk4AAEDQNgthVGDojdDtVO+1IAAEgWAQIBAQSievhAAgAwWJAIBhozAGJxFUgAAJAsCgQxK4C06n/HwaAABAsEgQCAonEvzAABIBgsSCI0QGJxBAAASBYPAgEhKU/lxEQAAJA8K8HljydOiAABIDgkAfio+X+9qDbfwUIDpkg9hvZjr8b0LOGS3suIyAABEQEGzywlOnUAcEGE3hu0Sh7uYhmCdOpAwJAQEQwwAOtJxIBwQAT0LqWa129a9nqcxkBASAgIkj0QIvPZQQEiSbw3qJR/nLRTUuJREAACIgIRniglUQiIBhhAlrXcq2rZy1bmE4dEAACIoJCHvA8nTogKGQCzy0aZS8X2Xh9LiMgAAREBBU84O25jICgggloYcu1sJ619DSdOiAABEQEFT3gJZEICCqawHNrRtnLRjXWE4mAABAQEUzkAcvTqQOCiUxAC1u2hfWsp8Xp1AEBICAimMED1hKJgGAGE3huzSh7ucjG0nTqgAAQEBHM7AEL06kDgplNQAtbroX1rOXciURAAAiICIx4QGMO5kokAgIjJvDcmlH2slHNHM9lBASAgIjAoAemTiQCAoMmoIUt28J61nOqRCIgAAREBMY9MMUsSIDAuAk8t2aUvVxkU3s6dUAACIgIHHmg1nMZAYEjE9DClmthPWtZYzp1QAAIiAiceqDkdOqAwKkJPLdolL1cZFMqkbilkUysaIAH/Hpgb28vjF3+AbSb48mcXO9tAAAAAElFTkSuQmCC"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,232 @@
import React, { Suspense, lazy, useState, useEffect } from 'react';
import {
Route, Switch, withRouter, Redirect, useHistory,
} from 'react-router-dom';
import { useConfig } from './providers/Config';
import List from './views/collections/List';
import { useAuthentication } from './providers/Authentication';
import DefaultTemplate from './templates/Default';
import { requests } from '../api';
import Loading from './elements/Loading';
const Dashboard = lazy(() => import('./views/Dashboard'));
const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
const Login = lazy(() => import('./views/Login'));
const Logout = lazy(() => import('./views/Logout'));
const NotFound = lazy(() => import('./views/NotFound'));
const Verify = lazy(() => import('./views/Verify'));
const CreateFirstUser = lazy(() => import('./views/CreateFirstUser'));
const Edit = lazy(() => import('./views/collections/Edit'));
const EditGlobal = lazy(() => import('./views/Global'));
const ResetPassword = lazy(() => import('./views/ResetPassword'));
const Unauthorized = lazy(() => import('./views/Unauthorized'));
const Account = lazy(() => import('./views/Account'));
const Routes = () => {
const history = useHistory();
const [initialized, setInitialized] = useState(null);
const { user, permissions, permissions: { canAccessAdmin } } = useAuthentication();
const {
admin: { user: userSlug }, routes, collections, globals,
} = useConfig();
useEffect(() => {
requests.get(`${routes.api}/${userSlug}/init`).then((res) => res.json().then((data) => {
if (data && 'initialized' in data) {
setInitialized(data.initialized);
}
}));
}, [routes, userSlug]);
useEffect(() => {
history.replace();
}, [history]);
return (
<Suspense fallback={<Loading />}>
<Route
path={routes.admin}
render={({ match }) => {
if (initialized === false) {
return (
<Switch>
<Route path={`${match.url}/create-first-user`}>
<CreateFirstUser setInitialized={setInitialized} />
</Route>
<Route>
<Redirect to={`${match.url}/create-first-user`} />
</Route>
</Switch>
);
}
if (initialized === true) {
return (
<Switch>
<Route path={`${match.url}/login`}>
<Login />
</Route>
<Route path={`${match.url}/logout`}>
<Logout />
</Route>
<Route path={`${match.url}/logout-inactivity`}>
<Logout inactivity />
</Route>
<Route path={`${match.url}/forgot`}>
<ForgotPassword />
</Route>
<Route path={`${match.url}/reset/:token`}>
<ResetPassword />
</Route>
{collections.map((collection) => {
if (collection?.auth?.emailVerification) {
return (
<Route
key={`${collection.slug}-verify`}
path={`${match.url}/${collection.slug}/verify/:token`}
exact
>
<Verify />
</Route>
);
}
return null;
})}
<Route
render={() => {
if (user) {
if (canAccessAdmin) {
return (
<DefaultTemplate>
<Switch>
<Route
path={`${match.url}/`}
exact
>
<Dashboard />
</Route>
<Route path={`${match.url}/account`}>
<Account />
</Route>
{collections.map((collection) => (
<Route
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
{collections.map((collection) => (
<Route
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.[collection.slug]?.create?.permission) {
return (
<Edit
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
{collections.map((collection) => (
<Route
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
if (permissions?.[collection.slug]?.read?.permission) {
return (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
{globals && globals.map((global) => (
<Route
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.[global.slug]?.read?.permission) {
return (
<EditGlobal
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>
))}
<Route path={`${match.url}*`}>
<NotFound />
</Route>
</Switch>
</DefaultTemplate>
);
}
if (canAccessAdmin === false) {
return <Unauthorized />;
}
return <Loading />;
}
if (user === undefined) {
return <Loading />;
}
return <Redirect to={`${match.url}/login`} />;
}}
/>
<Route path={`${match.url}*`}>
<NotFound />
</Route>
</Switch>
);
}
return null;
}}
/>
</Suspense>
);
};
export default withRouter(Routes);

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import './index.scss';
const baseClass = 'banner';
const Banner = ({
children, className, to, icon, alignIcon, onClick, type,
}) => {
const classes = [
baseClass,
`${baseClass}--type-${type}`,
className && className,
to && `${baseClass}--has-link`,
(to || onClick) && `${baseClass}--has-action`,
icon && `${baseClass}--has-icon`,
icon && `${baseClass}--align-icon-${alignIcon}`,
].filter(Boolean).join(' ');
let RenderedType = 'div';
if (onClick && !to) RenderedType = 'button';
if (to) RenderedType = Link;
return (
<RenderedType
className={classes}
onClick={onClick}
type={RenderedType === 'button' ? 'button' : undefined}
to={to || undefined}
>
{(icon && alignIcon === 'left') && (
<React.Fragment>
{icon}
</React.Fragment>
)}
{children}
{(icon && alignIcon === 'right') && (
<React.Fragment>
{icon}
</React.Fragment>
)}
</RenderedType>
);
};
Banner.defaultProps = {
children: undefined,
className: '',
to: undefined,
icon: undefined,
alignIcon: 'right',
onClick: undefined,
type: 'default',
};
Banner.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
icon: PropTypes.node,
alignIcon: PropTypes.oneOf(['left', 'right']),
onClick: PropTypes.func,
to: PropTypes.string,
type: PropTypes.oneOf(['error', 'success', 'info', 'default']),
};
export default Banner;

View File

@@ -0,0 +1,74 @@
@import '../../../scss/styles.scss';
.banner {
font-size: 1rem;
line-height: base(1);
border: 0;
vertical-align: middle;
background: rgba($color-dark-gray, .1);
color: $color-dark-gray;
border-radius: $style-radius-s;
padding: base(.5);
margin-bottom: $baseline;
&--has-action {
cursor: pointer;
text-decoration: none;
}
&--has-icon {
svg {
display: block;
}
}
&--align-icon-left {
padding-left: base(.125);
}
&--align-icon-right {
padding-right: base(.125);
}
&--type-default {
&.button--has-action {
&:hover {
background: darken($color-dark-gray, .15);
}
&:active {
background: darken($color-dark-gray, .2);
}
}
}
&--type-error {
background: rgba($color-red, .1);
color: $color-red;
&.button--has-action {
&:hover {
background: rgba($color-red, .15);
}
&:active {
background: rgba($color-red, .2);
}
}
}
&--type-success {
background: rgba($color-green, .1);
color: darken($color-green, 20%);
&.button--has-action {
&:hover {
background: rgba($color-green, .15);
}
&:active {
background: rgba($color-green, .2);
}
}
}
}

View File

@@ -0,0 +1,171 @@
import React, { isValidElement } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import plus from '../../icons/Plus';
import x from '../../icons/X';
import chevron from '../../icons/Chevron';
import './index.scss';
const icons = {
plus,
x,
chevron,
};
const baseClass = 'btn';
const ButtonContents = ({ children, icon }) => {
const BuiltInIcon = icons[icon];
return (
<span className={`${baseClass}__content`}>
{children && (
<span className={`${baseClass}__label`}>
{children}
</span>
)}
{icon && (
<span className={`${baseClass}__icon`}>
{isValidElement(icon) && icon}
{BuiltInIcon && <BuiltInIcon />}
</span>
)}
</span>
);
};
ButtonContents.defaultProps = {
icon: null,
children: null,
};
ButtonContents.propTypes = {
children: PropTypes.node,
icon: PropTypes.node,
};
const Button = (props) => {
const {
className,
type,
el,
to,
url,
children,
onClick,
disabled,
icon,
iconStyle,
buttonStyle,
round,
size,
iconPosition,
} = props;
const classes = [
baseClass,
className && className,
buttonStyle && `${baseClass}--style-${buttonStyle}`,
icon && `${baseClass}--icon`,
iconStyle && `${baseClass}--icon-style-${iconStyle}`,
(icon && !children) && `${baseClass}--icon-only`,
disabled && `${baseClass}--disabled`,
round && `${baseClass}--round`,
size && `${baseClass}--size-${size}`,
iconPosition && `${baseClass}--icon-position-${iconPosition}`,
].filter(Boolean).join(' ');
function handleClick(event) {
if (type !== 'submit' && onClick) event.preventDefault();
if (onClick) onClick(event);
}
const buttonProps = {
type,
className: classes,
onClick: handleClick,
};
switch (el) {
case 'link':
return (
<Link
{...buttonProps}
to={to || url}
>
<ButtonContents icon={icon}>
{children}
</ButtonContents>
</Link>
);
case 'anchor':
return (
<a
{...buttonProps}
href={url}
>
<ButtonContents icon={icon}>
{children}
</ButtonContents>
</a>
);
default:
return (
<button
type="submit"
{...buttonProps}
>
<ButtonContents icon={icon}>
{children}
</ButtonContents>
</button>
);
}
};
Button.defaultProps = {
className: null,
type: 'button',
buttonStyle: 'primary',
el: null,
to: null,
url: null,
children: null,
onClick: null,
disabled: undefined,
icon: null,
size: 'medium',
round: false,
iconPosition: 'right',
iconStyle: 'without-border',
};
Button.propTypes = {
round: PropTypes.bool,
className: PropTypes.string,
type: PropTypes.oneOf(['submit', 'button']),
size: PropTypes.oneOf(['small', 'medium']),
buttonStyle: PropTypes.oneOf(['primary', 'secondary', 'transparent', 'error', 'none', 'icon-label']),
el: PropTypes.oneOf(['link', 'anchor', undefined]),
to: PropTypes.string,
url: PropTypes.string,
children: PropTypes.node,
onClick: PropTypes.func,
disabled: PropTypes.bool,
iconStyle: PropTypes.oneOf([
'with-border',
'without-border',
'none',
]),
icon: PropTypes.oneOfType([
PropTypes.node,
PropTypes.oneOf(['chevron', 'x', 'plus']),
]),
iconPosition: PropTypes.oneOf(['left', 'right']),
};
export default Button;

View File

@@ -0,0 +1,177 @@
@import '../../../scss/styles.scss';
.btn {
background: transparent;
line-height: base(1);
border-radius: $style-radius-m;
font-size: 1rem;
margin-top: base(1);
margin-bottom: base(1);
border: 0;
cursor: pointer;
font-weight: normal;
text-decoration: none;
text-align: center;
color: inherit;
.btn__icon {
border: 1px solid;
border-radius: 100%;
@include color-svg(currentColor);
}
&--icon-style-without-border {
.btn__icon {
border: none;
}
}
&--icon-style-none {
.btn__icon {
border: none;
}
}
span {
line-height: base(1);
}
span, svg {
vertical-align: top;
}
&--size-medium {
padding: base(.5) $baseline;
}
&--size-small {
padding: base(.25) base(.5);
}
&--style-primary {
background-color: $color-dark-gray;
color: white;
&:hover {
background: lighten($color-dark-gray, 5%);
}
&:focus {
box-shadow: $focus-box-shadow;
outline: none;
}
&:active {
background: lighten($color-dark-gray, 10%);
}
}
&--style-secondary {
$base-box-shadow: inset 0 0 0 $style-stroke-width $color-dark-gray;
$hover-box-shadow: inset 0 0 0 $style-stroke-width lighten($color-dark-gray, 5%);
box-shadow: $base-box-shadow;
color: $color-dark-gray;
background: none;
&:hover {
background: rgba($color-dark-gray, .02);
box-shadow: $hover-box-shadow;
}
&:focus {
outline: none;
box-shadow: $hover-box-shadow, $focus-box-shadow;
}
&:active {
background: lighten($color-light-gray, 7%);
}
}
&--style-none {
padding: 0;
margin: 0;
border-radius: 0;
&:focus {
opacity: .8;
}
&:active {
opacity: .7;
}
}
&--round {
border-radius: 100%;
}
&--icon {
span {
display: flex;
justify-content: space-between;
}
&.btn--style-primary {
.icon {
@include color-svg(white);
}
}
}
&--style-icon-label {
padding: 0;
font-weight: 600;
}
&--style-light-gray {
box-shadow: inset 0 0 0 $style-stroke-width $color-dark-gray;
}
&--icon-position-left {
.btn__content {
flex-direction: row-reverse;
}
.btn__icon {
margin-right: base(.5);
}
}
&--icon-position-right {
.btn__icon {
margin-left: base(.5);
}
}
&--icon-only {
.btn__icon {
padding: 0;
margin: 0;
}
}
&:hover {
.btn__icon {
@include color-svg(white);
background: $color-dark-gray;
}
}
&:focus {
.btn__icon {
@include color-svg($color-dark-gray);
background: $color-light-gray;
}
outline: none;
}
&:active {
.btn__icon {
@include color-svg(white);
background: lighten($color-dark-gray, 10%);
}
}
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../Button';
import './index.scss';
const baseClass = 'card';
const Card = (props) => {
const { title, actions, onClick } = props;
const classes = [
baseClass,
onClick && `${baseClass}--has-onclick`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<h5>
{title}
</h5>
{actions && (
<div className={`${baseClass}__actions`}>
{actions}
</div>
)}
{onClick && (
<Button
className={`${baseClass}__click`}
buttonStyle="none"
onClick={onClick}
/>
)}
</div>
);
};
Card.defaultProps = {
actions: null,
onClick: undefined,
};
Card.propTypes = {
title: PropTypes.string.isRequired,
actions: PropTypes.node,
onClick: PropTypes.func,
};
export default Card;

View File

@@ -0,0 +1,41 @@
@import '../../../scss/styles';
.card {
background: $color-background-gray;
padding: base(1.25) $baseline;
position: relative;
h5 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__actions {
position: relative;
z-index: 2;
margin-top: base(.5);
display: inline-flex;
.btn {
margin: 0;
}
}
&--has-onclick {
cursor: pointer;
&:hover {
background: darken($color-background-gray, 3%);
}
}
&__click {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,42 @@
const getInitialColumnState = (fields, useAsTitle, defaultColumns) => {
let initialColumns = [];
if (Array.isArray(defaultColumns) && defaultColumns.length >= 1) {
return {
columns: defaultColumns,
};
}
if (useAsTitle) {
initialColumns.push(useAsTitle);
}
const remainingColumns = fields.reduce((remaining, field) => {
if (field.name === useAsTitle) {
return remaining;
}
if (!field.name && Array.isArray(field.fields)) {
return [
...remaining,
...field.fields.map((subField) => subField.name),
];
}
return [
...remaining,
field.name,
];
}, []);
initialColumns = initialColumns.concat(remainingColumns);
initialColumns = initialColumns.slice(0, 4);
return {
columns: initialColumns,
};
};
module.exports = getInitialColumnState;

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect, useReducer } from 'react';
import PropTypes from 'prop-types';
import getInitialState from './getInitialState';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import Pill from '../Pill';
import Plus from '../../icons/Plus';
import X from '../../icons/X';
import './index.scss';
const baseClass = 'column-selector';
const reducer = (state, { type, payload }) => {
if (type === 'enable') {
return [
...state,
payload,
];
}
if (type === 'replace') {
return [
...payload,
];
}
return state.filter((remainingColumn) => remainingColumn !== payload);
};
const ColumnSelector = (props) => {
const {
collection,
collection: {
admin: {
useAsTitle,
defaultColumns,
},
},
handleChange,
} = props;
const [initialColumns, setInitialColumns] = useState([]);
const [fields] = useState(() => flattenTopLevelFields(collection.fields));
const [columns, dispatchColumns] = useReducer(reducer, initialColumns);
useEffect(() => {
if (typeof handleChange === 'function') handleChange(columns);
}, [columns, handleChange]);
useEffect(() => {
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
setInitialColumns(initializedColumns);
}, [fields, useAsTitle, defaultColumns]);
useEffect(() => {
dispatchColumns({ payload: initialColumns, type: 'replace' });
}, [initialColumns]);
return (
<div className={baseClass}>
{fields && fields.map((field, i) => {
const isEnabled = columns.find((column) => column === field.name);
return (
<Pill
onClick={() => dispatchColumns({ payload: field.name, type: isEnabled ? 'disable' : 'enable' })}
alignIcon="left"
key={field.name || i}
icon={isEnabled ? <X /> : <Plus />}
pillStyle={isEnabled ? 'dark' : undefined}
className={`${baseClass}__active-column`}
>
{field.label}
</Pill>
);
})}
</div>
);
};
ColumnSelector.propTypes = {
collection: PropTypes.shape({
fields: PropTypes.arrayOf(
PropTypes.shape({}),
),
admin: PropTypes.shape({
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
useAsTitle: PropTypes.string,
}),
}).isRequired,
handleChange: PropTypes.func.isRequired,
};
export default ColumnSelector;

View File

@@ -0,0 +1,11 @@
@import '../../../scss/styles.scss';
.column-selector {
background: $color-background-gray;
padding: base(1) base(1) base(.5);
.pill {
margin-right: base(.5);
margin-bottom: base(.5);
}
}

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import Copy from '../../icons/Copy';
import Tooltip from '../Tooltip';
import './index.scss';
const baseClass = 'copy-to-clipboard';
const CopyToClipboard = ({ value, defaultMessage, successMessage }) => {
const ref = useRef(null);
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
useEffect(() => {
if (copied && !hovered) {
setTimeout(() => {
setCopied(false);
}, 1500);
}
}, [copied, hovered]);
if (value) {
return (
<button
onMouseEnter={() => {
setHovered(true);
setCopied(false);
}}
onMouseLeave={() => {
setHovered(false);
setCopied(false);
}}
type="button"
className={baseClass}
onClick={() => {
if (ref && ref.current) {
ref.current.select();
ref.current.setSelectionRange(0, value.length + 1);
document.execCommand('copy');
setCopied(true);
}
}}
>
<Copy />
<Tooltip>
{copied && successMessage}
{!copied && defaultMessage}
</Tooltip>
<textarea
readOnly
value={value}
ref={ref}
/>
</button>
);
}
return null;
};
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,35 @@
@import '../../../scss/styles.scss';
.copy-to-clipboard {
@extend %btn-reset;
position: relative;
cursor: pointer;
vertical-align: middle;
textarea {
position: absolute;
opacity: 0;
z-index: -1;
height: 0px;
width: 0px;
}
.tooltip {
pointer-events: none;
opacity: 0;
visibility: hidden;
}
&:focus,
&:active {
outline: none;
}
&:hover {
.tooltip {
opacity: 1;
visibility: visible;
}
}
}

View File

@@ -0,0 +1,112 @@
import React from 'react';
import PropTypes from 'prop-types';
import DatePicker from 'react-datepicker';
import CalendarIcon from '../../icons/Calendar';
import 'react-datepicker/dist/react-datepicker.css';
import './index.scss';
const baseClass = 'date-time-picker';
const DateTime = (props) => {
const {
inputDateTimeFormat,
useDate,
minDate,
maxDate,
monthsShown,
useTime,
minTime,
maxTime,
timeIntervals,
timeFormat,
placeholder: placeholderText,
value,
onChange,
admin: {
readOnly,
} = {},
} = props;
let dateTimeFormat = inputDateTimeFormat;
if (!dateTimeFormat) {
if (useTime && useDate) dateTimeFormat = 'MMM d, yyy h:mma';
else if (useTime) dateTimeFormat = 'h:mma';
else dateTimeFormat = 'MMM d, yyy';
}
const dateTimePickerProps = {
minDate,
maxDate,
dateFormat: dateTimeFormat,
monthsShown: Math.min(2, monthsShown),
showTimeSelect: useTime,
minTime,
maxTime,
timeIntervals,
timeFormat,
placeholderText,
disabled: readOnly,
onChange,
showPopperArrow: false,
selected: value && new Date(value),
customInputRef: 'ref',
};
const classes = [
baseClass,
!useDate && `${baseClass}--hide-dates`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div className={`${baseClass}__input-wrapper`}>
<DatePicker {...dateTimePickerProps} />
<CalendarIcon />
</div>
</div>
);
};
DateTime.defaultProps = {
placeholder: undefined,
// date specific props
useDate: true,
minDate: undefined,
maxDate: undefined,
monthsShown: 1,
inputDateTimeFormat: '',
// time specific props
useTime: true,
minTime: undefined,
maxTime: undefined,
timeIntervals: 30,
timeFormat: 'h:mm aa',
value: undefined,
onChange: undefined,
admin: {},
};
DateTime.propTypes = {
placeholder: PropTypes.string,
// date specific props
useDate: PropTypes.bool,
minDate: PropTypes.instanceOf(Date),
maxDate: PropTypes.instanceOf(Date),
monthsShown: PropTypes.number,
inputDateTimeFormat: PropTypes.string,
// time specific props
useTime: PropTypes.bool,
minTime: PropTypes.instanceOf(Date),
maxTime: PropTypes.instanceOf(Date),
timeIntervals: PropTypes.number,
timeFormat: PropTypes.string,
value: PropTypes.instanceOf(Date),
onChange: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
}),
};
export default DateTime;

View File

@@ -0,0 +1,5 @@
Available props per React DatePicker
- [see here](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md)
Date FNS Information
- [popular mistakes](https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md)

View File

@@ -0,0 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../Loading';
const DatePicker = lazy(() => import('./DatePicker'));
export default (props) => (
<Suspense fallback={<Loading />}>
<DatePicker {...props} />
</Suspense>
);

View File

@@ -0,0 +1,234 @@
@import '../../../scss/styles';
$cal-icon-width: 18px;
.date-time-picker {
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box,
.react-datepicker__time-container {
width: 120px;
}
&--hide-dates {
.react-datepicker {
width: 100%;
&__month-container,
&__navigation--previous,
&__navigation--next {
display: none;
visibility: hidden;
}
&-popper,
&__time-container,
&__time-box {
width: 100%;
}
&__time-container {
.react-datepicker__time {
.react-datepicker__time-box {
width: 100%;
}
}
}
}
}
&__input-wrapper {
position: relative;
.icon {
pointer-events: none;
position: absolute;
right: base(.75);
top: 50%;
transform: translateY(-50%);
width: $cal-icon-width;
height: auto;
@include color-svg($color-dark-gray);
}
}
.react-datepicker-wrapper {
display: block;
}
.react-datepicker-wrapper,
.react-datepicker__input-container {
width: 100%;
}
.react-datepicker__input-container input {
@include formInput;
padding-right: calc(#{base(.75)} + #{$cal-icon-width});
}
&--has-error {
.react-datepicker__input-container input {
background-color: lighten($color-red, 20%);
}
}
.react-datepicker {
@include shadow-sm;
background: white;
display: inline-flex;
border: 1px solid $color-light-gray;
font-family: $font-body;
font-weight: 100;
border-radius: 0;
border: none;
&__header {
padding-top: 0;
text-transform: none;
text-align: center;
border-radius: 0;
border: none;
background-color: white;
&--time {
padding: 10px 0;
border-bottom: 1px solid $color-light-gray;
font-weight: 600;
}
}
&__navigation {
background: none;
line-height: 1.7rem;
text-align: center;
cursor: pointer;
position: absolute;
top: 10px;
width: 0;
padding: 0;
border: 0.45rem solid transparent;
z-index: 1;
height: 10px;
width: 10px;
text-indent: -999em;
overflow: hidden;
top: 15px;
&--next {
border-left-color: $color-gray;
&:focus {
border-left-color: darken($color-gray, 30%);
outline:none;
}
}
&--previous {
border-right-color: $color-gray;
&:focus {
border-right-color: darken($color-gray, 30%);
outline:none;
}
}
}
&__current-month {
padding: 10px 0;
font-weight: 600;
}
&__month-container {
border-right: 1px solid $color-light-gray;
}
&__time-container {
border-left: none;
}
&__day-names {
background-color: $color-light-gray;
}
&__day {
box-shadow: inset 0px 0px 0px 1px $color-light-gray, 0px 0px 0px 1px $color-light-gray;
font-size: base(.55);
&:focus {
outline: 0;
background: $color-gray;
}
&--selected {
font-weight: 600;
&:focus {
background-color: $color-light-gray;
}
}
&--keyboard-selected {
color: white;
font-weight: 600;
&:focus {
background-color: $color-light-gray;
box-shadow: inset 0px 0px 0px 1px $color-dark-gray, 0px 0px 0px 1px $color-dark-gray;
}
}
&--today {
font-weight: 600;
}
}
&__day,
&__day-name {
width: base(1.5);
margin: base(.15);
line-height: base(1.25);
}
}
.react-datepicker-popper {
z-index: 10;
border: 1px solid $color-light-gray;
}
.react-datepicker__day--keyboard-selected,
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected {
box-shadow: inset 0px 0px 0px 1px $color-dark-gray, 0px 0px 0px 1px $color-dark-gray;
background-color: $color-light-gray;
color: $color-dark-gray;
border-radius: 0;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected,
.react-datepicker__day--selected, .react-datepicker__day--in-selecting-range, .react-datepicker__day--in-range, .react-datepicker__month-text--selected, .react-datepicker__month-text--in-selecting-range, .react-datepicker__month-text--in-range {
box-shadow: inset 0px 0px 0px 1px $color-dark-gray, 0px 0px 0px 1px $color-dark-gray;
background-color: $color-light-gray;
color: $color-dark-gray;
border-radius: 0;
}
.react-datepicker__day:hover, .react-datepicker__month-text:hover {
border-radius: 0;
}
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
right: 130px;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item {
line-height: 20px;
font-size: base(.5);
font-weight: 500;
}
@include small-break {
&__input-wrapper {
.icon {
top: calc(50% - #{base(.25)});
}
}
}
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { Modal, useModal } from '@faceless-ui/modal';
import { useConfig } from '../../providers/Config';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import { useForm } from '../../forms/Form/context';
import useTitle from '../../../hooks/useTitle';
import { requests } from '../../../api';
import { useStatusList } from '../Status';
import './index.scss';
const baseClass = 'delete-document';
const DeleteDocument = (props) => {
const {
title: titleFromProps,
id,
collection: {
admin: {
useAsTitle,
},
slug,
labels: {
singular,
} = {},
} = {},
} = props;
const { serverURL, routes: { api, admin } } = useConfig();
const { replaceStatus } = useStatusList();
const { setModified } = useForm();
const [deleting, setDeleting] = useState(false);
const { closeAll, toggle } = useModal();
const history = useHistory();
const title = useTitle(useAsTitle) || id;
const titleToRender = titleFromProps || title;
const modalSlug = `delete-${id}`;
const addDefaultError = useCallback(() => {
replaceStatus([{
message: `There was an error while deleting ${title}. Please check your connection and try again.`,
type: 'error',
}]);
}, [replaceStatus, title]);
const handleDelete = useCallback(() => {
setDeleting(true);
setModified(false);
requests.delete(`${serverURL}${api}/${slug}/${id}`, {
headers: {
'Content-Type': 'application/json',
},
}).then(async (res) => {
try {
const json = await res.json();
if (res.status < 400) {
closeAll();
return history.push({
pathname: `${admin}/collections/${slug}`,
state: {
status: {
message: `${singular} "${title}" successfully deleted.`,
},
},
});
}
closeAll();
if (json.errors) {
replaceStatus(json.errors);
}
addDefaultError();
return false;
} catch (e) {
return addDefaultError();
}
});
}, [addDefaultError, closeAll, history, id, replaceStatus, singular, slug, title, admin, api, serverURL, setModified]);
if (id) {
return (
<React.Fragment>
<button
type="button"
slug={modalSlug}
className={`${baseClass}__toggle`}
onClick={(e) => {
e.preventDefault();
toggle(modalSlug);
}}
>
Delete
</button>
<Modal
slug={modalSlug}
className={baseClass}
>
<MinimalTemplate>
<h1>Confirm deletion</h1>
<p>
You are about to delete the
{' '}
{singular}
{' '}
&quot;
<strong>
{titleToRender}
</strong>
&quot;. Are you sure?
</p>
<Button
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggle(modalSlug)}
>
Cancel
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
>
{deleting ? 'Deleting...' : 'Confirm'}
</Button>
</MinimalTemplate>
</Modal>
</React.Fragment>
);
}
return null;
};
DeleteDocument.defaultProps = {
title: undefined,
id: undefined,
};
DeleteDocument.propTypes = {
collection: PropTypes.shape({
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
slug: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,
}),
}).isRequired,
id: PropTypes.string,
title: PropTypes.string,
};
export default DeleteDocument;

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles.scss';
.delete-document {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
&__toggle {
@extend %btn-reset;
}
.btn {
margin-right: $baseline;
}
}

View File

@@ -0,0 +1,43 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useConfig } from '../../providers/Config';
import Button from '../Button';
import { useForm } from '../../forms/Form/context';
import './index.scss';
const baseClass = 'duplicate';
const Duplicate = ({ slug }) => {
const { push } = useHistory();
const { getData } = useForm();
const { routes: { admin } } = useConfig();
const handleClick = useCallback(() => {
const data = getData();
push({
pathname: `${admin}/collections/${slug}/create`,
state: {
data,
},
});
}, [push, getData, slug, admin]);
return (
<Button
buttonStyle="none"
className={baseClass}
onClick={handleClick}
>
Duplicate
</Button>
);
};
Duplicate.propTypes = {
slug: PropTypes.string.isRequired,
};
export default Duplicate;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import StepNav from '../StepNav';
import './index.scss';
const baseClass = 'eyebrow';
const Eyebrow = ({ actions }) => {
return (
<div className={baseClass}>
<StepNav />
{actions}
</div>
);
};
Eyebrow.defaultProps = {
actions: null,
};
Eyebrow.propTypes = {
actions: PropTypes.node,
};
export default Eyebrow;

View File

@@ -0,0 +1,19 @@
@import '../../../scss/styles.scss';
.eyebrow {
@include blur-bg;
position: sticky;
top: 0;
z-index: $z-nav;
padding: base(1.5) 0;
display: flex;
align-items: center;
justify-content: space-between;
@include mid-break {
padding: 0 0 $baseline;
margin: 0;
position: static;
display: block;
}
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useConfig } from '../../../providers/Config';
import CopyToClipboard from '../../CopyToClipboard';
import formatFilesize from '../../../../../uploads/formatFilesize';
import './index.scss';
const baseClass = 'file-meta';
const Meta = (props) => {
const {
filename, filesize, width, height, mimeType, staticURL,
} = props;
const { serverURL } = useConfig();
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) && (
<React.Fragment>
&nbsp;-&nbsp;
{width}
x
{height}
</React.Fragment>
)}
{mimeType && (
<React.Fragment>
&nbsp;-&nbsp;
{mimeType}
</React.Fragment>
)}
</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,117 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import Thumbnail from '../Thumbnail';
import Button from '../Button';
import Meta from './Meta';
import Chevron from '../../icons/Chevron';
import './index.scss';
const baseClass = 'file-details';
const FileDetails = (props) => {
const {
filename, mimeType, filesize, staticURL, adminThumbnail, sizes, handleRemove, width, height,
} = props;
const [moreInfoOpen, setMoreInfoOpen] = useState(false);
const hasSizes = sizes && Object.keys(sizes)?.length > 0;
return (
<div className={baseClass}>
<header>
<Thumbnail {...{
mimeType, adminThumbnail, sizes, staticURL, filename,
}}
/>
<div className={`${baseClass}__main-detail`}>
<Meta
staticURL={staticURL}
filename={filename}
filesize={filesize}
width={width}
height={height}
mimeType={mimeType}
/>
{hasSizes && (
<Button
className={`${baseClass}__toggle-more-info${moreInfoOpen ? ' open' : ''}`}
buttonStyle="none"
onClick={() => setMoreInfoOpen(!moreInfoOpen)}
>
{!moreInfoOpen && (
<React.Fragment>
More info
<Chevron />
</React.Fragment>
)}
{moreInfoOpen && (
<React.Fragment>
Less info
<Chevron />
</React.Fragment>
)}
</Button>
)}
</div>
{handleRemove && (
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={handleRemove}
className={`${baseClass}__remove`}
/>
)}
</header>
{hasSizes && (
<AnimateHeight
className={`${baseClass}__more-info`}
height={moreInfoOpen ? 'auto' : 0}
>
<ul className={`${baseClass}__sizes`}>
{Object.entries(sizes).map(([key, val]) => (
<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,97 @@
@import '../../../scss/styles.scss';
.file-details {
background-color: $color-background-gray;
header {
display: flex;
align-items: flex-start;
border-bottom: $style-stroke-width-m solid white;
}
&__remove {
margin: $baseline $baseline $baseline 0;
}
&__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 {
&__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;
width: 100%;
}
}
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Modal, useModal } from '@faceless-ui/modal';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import { useStatusList } from '../Status';
import './index.scss';
const baseClass = 'generate-confirmation';
const GenerateConfirmation = (props) => {
const {
setKey,
highlightField,
} = props;
const { toggle } = useModal();
const { replaceStatus } = useStatusList();
const modalSlug = 'generate-confirmation';
const handleGenerate = () => {
setKey();
toggle(modalSlug);
replaceStatus([{
message: 'New API Key Generated.',
type: 'success',
}]);
highlightField(true);
};
return (
<React.Fragment>
<Button
size="small"
buttonStyle="secondary"
slug={modalSlug}
onClick={() => {
toggle(modalSlug);
}}
>
Generate new API key
</Button>
<Modal
slug={modalSlug}
className={baseClass}
>
<MinimalTemplate>
<h1>Confirm Generation</h1>
<p>
Generating a new API key will
{' '}
<strong>invalidate</strong>
{' '}
the previous key.
{' '}
Are you sure you wish to continue?
</p>
<Button
buttonStyle="secondary"
type="button"
onClick={() => {
toggle(modalSlug);
}}
>
Cancel
</Button>
<Button
onClick={handleGenerate}
>
Generate
</Button>
</MinimalTemplate>
</Modal>
</React.Fragment>
);
};
GenerateConfirmation.propTypes = {
setKey: PropTypes.func.isRequired,
highlightField: PropTypes.func.isRequired,
};
export default GenerateConfirmation;

View File

@@ -0,0 +1,12 @@
@import '../../../scss/styles.scss';
.generate-confirmation {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
.btn {
margin-right: $baseline;
}
}

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import SearchFilter from '../SearchFilter';
import ColumnSelector from '../ColumnSelector';
import WhereBuilder from '../WhereBuilder';
import Button from '../Button';
import './index.scss';
const baseClass = 'list-controls';
const ListControls = (props) => {
const {
handleChange,
collection,
enableColumns,
collection: {
fields,
admin: {
useAsTitle,
defaultColumns,
},
},
} = props;
const [titleField, setTitleField] = useState(null);
const [search, setSearch] = useState('');
const [columns, setColumns] = useState([]);
const [where, setWhere] = useState({});
const [visibleDrawer, setVisibleDrawer] = useState(false);
useEffect(() => {
if (useAsTitle) {
const foundTitleField = fields.find((field) => field.name === useAsTitle);
if (foundTitleField) {
setTitleField(foundTitleField);
}
}
}, [useAsTitle, fields]);
useEffect(() => {
const newState = {
columns,
};
if (search) {
newState.where = {
and: [
search,
],
};
}
if (where) {
if (!search) {
newState.where = {
and: [],
};
}
newState.where.and.push(where);
}
handleChange(newState);
}, [search, columns, where, handleChange]);
return (
<div className={baseClass}>
<div className={`${baseClass}__wrap`}>
<SearchFilter
handleChange={setSearch}
fieldName={titleField ? titleField.name : undefined}
fieldLabel={titleField ? titleField.label : undefined}
/>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
{enableColumns && (
<Button
className={`${baseClass}__toggle-columns`}
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : false)}
icon="chevron"
iconStyle="none"
>
Columns
</Button>
)}
<Button
className={`${baseClass}__toggle-where`}
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : false)}
icon="chevron"
iconStyle="none"
>
Filters
</Button>
</div>
</div>
</div>
{enableColumns && (
<AnimateHeight
className={`${baseClass}__columns`}
height={visibleDrawer === 'columns' ? 'auto' : 0}
>
<ColumnSelector
collection={collection}
defaultColumns={defaultColumns}
handleChange={setColumns}
/>
</AnimateHeight>
)}
<AnimateHeight
className={`${baseClass}__where`}
height={visibleDrawer === 'where' ? 'auto' : 0}
>
<WhereBuilder
handleChange={setWhere}
collection={collection}
/>
</AnimateHeight>
</div>
);
};
ListControls.defaultProps = {
enableColumns: true,
};
ListControls.propTypes = {
enableColumns: PropTypes.bool,
collection: PropTypes.shape({
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
}),
fields: PropTypes.arrayOf(PropTypes.shape),
}).isRequired,
handleChange: PropTypes.func.isRequired,
};
export default ListControls;

View File

@@ -0,0 +1,75 @@
@import '../../../scss/styles';
.list-controls {
margin-bottom: $baseline;
&__wrap {
display: flex;
}
.search-filter {
flex-grow: 1;
input {
margin: 0;
}
}
&__buttons {
margin-left: $baseline;
}
&__buttons-wrap {
display: flex;
margin-left: - base(.5);
margin-right: - base(.5);
width: calc(100% + #{$baseline});
}
&__toggle-columns,
&__toggle-where {
margin: 0 base(.5);
min-width: 140px;
&.btn--style-primary {
svg {
transform: rotate(180deg);
}
}
}
.column-selector,
.where-builder {
margin-top: base(1);
}
@include mid-break {
&__buttons {
margin-left: base(.5);
}
}
@include small-break {
&__wrap {
flex-wrap: wrap;
}
.search-filter {
margin-bottom: base(.5);
width: 100%;
}
&__buttons {
margin: 0;
}
&__buttons {
width: 100%;
}
&__toggle-columns,
&__toggle-where {
flex-grow: 1;
}
}
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
const Loading = () => <div>Loading</div>;
export default Loading;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Link } from 'react-router-dom';
import qs from 'qs';
import { useConfig } from '../../providers/Config';
import { useLocale } from '../../utilities/Locale';
import { useSearchParams } from '../../utilities/SearchParams';
import Popup from '../Popup';
import './index.scss';
const baseClass = 'localizer';
const Localizer = () => {
const { localization } = useConfig();
const locale = useLocale();
const searchParams = useSearchParams();
if (localization) {
const { locales } = localization;
return (
<div className={baseClass}>
<Popup
align="left"
button={locale}
render={({ close }) => (
<div>
<span>Locales</span>
<ul>
{locales.map((localeOption) => {
const baseLocaleClass = `${baseClass}__locale`;
const localeClasses = [
baseLocaleClass,
locale === localeOption && `${baseLocaleClass}--active`,
];
const newParams = {
...searchParams,
locale: localeOption,
};
const search = qs.stringify(newParams);
if (localeOption !== locale) {
return (
<li
key={localeOption}
className={localeClasses}
>
<Link
to={{ search }}
onClick={close}
>
{localeOption}
</Link>
</li>
);
}
return null;
})}
</ul>
</div>
)}
/>
</div>
);
}
return null;
};
export default Localizer;

View File

@@ -0,0 +1,41 @@
@import '../../../scss/styles.scss';
.localizer {
position: relative;
button {
padding: base(.25) 0;
font-size: 1rem;
line-height: base(1);
background: transparent;
border: 0;
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:active,
&:focus {
outline: none;
}
}
span {
color: $color-gray;
}
ul {
list-style: none;
padding: 0;
text-align: left;
margin: 0;
a {
&:hover {
text-decoration: underline;
}
}
}
}

View File

@@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { NavLink, Link, useHistory } from 'react-router-dom';
import { useConfig } from '../../providers/Config';
import { useAuthentication } from '../../providers/Authentication';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import Chevron from '../../icons/Chevron';
import LogOut from '../../icons/LogOut';
import Menu from '../../icons/Menu';
import CloseMenu from '../../icons/CloseMenu';
import Icon from '../../graphics/Icon';
import Account from '../../graphics/Account';
import Localizer from '../Localizer';
import './index.scss';
const baseClass = 'nav';
const DefaultNav = () => {
const { permissions } = useAuthentication();
const [menuActive, setMenuActive] = useState(false);
const history = useHistory();
const {
collections,
globals,
routes: {
admin,
},
} = useConfig();
const classes = [
baseClass,
menuActive && `${baseClass}--menu-active`,
].filter(Boolean).join(' ');
useEffect(() => history.listen(() => {
setMenuActive(false);
}), [history]);
return (
<aside className={classes}>
<div className={`${baseClass}__scroll`}>
<header>
<Link
to={admin}
className={`${baseClass}__brand`}
>
<Icon />
</Link>
<button
type="button"
className={`${baseClass}__mobile-menu-btn`}
onClick={() => setMenuActive(!menuActive)}
>
{menuActive && (
<CloseMenu />
)}
{!menuActive && (
<Menu />
)}
</button>
</header>
<div className={`${baseClass}__wrap`}>
<span className={`${baseClass}__label`}>Collections</span>
<nav>
{collections && collections.map((collection, i) => {
const href = `${admin}/collections/${collection.slug}`;
if (permissions?.[collection.slug]?.read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{collection.labels.plural}
</NavLink>
);
}
return null;
})}
</nav>
{(globals && globals.length > 0) && (
<React.Fragment>
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
if (permissions?.[global.slug].read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
}
return null;
})}
</nav>
</React.Fragment>
)}
<div className={`${baseClass}__controls`}>
<Localizer />
<Link
to={`${admin}/account`}
className={`${baseClass}__account`}
>
<Account />
</Link>
<Link
to={`${admin}/logout`}
className={`${baseClass}__log-out`}
>
<LogOut />
</Link>
</div>
</div>
</div>
</aside>
);
};
const Nav = () => {
const {
admin: {
components: {
Nav: CustomNav,
} = {},
} = {},
} = useConfig();
return (
<RenderCustomComponent
CustomComponent={CustomNav}
DefaultComponent={DefaultNav}
/>
);
};
export default Nav;

View File

@@ -0,0 +1,178 @@
@import '../../../scss/styles.scss';
.nav {
flex-shrink: 0;
position: sticky;
top: 0;
left: 0;
height: 100vh;
width: base(9);
overflow: hidden;
header {
width: 100%;
display: flex;
margin-bottom: base(1.5);
a, button {
display: block;
padding: 0;
svg {
display: block;
}
}
}
&__brand {
margin-right: base(1);
}
&__mobile-menu-btn {
background: none;
border: 0;
opacity: 0;
visibility: hidden;
cursor: pointer;
&:active, &:focus {
outline: none;
}
}
&__scroll {
height: 100%;
display: flex;
flex-direction: column;
padding: base(1.5) base(1);
width: calc(100% + #{base(1)});
overflow-y: scroll;
}
&__wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
}
&__label {
color: $color-gray;
}
&__controls {
margin-top: auto;
margin-bottom: 0;
> * {
margin-top: base(1);
}
}
&__log-out {
&:hover {
g {
transform: translateX(- #{base(.125)});
}
}
}
a {
padding: base(.125) 0;
display: flex;
text-decoration: none;
&:focus {
box-shadow: none;
font-weight: 600;
}
&:active {
font-weight: normal;
}
}
nav {
margin: base(.25) 0 $baseline;
a {
position: relative;
svg {
opacity: 0;
position: absolute;
left: - base(.5);
transform: rotate(-90deg);
}
&:hover {
text-decoration: underline;
}
&.active {
padding-left: base(.6);
font-weight: 600;
svg {
opacity: 1;
}
}
}
}
@include large-break {
width: base(8);
}
@include mid-break {
background: rgba($color-background-gray, .8);
backdrop-filter: saturate(180%) blur(5px);
width: 100%;
height: base(3);
z-index: $z-nav;
&__scroll {
padding: 0;
overflow: hidden;
width: 100%;
}
header,
&__wrap {
padding: $baseline;
}
header {
justify-content: space-between;
margin: 0;
}
&__mobile-menu-btn {
opacity: 1;
visibility: visible;
}
&__wrap {
padding-top: 0;
visibility: hidden;
opacity: 0;
overflow-y: scroll;
}
&.nav--menu-active {
height: 100vh;
.nav__wrap {
visibility: visible;
opacity: 1;
}
}
nav a {
font-size: base(.875);
line-height: base(1.25);
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import Chevron from '../../../icons/Chevron';
import './index.scss';
const baseClass = 'clickable-arrow';
const ClickableArrow = (props) => {
const {
updatePage,
isDisabled,
direction,
} = props;
const classes = [
baseClass,
isDisabled && `${baseClass}--is-disabled`,
direction && `${baseClass}--${direction}`,
].filter(Boolean).join(' ');
return (
<button
className={classes}
onClick={!isDisabled ? updatePage : undefined}
type="button"
>
<Chevron />
</button>
);
};
ClickableArrow.defaultProps = {
updatePage: null,
isDisabled: false,
direction: 'right',
};
ClickableArrow.propTypes = {
updatePage: PropTypes.func,
isDisabled: PropTypes.bool,
direction: PropTypes.oneOf(['right', 'left']),
};
export default ClickableArrow;

View File

@@ -0,0 +1,18 @@
@import '../../../../scss/styles.scss';
.clickable-arrow {
cursor: pointer;
transform: rotate(-90deg);
&--left {
transform: rotate(90deg);
}
&--is-disabled {
cursor: default;
.icon .stroke{
stroke: $color-gray;
}
}
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
const baseClass = 'paginator__page';
const Page = ({
page, isCurrent, updatePage, isFirstPage, isLastPage,
}) => {
const classes = [
baseClass,
isCurrent && `${baseClass}--is-current`,
isFirstPage && `${baseClass}--is-first-page`,
isLastPage && `${baseClass}--is-last-page`,
].filter(Boolean).join(' ');
return (
<button
className={classes}
onClick={() => updatePage(page)}
type="button"
>
{page}
</button>
);
};
Page.defaultProps = {
page: 1,
isCurrent: false,
updatePage: null,
isFirstPage: false,
isLastPage: false,
};
Page.propTypes = {
page: PropTypes.number,
isCurrent: PropTypes.bool,
updatePage: PropTypes.func,
isFirstPage: PropTypes.bool,
isLastPage: PropTypes.bool,
};
export default Page;

View File

@@ -0,0 +1,5 @@
import React from 'react';
const Separator = () => <span className="paginator__separator">&mdash;</span>;
export default Separator;

View File

@@ -0,0 +1,163 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory, useLocation } from 'react-router-dom';
import queryString from 'qs';
import Page from './Page';
import Separator from './Separator';
import ClickableArrow from './ClickableArrow';
import './index.scss';
const nodeTypes = {
Page,
Separator,
ClickableArrow,
};
const baseClass = 'paginator';
const Pagination = (props) => {
const history = useHistory();
const location = useLocation();
const {
totalPages,
page: currentPage,
hasPrevPage,
hasNextPage,
prevPage,
nextPage,
numberOfNeighbors,
disableHistoryChange,
onChange,
} = props;
if (!totalPages || totalPages <= 1) return null;
// uses react router to set the current page
const updatePage = (page) => {
if (!disableHistoryChange) {
const params = queryString.parse(location.search, { ignoreQueryPrefix: true });
params.page = page;
history.push({ search: queryString.stringify(params, { addQueryPrefix: true }) });
}
if (typeof onChange === 'function') onChange(page);
};
// Create array of integers for each page
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
// Assign indices for start and end of the range of pages that should be shown in paginator
let rangeStartIndex = (currentPage - 1) - numberOfNeighbors;
// Sanitize rangeStartIndex in case it is less than zero for safe split
if (rangeStartIndex <= 0) rangeStartIndex = 0;
const rangeEndIndex = (currentPage - 1) + numberOfNeighbors + 1;
// Slice out the range of pages that we want to render
const nodes = pages.slice(rangeStartIndex, rangeEndIndex);
// Add prev separator if necessary
if (currentPage - numberOfNeighbors - 1 >= 2) nodes.unshift({ type: 'Separator' });
// Add first page if necessary
if (currentPage > numberOfNeighbors + 1) {
nodes.unshift({
type: 'Page',
props: {
page: 1,
updatePage,
isFirstPage: true,
},
});
}
// Add next separator if necessary
if (currentPage + numberOfNeighbors + 1 < totalPages) nodes.push({ type: 'Separator' });
// Add last page if necessary
if (rangeEndIndex < totalPages) {
nodes.push({
type: 'Page',
props: {
page: totalPages,
updatePage,
isLastPage: true,
},
});
}
// Add prev and next arrows based on necessity
nodes.push({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(prevPage),
isDisabled: !hasPrevPage,
direction: 'left',
},
});
nodes.push({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(nextPage),
isDisabled: !hasNextPage,
direction: 'right',
},
});
return (
<div className={baseClass}>
{nodes.map((node, i) => {
if (typeof node === 'number') {
return (
<Page
key={i}
page={node}
updatePage={updatePage}
isCurrent={currentPage === node}
/>
);
}
const NodeType = nodeTypes[node.type];
return (
<NodeType
key={i}
{...node.props}
/>
);
})}
</div>
);
};
export default Pagination;
Pagination.defaultProps = {
limit: null,
totalPages: null,
page: 1,
hasPrevPage: false,
hasNextPage: false,
prevPage: null,
nextPage: null,
numberOfNeighbors: 1,
disableHistoryChange: false,
onChange: undefined,
};
Pagination.propTypes = {
limit: PropTypes.number,
totalPages: PropTypes.number,
page: PropTypes.number,
hasPrevPage: PropTypes.bool,
hasNextPage: PropTypes.bool,
prevPage: PropTypes.number,
nextPage: PropTypes.number,
numberOfNeighbors: PropTypes.number,
disableHistoryChange: PropTypes.bool,
onChange: PropTypes.func,
};

View File

@@ -0,0 +1,48 @@
@import '../../../scss/styles.scss';
.paginator {
display: flex;
margin-bottom: $baseline;
&__page {
cursor: pointer;
&--is-current {
background: $color-background-gray;
color: $color-gray;
cursor: default;
}
&--is-last-page {
margin-right: 0;
}
}
.clickable-arrow,
&__page {
@extend %btn-reset;
width: base(2);
height: base(2);
display: flex;
justify-content: center;
align-content: center;
outline: 0;
padding: base(.5);
color: $color-dark-gray;
line-height: base(1);
&:hover:not(.clickable-arrow--is-disabled) {
background: $color-background-gray;
}
}
&__page,
&__separator {
margin-right: base(.25);
}
&__separator {
align-self: center;
color: $color-gray;
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import './index.scss';
const baseClass = 'pill';
const Pill = ({
children, className, to, icon, alignIcon, onClick, pillStyle,
}) => {
const classes = [
baseClass,
`${baseClass}--style-${pillStyle}`,
className && className,
to && `${baseClass}--has-link`,
(to || onClick) && `${baseClass}--has-action`,
icon && `${baseClass}--has-icon`,
icon && `${baseClass}--align-icon-${alignIcon}`,
].filter(Boolean).join(' ');
let RenderedType = 'div';
if (onClick && !to) RenderedType = 'button';
if (to) RenderedType = Link;
return (
<RenderedType
className={classes}
onClick={onClick}
type={RenderedType === 'button' ? 'button' : undefined}
to={to || undefined}
>
{(icon && alignIcon === 'left') && (
<>
{icon}
</>
)}
{children}
{(icon && alignIcon === 'right') && (
<>
{icon}
</>
)}
</RenderedType>
);
};
Pill.defaultProps = {
children: undefined,
className: '',
to: undefined,
icon: undefined,
alignIcon: 'right',
onClick: undefined,
pillStyle: 'light',
};
Pill.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
to: PropTypes.string,
icon: PropTypes.node,
alignIcon: PropTypes.oneOf(['left', 'right']),
onClick: PropTypes.func,
pillStyle: PropTypes.oneOf(['light', 'dark', 'light-gray']),
};
export default Pill;

View File

@@ -0,0 +1,70 @@
@import '../../../scss/styles.scss';
.pill {
font-size: 1rem;
line-height: base(1);
border: 0;
display: inline-flex;
vertical-align: middle;
background: $color-light-gray;
color: $color-dark-gray;
border-radius: $style-radius-s;
padding: 0 base(.25);
padding-left: base(.0875 + .25);
&:active,
&:focus {
outline: none;
}
&--has-action {
cursor: pointer;
text-decoration: none;
}
&--has-icon {
svg {
display: block;
}
}
&--align-icon-left {
padding-left: base(.125);
}
&--align-icon-right {
padding-right: base(.125);
}
&--style-light {
&:hover {
background: lighten($color-light-gray, 3%);
}
&:active {
background: lighten($color-light-gray, 5%);
}
}
&--style-light-gray {
background: $color-background-gray;
color: $color-dark-gray;
}
&--style-dark {
background: $color-dark-gray;
color: white;
svg {
@include color-svg(white);
}
&:hover {
background: lighten($color-dark-gray, 3%);
}
&:active {
background: lighten($color-dark-gray, 5%);
}
}
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'popup-button';
const PopupButton = (props) => {
const {
buttonType,
button,
setActive,
active,
onToggleOpen,
} = props;
const classes = [
baseClass,
`${baseClass}--${buttonType}`,
].filter(Boolean).join(' ');
const handleClick = () => {
if (typeof onToggleOpen === 'function') onToggleOpen(!active);
setActive(!active);
};
if (buttonType === 'custom') {
return (
<div
role="button"
tabIndex="0"
onKeyDown={(e) => { if (e.key === 'Enter') handleClick(); }}
onClick={handleClick}
className={classes}
>
{button}
</div>
);
}
return (
<button
type="button"
onClick={() => setActive(!active)}
className={classes}
>
{button}
</button>
);
};
PopupButton.defaultProps = {
buttonType: null,
onToggleOpen: undefined,
};
PopupButton.propTypes = {
buttonType: PropTypes.oneOf(['custom', 'default']),
button: PropTypes.node.isRequired,
setActive: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
onToggleOpen: PropTypes.func,
};
export default PopupButton;

View File

@@ -0,0 +1,5 @@
@import '../../../../scss/styles.scss';
.popup-button {
display: inline-flex;
}

View File

@@ -0,0 +1,166 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useWindowInfo } from '@faceless-ui/window-info';
import { useScrollInfo } from '@faceless-ui/scroll-info';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import PopupButton from './PopupButton';
import './index.scss';
const baseClass = 'popup';
const Popup = (props) => {
const {
render, align, size, color, button, buttonType, children, showOnHover, horizontalAlign, initActive, onToggleOpen,
} = props;
const buttonRef = useRef(null);
const contentRef = useRef(null);
const [active, setActive] = useState(initActive);
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)) {
return;
}
setActive(false);
};
useThrottledEffect(() => {
if (contentRef.current && buttonRef.current) {
const {
height: contentHeight,
width: contentWidth,
right: contentRightEdge,
} = contentRef.current.getBoundingClientRect();
const { y: buttonYCoord } = buttonRef.current.getBoundingClientRect();
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');
}
}
}, 500, [setVerticalAlign, contentRef, scrollY, windowHeight]);
useEffect(() => {
if (active) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [active]);
const classes = [
baseClass,
`${baseClass}--align-${align}`,
`${baseClass}--size-${size}`,
`${baseClass}--color-${color}`,
`${baseClass}--v-align-${verticalAlign}`,
`${baseClass}--h-align-${horizontalAlign}`,
forceHorizontalAlign && `${baseClass}--force-h-align-${forceHorizontalAlign}`,
active && `${baseClass}--active`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div
ref={buttonRef}
className={`${baseClass}__wrapper`}
>
{showOnHover
? (
<div
className={`${baseClass}__on-hover-watch`}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
>
<PopupButton
onToggleOpen={onToggleOpen}
buttonType={buttonType}
button={button}
setActive={setActive}
active={active}
/>
</div>
)
: (
<PopupButton
onToggleOpen={onToggleOpen}
buttonType={buttonType}
button={button}
setActive={setActive}
active={active}
/>
)}
</div>
<div
className={`${baseClass}__content`}
ref={contentRef}
>
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__scroll`}>
{render && render({ close: () => setActive(false) })}
{children && children}
</div>
</div>
</div>
</div>
);
};
Popup.defaultProps = {
align: 'center',
size: 'small',
color: 'light',
children: undefined,
render: undefined,
buttonType: 'default',
button: undefined,
showOnHover: false,
horizontalAlign: 'left',
initActive: false,
onToggleOpen: undefined,
};
Popup.propTypes = {
render: PropTypes.func,
children: PropTypes.node,
align: PropTypes.oneOf(['left', 'center', 'right']),
horizontalAlign: PropTypes.oneOf(['left', 'center', 'right']),
size: PropTypes.oneOf(['small', 'large', 'wide']),
color: PropTypes.oneOf(['light', 'dark']),
buttonType: PropTypes.oneOf(['default', 'custom']),
button: PropTypes.node,
showOnHover: PropTypes.bool,
initActive: PropTypes.bool,
onToggleOpen: PropTypes.func,
};
export default Popup;

View File

@@ -0,0 +1,279 @@
@import '../../../scss/styles.scss';
.popup {
position: relative;
&__content {
position: absolute;
background: white;
opacity: 0;
visibility: hidden;
z-index: $z-modal;
max-width: calc(100vw - #{$baseline});
&:after {
content: ' ';
position: absolute;
top: calc(100% - 1px);
border: 12px solid transparent;
border-top-color: white;
}
}
&__wrap {
overflow: hidden;
}
&__scroll {
padding: $baseline;
padding-right: calc(var(--scrollbar-width) + #{$baseline});
overflow-y: auto;
width: calc(100% + var(--scrollbar-width));
white-space: nowrap;
}
&:focus,
&:active {
outline: none;
}
////////////////////////////////
// SIZE
////////////////////////////////
&--size-small {
.popup__content {
@include shadow-sm;
}
&.popup--align-left {
.popup__content {
left: - base(.5);
&:after {
left: base(.425);
}
}
}
}
&--size-large {
.popup__content {
@include shadow-lg;
}
.popup__scroll {
padding: base(1) base(1.5);
}
}
&--size-wide {
.popup__content {
@include shadow-sm;
&:after {
border: 12px solid transparent;
border-top-color: white;
}
}
.popup__scroll {
padding: base(.25) base(.5);
}
&.popup--align-left {
.popup__content {
left: - base(.5);
&:after {
left: base(.425);
}
}
}
}
////////////////////////////////
// HORIZONTAL ALIGNMENT
////////////////////////////////
&--h-align-left {
.popup__content {
left: - base(1.75);
&:after {
left: base(1.75);
}
}
}
&--h-align-center {
.popup__content {
left: 50%;
transform: translateX(-50%);
&:after {
left: 50%;
transform: translateX(-50%);
}
}
}
&--h-align-right {
.popup__content {
right: - base(1.75);
&:after {
right: base(1.75);
}
}
}
&--force-h-align-left {
.popup__content {
left: - base(1.75);
&:after {
left: base(1.75);
right: unset;
transform: unset;
}
}
}
&--force-h-align-right {
.popup__content {
right: - base(1.75);
&:after {
right: base(1.75);
left: unset;
transform: unset;
}
}
}
////////////////////////////////
// VERTICAL ALIGNMENT
////////////////////////////////
&--v-align-top {
.popup__content {
bottom: calc(100% + #{$baseline});
}
}
&--v-align-bottom {
.popup__content {
@include shadow-lg-top;
top: calc(100% + #{base(.5)});
&:after {
top: unset;
bottom: calc(100% - 1px);
border-top-color: transparent !important;
}
}
&.popup--color-dark {
.popup__content {
&:after {
border-bottom-color: $color-dark-gray;
}
}
}
}
////////////////////////////////
// COLOR
////////////////////////////////
&--color-dark {
.popup__content {
background: $color-dark-gray;
color: white;
&:after {
border-top-color: $color-dark-gray;
}
}
}
////////////////////////////////
// ACTIVE
////////////////////////////////
&--active {
.popup__content {
opacity: 1;
visibility: visible;
}
}
@include mid-break {
&__scroll,
&--size-large .popup__scroll{
padding: base(.75);
padding-right: calc(var(--scrollbar-width) + #{base(.75)});
}
&--h-align-left {
.popup__content {
left: - base(.5);
&:after {
left: base(.5);
}
}
}
&--h-align-center {
.popup__content {
left: 50%;
transform: translateX(-50%);
&:after {
left: 50%;
transform: translateX(-50%);
}
}
}
&--h-align-right {
.popup__content {
right: - base(.5);
&:after {
right: base(.5);
}
}
}
&--force-h-align-left {
.popup__content {
left: - base(.5);
right: unset;
transform: unset;
&:after {
left: base(.5);
right: unset;
transform: unset;
}
}
}
&--force-h-align-right {
.popup__content {
right: - base(.5);
left: unset;
transform: unset;
&:after {
right: base(.5);
left: unset;
transform: unset;
}
}
}
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useForm } from '../../forms/Form/context';
import { useAuthentication } from '../../providers/Authentication';
import Button from '../Button';
const baseClass = 'preview-btn';
const PreviewButton = ({ generatePreviewURL }) => {
const { token } = useAuthentication();
const { getFields } = useForm();
const fields = getFields();
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const previewURL = generatePreviewURL(fields, token);
return (
<Button
el="anchor"
className={baseClass}
buttonStyle="secondary"
url={previewURL}
>
Preview
</Button>
);
}
return null;
};
PreviewButton.defaultProps = {
generatePreviewURL: null,
};
PreviewButton.propTypes = {
generatePreviewURL: PropTypes.func,
};
export default PreviewButton;

View File

@@ -0,0 +1,110 @@
import React from 'react';
import PropTypes from 'prop-types';
import Select from 'react-select';
import Chevron from '../../icons/Chevron';
import './index.scss';
const ReactSelect = (props) => {
const {
showError,
options,
isMulti,
onChange,
value,
disabled,
formatValue,
} = props;
const classes = [
'react-select',
showError && 'react-select--error',
].filter(Boolean).join(' ');
return (
<Select
{...props}
value={value}
onChange={(selected) => {
if (formatValue) {
onChange(formatValue(selected));
} else {
let valueToChange;
if (isMulti) {
if (selected) {
valueToChange = selected.map((selectedOption) => {
if (typeof selectedOption === 'string') {
return {
label: selectedOption,
value: selectedOption,
};
}
return selectedOption;
});
}
} else if (selected) {
valueToChange = selected.value;
}
onChange(valueToChange);
}
}}
disabled={disabled ? 'disabled' : undefined}
components={{ DropdownIndicator: Chevron }}
className={classes}
classNamePrefix="rs"
options={options.map((option) => {
const formattedOption = {
value: option,
label: option,
};
if (typeof option === 'object') {
formattedOption.value = option.value;
formattedOption.label = option.label;
if (option.options) formattedOption.options = option.options;
}
return formattedOption;
})}
/>
);
};
ReactSelect.defaultProps = {
isMulti: false,
value: undefined,
showError: false,
disabled: false,
formatValue: null,
options: [],
onChange: () => { },
};
ReactSelect.propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
PropTypes.shape({}),
]),
onChange: PropTypes.func,
disabled: PropTypes.bool,
showError: PropTypes.bool,
formatValue: PropTypes.func,
options: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.string,
),
PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
}),
),
]),
isMulti: PropTypes.bool,
};
export default ReactSelect;

View File

@@ -0,0 +1,107 @@
@import '../../../scss/styles';
div.react-select {
div.rs__control {
@include formInput;
height: auto;
padding-top: base(.25);
padding-bottom: base(.25);
}
.rs__value-container {
padding: 0;
> * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
&--is-multi {
margin-left: - base(.25);
}
}
.rs__indicators {
.arrow {
margin-left: base(.5);
transform: rotate(90deg);
width: base(.3);
}
}
.rs__indicator {
padding: 0px 4px;
}
.rs__indicator-separator {
display: none;
}
.rs__input {
margin-top: base(.25);
margin-bottom: base(.25);
input {
font-family: $font-body;
width: 100% !important;
}
}
.rs__menu {
z-index: 2;
border-radius: 0;
@include inputShadowActive;
}
.rs__group-heading {
color: $color-dark-gray;
padding-left: base(.5);
margin-bottom: base(.25);
}
.rs__option {
font-family: $font-body;
font-size: $baseline-body-size;
padding: base(.375) base(.75);
color: $color-dark-gray;
&--is-focused {
background-color: rgba($color-dark-gray, .1);
}
&--is-selected {
background-color: rgba($color-dark-gray, .5);
}
}
.rs__multi-value {
padding: 0;
background: transparent;
border: $style-stroke-width-s solid $color-dark-gray;
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
margin: base(.25) base(.5) base(.25) 0;
}
.rs__multi-value__label {
padding: 0 base(.125) 0 base(.25);
max-width: 150px;
}
.rs__multi-value__remove {
padding: 0 base(.125);
cursor: pointer;
&:hover {
color: $color-dark-gray;
background: rgba($color-red, .25);
}
}
&--error {
div.rs__control {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,59 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import useTitle from '../../../hooks/useTitle';
import './index.scss';
const baseClass = 'render-title';
const RenderTitle = (props) => {
const {
useAsTitle, title: titleFromProps, data, fallback,
} = props;
const titleFromForm = useTitle(useAsTitle);
const titleFromData = data && data[useAsTitle];
let title = titleFromData;
if (!title) title = titleFromForm;
if (!title) title = data?.id;
if (!title) title = fallback;
title = titleFromProps || title;
const idAsTitle = title === data?.id;
const classes = [
baseClass,
idAsTitle && `${baseClass}--id-as-title`,
].filter(Boolean).join(' ');
return (
<span className={classes}>
{idAsTitle && (
<Fragment>
ID:&nbsp;&nbsp;
</Fragment>
)}
{title}
</span>
);
};
RenderTitle.defaultProps = {
title: undefined,
fallback: '[Untitled]',
useAsTitle: null,
data: null,
};
RenderTitle.propTypes = {
useAsTitle: PropTypes.string,
data: PropTypes.shape({
id: PropTypes.string,
}),
title: PropTypes.string,
fallback: PropTypes.string,
};
export default RenderTitle;

View File

@@ -0,0 +1,12 @@
@import '../../../scss/styles.scss';
.render-title {
&--id-as-title {
font-size: base(.75);
font-weight: normal;
color: $color-gray;
background: $color-background-gray;
padding: base(.25) base(.5);
border-radius: $style-radius-m;
}
}

View File

@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Search from '../../icons/Search';
import useDebounce from '../../../hooks/useDebounce';
import './index.scss';
const baseClass = 'search-filter';
const SearchFilter = (props) => {
const {
fieldName,
fieldLabel,
handleChange,
} = props;
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
if (typeof handleChange === 'function') {
handleChange(debouncedSearch ? {
[fieldName]: {
like: debouncedSearch,
},
} : null);
}
}, [debouncedSearch, handleChange, fieldName]);
return (
<div className={baseClass}>
<input
className={`${baseClass}__input`}
placeholder={`Search by ${fieldLabel}`}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<Search />
</div>
);
};
SearchFilter.defaultProps = {
fieldName: 'id',
fieldLabel: 'ID',
};
SearchFilter.propTypes = {
fieldName: PropTypes.string,
fieldLabel: PropTypes.string,
handleChange: PropTypes.func.isRequired,
};
export default SearchFilter;

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles';
.search-filter {
position: relative;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
right: base(.5);
}
&__input {
@include formInput;
}
}

View File

@@ -0,0 +1,67 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Chevron from '../../icons/Chevron';
import Button from '../Button';
import './index.scss';
const baseClass = 'sort-column';
const SortColumn = (props) => {
const {
label, handleChange, name, disable,
} = props;
const [sort, setSort] = useState(null);
useEffect(() => {
handleChange(sort);
}, [sort, handleChange]);
const desc = `-${name}`;
const asc = name;
const ascClasses = [`${baseClass}__asc`];
if (sort === asc) ascClasses.push(`${baseClass}--active`);
const descClasses = [`${baseClass}__desc`];
if (sort === desc) descClasses.push(`${baseClass}--active`);
return (
<div className={baseClass}>
<span className={`${baseClass}__label`}>{label}</span>
{!disable && (
<span className={`${baseClass}__buttons`}>
<Button
round
buttonStyle="none"
className={ascClasses.join(' ')}
onClick={() => setSort(asc)}
>
<Chevron />
</Button>
<Button
round
buttonStyle="none"
className={descClasses.join(' ')}
onClick={() => setSort(desc)}
>
<Chevron />
</Button>
</span>
)}
</div>
);
};
SortColumn.defaultProps = {
disable: false,
};
SortColumn.propTypes = {
label: PropTypes.string.isRequired,
handleChange: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
disable: PropTypes.bool,
};
export default SortColumn;

View File

@@ -0,0 +1,51 @@
@import '../../../scss/styles.scss';
.sort-column {
&__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__buttons {
flex-shrink: 0;
}
&__label,
&__buttons,
.btn {
vertical-align: middle;
display: inline-block;
}
&__label {
cursor: default;
}
button.btn {
margin: 0;
opacity: .3;
&.sort-column--active {
opacity: 1;
visibility: visible;
}
&:hover {
opacity: .7;
}
}
&__asc {
svg {
transform: rotate(180deg);
}
}
&:hover {
.btn {
opacity: .4;
visibility: visible;
}
}
}

View File

@@ -0,0 +1,101 @@
import React, {
useReducer, createContext, useContext, useEffect, useCallback,
} from '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 useStatusList = () => useContext(Context);
const StatusListProvider = ({ children }) => {
const [statusList, dispatchStatus] = useReducer(reducer, []);
const { pathname, state } = useLocation();
const removeStatus = useCallback((i) => dispatchStatus({ type: 'REMOVE', payload: i }), []);
const addStatus = useCallback((status) => dispatchStatus({ type: 'ADD', payload: status }), []);
const clearStatus = useCallback(() => dispatchStatus({ type: 'CLEAR' }), []);
const replaceStatus = useCallback((status) => dispatchStatus({ type: 'REPLACE', payload: status }), []);
useEffect(() => {
if (state && state.status) {
if (Array.isArray(state.status)) {
replaceStatus(state.status);
} else {
replaceStatus([state.status]);
}
} else {
clearStatus();
}
}, [addStatus, replaceStatus, clearStatus, state, pathname]);
return (
<Context.Provider value={{
statusList,
removeStatus,
addStatus,
clearStatus,
replaceStatus,
}}
>
{children}
</Context.Provider>
);
};
StatusListProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
const StatusList = () => {
const { statusList, removeStatus } = useStatusList();
if (statusList.length > 0) {
return (
<ul className={baseClass}>
{statusList.map((status, i) => {
const classes = [
`${baseClass}__status`,
`${baseClass}__status--${status.type}`,
].join(' ');
return (
<li
className={classes}
key={i}
>
{status.message}
<button
type="button"
className="close"
onClick={(e) => {
e.preventDefault();
removeStatus(i);
}}
>
<X />
</button>
</li>
);
})}
</ul>
);
}
return null;
};
export {
StatusListProvider,
useStatusList,
};
export default StatusList;

View File

@@ -0,0 +1,47 @@
@import '../../../scss/styles.scss';
.status-list {
position: relative;
z-index: $z-status;
list-style: none;
padding: 0;
margin: 0;
li {
background: $color-green;
color: $color-dark-gray;
padding: base(.5) $baseline;
margin-bottom: 1px;
display: flex;
justify-content: space-between;
}
button {
@extend %btn-reset;
cursor: pointer;
svg {
width: base(1);
height: base(1);
}
&:hover {
opacity: .5;
}
&:active,
&:focus {
outline: none;
border: 0;
}
}
li.status-list__status--error {
background: $color-red;
color: white;
button svg {
@include color-svg(white);
}
}
}

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

@@ -0,0 +1,77 @@
import React, {
useState, createContext, useContext,
} from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Chevron from '../../icons/Chevron';
import './index.scss';
const Context = createContext({});
const StepNavProvider = ({ children }) => {
const [stepNav, setStepNav] = useState([]);
return (
<Context.Provider value={{
stepNav,
setStepNav,
}}
>
{children}
</Context.Provider>
);
};
StepNavProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
const useStepNav = () => useContext(Context);
const StepNav = () => {
const dashboardLabel = <span>Dashboard</span>;
const { stepNav } = useStepNav();
return (
<nav className="step-nav">
{stepNav.length > 0
? (
<Link to="/admin">
{dashboardLabel}
<Chevron />
</Link>
)
: dashboardLabel
}
{stepNav.map((item, i) => {
const StepLabel = <span key={i}>{item.label}</span>;
const Step = stepNav.length === i + 1
? StepLabel
: (
<Link
to={item.url}
key={i}
>
{StepLabel}
<Chevron />
</Link>
);
return Step;
})}
</nav>
);
};
export {
StepNavProvider,
useStepNav,
};
export default StepNav;

View File

@@ -0,0 +1,44 @@
@import '../../../scss/styles.scss';
.step-nav {
display: flex;
* {
display: block;
}
a {
margin-right: base(.25);
border: 0;
display: flex;
align-items: center;
font-weight: 600;
text-decoration: none;
svg {
margin-left: base(.25);
transform: rotate(-90deg);
}
label {
cursor: pointer;
}
&:hover {
text-decoration: underline;
}
}
span {
max-width: base(6);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
@include mid-break {
> *:first-child {
padding-left: $baseline;
}
}
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'table';
const Table = ({ columns, data }) => {
if (columns && columns.length > 0) {
return (
<div className={baseClass}>
<table
cellPadding="0"
cellSpacing="0"
border="0"
>
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>
{col.components.Heading}
</th>
))}
</tr>
</thead>
<tbody>
{data && data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((col, colIndex) => (
<td key={colIndex}>
{col.components.renderCell(row, row[col.accessor])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
return null;
};
Table.propTypes = {
columns: PropTypes.arrayOf(
PropTypes.shape({
accessor: PropTypes.string,
components: PropTypes.shape({
Heading: PropTypes.node,
renderCell: PropTypes.function,
}),
}),
).isRequired,
data: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
};
export default Table;

View File

@@ -0,0 +1,35 @@
@import '../../../scss/styles.scss';
.table {
margin-bottom: $baseline;
overflow: auto;
max-width: 100%;
thead {
color: $color-gray;
th {
font-weight: normal;
text-align: left;
}
}
th, td {
padding: base(.75);
min-width: 150px;
}
tbody {
tr {
&:nth-child(odd) {
background: $color-background-gray;
}
}
}
@include mid-break {
th, td {
max-width: 70vw;
}
}
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useConfig } from '../../providers/Config';
import FileGraphic from '../../graphics/File';
import getThumbnail from '../../../../uploads/getThumbnail';
import './index.scss';
const baseClass = 'thumbnail';
const Thumbnail = (props) => {
const {
filename, mimeType, staticURL, sizes, adminThumbnail, size,
} = props;
const { serverURL } = useConfig();
const thumbnail = getThumbnail(mimeType, staticURL, filename, sizes, adminThumbnail);
const classes = [
baseClass,
`${baseClass}--size-${size}`,
].join(' ');
return (
<div className={classes}>
{thumbnail && (
<img
src={`${serverURL}${thumbnail}`}
alt={filename}
/>
)}
{!thumbnail && (
<FileGraphic />
)}
</div>
);
};
Thumbnail.defaultProps = {
adminThumbnail: undefined,
sizes: undefined,
mimeType: undefined,
size: 'medium',
};
Thumbnail.propTypes = {
filename: PropTypes.string.isRequired,
sizes: PropTypes.shape({}),
adminThumbnail: PropTypes.string,
mimeType: PropTypes.string,
staticURL: PropTypes.string.isRequired,
size: PropTypes.oneOf(['small', 'medium', 'large', 'expand']),
};
export default Thumbnail;

View File

@@ -0,0 +1,47 @@
@import '../../../scss/styles.scss';
.thumbnail {
min-height: 100%;
flex-shrink: 0;
align-self: stretch;
overflow: hidden;
img, svg {
width: 100%;
height: 100%;
object-fit: cover;
}
&--size-expand {
max-height: 100%;
width: 100%;
padding-top: 100%;
position: relative;
img, svg {
position: absolute;
top: 0;
}
}
&--size-large {
max-height: base(9);
width: base(9);
}
&--size-medium {
max-height: base(7);
width: base(7);
}
&--size-small {
max-height: base(5);
width: base(5);
}
@include large-break {
.thumbnail {
width: base(5);
}
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const Tooltip = (props) => {
const { className, children } = props;
const classes = [
'tooltip',
className,
].filter(Boolean).join(' ');
return (
<aside className={classes}>
{children}
<span />
</aside>
);
};
Tooltip.defaultProps = {
className: undefined,
};
Tooltip.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
};
export default Tooltip;

View File

@@ -0,0 +1,26 @@
@import '../../../scss/styles.scss';
.tooltip {
background-color: $color-dark-gray;
position: absolute;
z-index: 2;
bottom: 100%;
left: 50%;
transform: translate3d(-50%, -20%, 0);
padding: base(.2) base(.4);
color: white;
line-height: base(.75);
font-weight: normal;
white-space: nowrap;
span {
position: absolute;
transform: translateX(-50%);
top: calc(100% - #{base(.0625)});
left: 50%;
height: 0;
width: 0;
border: 10px solid transparent;
border-top-color: $color-dark-gray;
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import Thumbnail from '../Thumbnail';
import './index.scss';
const baseClass = 'upload-card';
const UploadCard = (props) => {
const {
onClick,
mimeType,
sizes,
filename,
collection: {
upload: {
adminThumbnail,
staticURL,
} = {},
} = {},
} = props;
const classes = [
baseClass,
typeof onClick === 'function' && `${baseClass}--has-on-click`,
].filter(Boolean).join(' ');
return (
<div
className={classes}
onClick={typeof onClick === 'function' ? onClick : undefined}
>
<Thumbnail
size="expand"
{...{
mimeType, adminThumbnail, sizes, staticURL, filename,
}}
/>
<div className={`${baseClass}__filename`}>
{filename}
</div>
</div>
);
};
UploadCard.defaultProps = {
sizes: undefined,
onClick: undefined,
};
UploadCard.propTypes = {
collection: PropTypes.shape({
labels: PropTypes.shape({
singular: PropTypes.string,
}),
upload: PropTypes.shape({
adminThumbnail: PropTypes.string,
staticURL: PropTypes.string,
}),
}).isRequired,
id: PropTypes.string.isRequired,
filename: PropTypes.string.isRequired,
mimeType: PropTypes.string.isRequired,
sizes: PropTypes.shape({}),
onClick: PropTypes.func,
};
export default UploadCard;

View File

@@ -0,0 +1,19 @@
@import '../../../scss/styles.scss';
.upload-card {
@include shadow;
background: white;
max-width: base(9);
margin-bottom: base(.5);
&__filename {
padding: base(.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--has-on-click {
cursor: pointer;
}
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import UploadCard from '../UploadCard';
import './index.scss';
const baseClass = 'upload-gallery';
const UploadGallery = (props) => {
const { docs, onCardClick, collection } = props;
if (docs && docs.length > 0) {
return (
<ul className={baseClass}>
{docs.map((doc, i) => {
return (
<li key={i}>
<UploadCard
{...doc}
{...{ collection }}
onClick={() => onCardClick(doc)}
/>
</li>
);
})}
</ul>
);
}
return null;
};
UploadGallery.defaultProps = {
docs: undefined,
};
UploadGallery.propTypes = {
docs: PropTypes.arrayOf(
PropTypes.shape({}),
),
collection: PropTypes.shape({}).isRequired,
onCardClick: PropTypes.func.isRequired,
};
export default UploadGallery;

View File

@@ -0,0 +1,32 @@
@import '../../../scss/styles.scss';
.upload-gallery {
list-style: none;
padding: 0;
margin: base(2) -#{base(.5)};
width: calc(100% + #{$baseline});
display: flex;
flex-wrap: wrap;
li {
min-width: 0;
width: 25%;
}
.upload-card {
margin: base(.5);
max-width: initial;
}
@include large-break {
li {
width: 33.33%;
}
}
@include mid-break {
li {
width: 50%;
}
}
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import DatePicker from '../../../DatePicker';
const baseClass = 'condition-value-date';
const DateField = ({ onChange, value }) => (
<div className={baseClass}>
<DatePicker
onChange={onChange}
value={value}
/>
</div>
);
DateField.defaultProps = {
value: undefined,
};
DateField.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.instanceOf(Date),
};
export default DateField;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'condition-value-number';
const NumberField = ({ onChange, value }) => {
return (
<input
placeholder="Enter a value"
className={baseClass}
type="number"
onChange={e => onChange(e.target.value)}
value={value}
/>
);
};
NumberField.defaultProps = {
value: '',
};
NumberField.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default NumberField;

View File

@@ -0,0 +1,5 @@
@import '../../../../../scss/styles.scss';
.condition-value-number {
@include formInput;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'condition-value-text';
const Text = ({ onChange, value }) => {
return (
<input
placeholder="Enter a value"
className={baseClass}
type="text"
onChange={e => onChange(e.target.value)}
value={value}
/>
);
};
Text.defaultProps = {
value: '',
};
Text.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default Text;

View File

@@ -0,0 +1,5 @@
@import '../../../../../scss/styles.scss';
.condition-value-text {
@include formInput;
}

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import ReactSelect from '../../ReactSelect';
import Button from '../../Button';
import Date from './Date';
import Number from './Number';
import Text from './Text';
import useDebounce from '../../../../hooks/useDebounce';
import './index.scss';
const valueFields = {
Date,
Number,
Text,
};
const baseClass = 'condition';
const Condition = (props) => {
const {
fields,
dispatch,
value,
orIndex,
andIndex,
} = props;
const [activeField, setActiveField] = useState({ operators: [] });
const [internalValue, setInternalValue] = useState(value.value);
const debouncedValue = useDebounce(internalValue, 300);
useEffect(() => {
const newActiveField = fields.find((field) => value.field === field.value);
if (newActiveField) {
setActiveField(newActiveField);
}
}, [value, fields]);
useEffect(() => {
dispatch({
type: 'update',
orIndex,
andIndex,
value: debouncedValue || '',
});
}, [debouncedValue, dispatch, orIndex, andIndex]);
const ValueComponent = valueFields[activeField.component] || valueFields.Text;
return (
<div className={baseClass}>
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__inputs`}>
<div className={`${baseClass}__field`}>
<ReactSelect
value={fields.find((field) => value.field === field.value)}
options={fields}
onChange={(field) => dispatch({
type: 'update',
orIndex,
andIndex,
field,
})}
/>
</div>
<div className={`${baseClass}__operator`}>
<ReactSelect
value={activeField.operators.find((operator) => value.operator === operator.value)}
options={activeField.operators}
onChange={(operator) => dispatch({
type: 'update',
orIndex,
andIndex,
operator,
})}
/>
</div>
<div className={`${baseClass}__value`}>
<RenderCustomComponent
CustomComponent={activeField?.props?.admin?.components?.Filter}
DefaultComponent={ValueComponent}
componentProps={{
...activeField.props,
value: internalValue,
onChange: setInternalValue,
}}
/>
</div>
</div>
<div className={`${baseClass}__actions`}>
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => dispatch({
type: 'remove',
orIndex,
andIndex,
})}
/>
<Button
icon="plus"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => dispatch({
type: 'add',
relation: 'and',
orIndex,
andIndex: andIndex + 1,
})}
/>
</div>
</div>
</div>
);
};
Condition.propTypes = {
fields: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,
value: PropTypes.string,
operators: PropTypes.arrayOf(
PropTypes.shape({}),
),
}),
).isRequired,
value: PropTypes.shape({
field: PropTypes.string,
operator: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.instanceOf(Date),
PropTypes.shape({}),
]),
}).isRequired,
dispatch: PropTypes.func.isRequired,
orIndex: PropTypes.number.isRequired,
andIndex: PropTypes.number.isRequired,
};
export default Condition;

View File

@@ -0,0 +1,52 @@
@import '../../../../scss/styles.scss';
.condition {
&__wrap {
display: flex;
align-items: center;
}
&__inputs {
display: flex;
flex-grow: 1;
> div {
flex-basis: 100%;
}
}
&__field,
&__operator {
margin-right: $baseline;
}
&__actions {
flex-shrink: 0;
}
.btn {
vertical-align: middle;
margin: 0 0 0 $baseline;
}
@include mid-break {
&__wrap {
align-items: initial;
}
&__inputs {
display: block;
}
&__actions {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__field,
&__operator {
margin: 0 0 base(.5);
}
}
}

View File

@@ -0,0 +1,96 @@
const boolean = [
{
label: 'equals',
value: 'equals',
},
{
label: 'is not equal to',
value: 'not_equals',
},
];
const base = [
...boolean,
{
label: 'is in',
value: 'in',
},
{
label: 'is not in',
value: 'not_in',
},
];
const numeric = [
...base,
{
label: 'is greater than',
value: 'greater_than',
},
{
label: 'is less than',
value: 'less_than',
},
{
label: 'is less than or equal to',
value: 'less_than_equals',
},
{
label: 'is greater than or equal to',
value: 'greater_than_equals',
},
];
const like = {
label: 'is like',
value: 'like',
};
const fieldTypeConditions = {
text: {
component: 'Text',
operators: [...base, like],
},
email: {
component: 'Text',
operators: [...base, like],
},
textarea: {
component: 'Text',
operators: [...base, like],
},
wysiwyg: {
component: 'Text',
operators: [...base, like],
},
code: {
component: 'Text',
operators: [...base, like],
},
number: {
component: 'Number',
operators: [...base, ...numeric],
},
date: {
component: 'Date',
operators: [...base, ...numeric],
},
upload: {
component: 'Text',
operators: [...base],
},
relationship: {
component: 'Text',
operators: [...base],
},
select: {
component: 'Text',
operators: [...base],
},
checkbox: {
component: 'Text',
operators: boolean,
},
};
export default fieldTypeConditions;

View File

@@ -0,0 +1,172 @@
import React, { useState, useReducer } from 'react';
import PropTypes from 'prop-types';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import Button from '../Button';
import reducer from './reducer';
import Condition from './Condition';
import fieldTypes from './field-types';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import './index.scss';
const baseClass = 'where-builder';
const validateWhereQuery = (query) => {
if (query.or.length > 0 && query.or[0].and && query.or[0].and.length > 0) {
return query;
}
return null;
};
const reduceFields = (fields) => flattenTopLevelFields(fields).reduce((reduced, field) => {
if (typeof fieldTypes[field.type] === 'object') {
const formattedField = {
label: field.label,
value: field.name,
...fieldTypes[field.type],
props: {
...field,
},
};
return [
...reduced,
formattedField,
];
}
return reduced;
}, []);
const WhereBuilder = (props) => {
const {
collection,
collection: {
labels: {
plural,
} = {},
} = {},
handleChange,
} = props;
const [where, dispatchWhere] = useReducer(reducer, []);
const [reducedFields] = useState(() => reduceFields(collection.fields));
useThrottledEffect(() => {
let whereQuery = {
or: [],
};
if (where) {
whereQuery.or = where.map((or) => or.reduce((conditions, condition) => {
const { field, operator, value } = condition;
if (field && operator && value) {
return {
and: [
...conditions.and,
{
[field]: {
[operator]: value,
},
},
],
};
}
return conditions;
}, {
and: [],
}));
}
whereQuery = validateWhereQuery(whereQuery);
if (typeof handleChange === 'function') handleChange(whereQuery);
}, 500, [where, handleChange]);
return (
<div className={baseClass}>
{where.length > 0 && (
<React.Fragment>
<div className={`${baseClass}__label`}>
Filter
{' '}
{plural}
{' '}
where
</div>
<ul className={`${baseClass}__or-filters`}>
{where.map((or, orIndex) => (
<li key={orIndex}>
{orIndex !== 0 && (
<div className={`${baseClass}__label`}>
Or
</div>
)}
<ul className={`${baseClass}__and-filters`}>
{or && or.map((_, andIndex) => (
<li key={andIndex}>
{andIndex !== 0 && (
<div className={`${baseClass}__label`}>
And
</div>
)}
<Condition
value={where[orIndex][andIndex]}
orIndex={orIndex}
andIndex={andIndex}
key={andIndex}
fields={reducedFields}
dispatch={dispatchWhere}
/>
</li>
))}
</ul>
</li>
))}
</ul>
<Button
className={`${baseClass}__add-or`}
icon="plus"
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchWhere({ type: 'add' })}
>
Or
</Button>
</React.Fragment>
)}
{where.length === 0 && (
<div className={`${baseClass}__no-filters`}>
<div className={`${baseClass}__label`}>No filters set</div>
<Button
className={`${baseClass}__add-first-filter`}
icon="plus"
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchWhere({ type: 'add' })}
>
Add filter
</Button>
</div>
)}
</div>
);
};
WhereBuilder.propTypes = {
handleChange: PropTypes.func.isRequired,
collection: PropTypes.shape({
fields: PropTypes.arrayOf(
PropTypes.shape({}),
),
labels: PropTypes.shape({
plural: PropTypes.string,
}),
}).isRequired,
};
export default WhereBuilder;

View File

@@ -0,0 +1,26 @@
@import '../../../scss/styles.scss';
.where-builder {
background: $color-background-gray;
padding: base(.5) $baseline $baseline;
&__label {
margin: base(.5) 0;
}
&__or-filters,
&__and-filters {
list-style: none;
margin: 0;
padding: 0;
}
&__add-or,
&__add-first-filter {
margin-bottom: 0;
}
&__add-first-filter {
margin-top: 0;
}
}

View File

@@ -0,0 +1,63 @@
const reducer = (state, action = {}) => {
const newState = [...state];
const {
type,
relation,
orIndex,
andIndex,
field,
operator,
value,
} = action;
switch (type) {
case 'add': {
if (relation === 'and') {
newState[orIndex].splice(andIndex, 0, {});
return newState;
}
return [
...newState,
[{}],
];
}
case 'remove': {
newState[orIndex].splice(andIndex, 1);
if (newState[orIndex].length === 0) {
newState.splice(orIndex, 1);
}
return newState;
}
case 'update': {
newState[orIndex][andIndex] = {
...newState[orIndex][andIndex],
};
if (operator) {
newState[orIndex][andIndex].operator = operator;
}
if (field) {
newState[orIndex][andIndex].field = field;
}
if (value !== undefined) {
newState[orIndex][andIndex].value = value;
}
return newState;
}
default: {
return newState;
}
}
};
export default reducer;

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { render } from '@testing-library/react';
import Separator from './Paginator/Separator';
describe('Elements', () => {
describe('Paginator', () => {
it('separator - renders dash', () => {
const { getByText } = render(<Separator />);
const linkElement = getByText(/—/i); // &mdash;
expect(linkElement).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,125 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import Popup from '../../../elements/Popup';
import BlockSelector from '../../field-types/Blocks/BlockSelector';
import './index.scss';
const baseClass = 'action-panel';
const ActionPanel = (props) => {
const {
addRow,
removeRow,
label,
blockType,
blocks,
rowIndex,
isHovered,
} = props;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Popup
showOnHover
size="wide"
color="dark"
horizontalAlign="center"
buttonType="custom"
button={(
<Button
className={`${baseClass}__remove-row`}
round
buttonStyle="none"
icon="x"
iconPosition="left"
iconStyle="with-border"
onClick={removeRow}
/>
)}
>
Remove&nbsp;
{label}
</Popup>
{blockType === 'blocks'
? (
<Popup
buttonType="custom"
size="large"
horizontalAlign="center"
button={(
<Button
className={`${baseClass}__add-row`}
round
buttonStyle="none"
icon="plus"
iconPosition="left"
iconStyle="with-border"
/>
)}
render={({ close }) => (
<BlockSelector
blocks={blocks}
addRow={addRow}
addRowIndex={rowIndex}
close={close}
parentIsHovered={isHovered}
watchParentHover
/>
)}
/>
)
: (
<Popup
showOnHover
size="wide"
color="dark"
horizontalAlign="center"
buttonType="custom"
button={(
<Button
className={`${baseClass}__add-row`}
round
buttonStyle="none"
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={addRow}
/>
)}
>
Add&nbsp;
{label}
</Popup>
)}
</div>
);
};
ActionPanel.defaultProps = {
label: 'Row',
blockType: null,
isHovered: false,
blocks: [],
};
ActionPanel.propTypes = {
label: PropTypes.string,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
blockType: PropTypes.oneOf(['blocks', 'array']),
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
),
isHovered: PropTypes.bool,
rowIndex: PropTypes.number.isRequired,
};
export default ActionPanel;

View File

@@ -0,0 +1,12 @@
@import '../../../../scss/styles';
.action-panel {
&__remove-row {
margin: 0 0 base(.3);
}
&__add-row {
margin: base(.3) 0 0;
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import './index.scss';
const baseClass = 'position-panel';
const PositionPanel = (props) => {
const { moveRow, positionIndex, rowCount } = props;
const adjustedIndex = positionIndex + 1;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Button
className={`${baseClass}__move-backward ${positionIndex === 0 ? 'first-row' : ''}`}
buttonStyle="none"
icon="chevron"
round
onClick={() => moveRow(positionIndex, positionIndex - 1)}
/>
{(adjustedIndex && typeof positionIndex === 'number')
&& <div className={`${baseClass}__current-position`}>{adjustedIndex >= 10 ? adjustedIndex : `0${adjustedIndex}`}</div>}
<Button
className={`${baseClass}__move-forward ${(positionIndex === rowCount - 1) ? 'last-row' : ''}`}
buttonStyle="none"
icon="chevron"
round
onClick={() => moveRow(positionIndex, positionIndex + 1)}
/>
</div>
);
};
PositionPanel.defaultProps = {
positionIndex: null,
};
PositionPanel.propTypes = {
positionIndex: PropTypes.number,
moveRow: PropTypes.func.isRequired,
rowCount: PropTypes.number.isRequired,
};
export default PositionPanel;

Some files were not shown because too many files have changed in this diff Show More