initial commit

This commit is contained in:
Jordan Knott
2020-04-09 21:40:22 -05:00
commit 9611105364
141 changed files with 29236 additions and 0 deletions

9
web/.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

62
web/.eslintrc.json Normal file
View File

@ -0,0 +1,62 @@
{
"env": {
"browser": true,
"es6": true
},
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint", "prettier"],
"extends": [
"plugin:react/recommended",
"airbnb",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"prettier/prettier": "error",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
"react/prop-types": 0,
"react/jsx-props-no-spreading": "off",
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"mjs": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [".storybook/**", "src/shared/components/**/*.stories.tsx"]
}
]
},
"settings": {
"import/resolver": {
"node": {
"paths": ["src"],
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
}
}

23
web/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

7
web/.prettierrc.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
semi: true,
trailingComma: "all",
singleQuote: true,
printWidth: 120,
tabWidth: 2
};

18
web/.storybook/main.js Normal file
View File

@ -0,0 +1,18 @@
const path = require('path');
module.exports = {
stories: ['../src/shared/components/**/*.stories.tsx'],
addons: [
'@storybook/addon-actions/register',
'@storybook/addon-links',
'@storybook/addon-storysource',
'@storybook/addon-knobs/register',
'@storybook/addon-docs/register',
'@storybook/addon-viewport/register',
'@storybook/addon-backgrounds/register',
],
webpackFinal: async config => {
config.resolve.modules.push(path.resolve(__dirname, '../src'));
return config;
},
};

44
web/README.md Normal file
View File

@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

95
web/package.json Normal file
View File

@ -0,0 +1,95 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@apollo/react-hooks": "^3.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-brands-svg-icons": "^5.12.1",
"@fortawesome/free-regular-svg-icons": "^5.12.1",
"@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/react-fontawesome": "^0.1.8",
"@storybook/addon-backgrounds": "^5.3.17",
"@storybook/addon-docs": "^5.3.17",
"@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/color": "^3.0.1",
"@types/jest": "^24.0.0",
"@types/lodash": "^4.14.149",
"@types/node": "^12.0.0",
"@types/react": "^16.9.21",
"@types/react-beautiful-dnd": "^12.1.1",
"@types/react-dom": "^16.9.5",
"@types/react-router": "^5.1.4",
"@types/react-router-dom": "^5.1.3",
"@types/styled-components": "^5.0.0",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
"apollo-link": "^1.2.13",
"apollo-link-error": "^1.1.12",
"apollo-link-http": "^1.5.16",
"apollo-link-state": "^0.4.2",
"apollo-utilities": "^1.3.3",
"color": "^3.1.2",
"graphql": "^14.6.0",
"graphql-tag": "^2.10.3",
"history": "^4.10.1",
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0",
"react-dom": "^16.12.0",
"react-hook-form": "^5.2.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"styled-components": "^5.0.1",
"typescript": "~3.7.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.13",
"@storybook/addon-links": "^5.3.13",
"@storybook/addons": "^5.3.13",
"@storybook/preset-create-react-app": "^1.5.2",
"@storybook/react": "^5.3.13",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-react": "^7.18.3",
"eslint-plugin-react-hooks": "^1.7.0",
"prettier": "^1.19.1"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

43
web/public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
web/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
web/public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
web/public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

110
web/src/App/BaseStyles.ts Normal file
View File

@ -0,0 +1,110 @@
import { createGlobalStyle } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export default createGlobalStyle`
html, body, #root {
height: 100%;
min-height: 100%;
min-width: 768px;
}
body {
color: ${color.textDarkest};
-webkit-tap-highlight-color: transparent;
line-height: 1.2;
${font.size(16)}
${font.regular}
}
#root {
display: flex;
flex-direction: column;
}
button,
input,
optgroup,
select,
textarea {
${font.regular}
}
*, *:after, *:before, input[type="search"] {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
ul {
list-style: none;
}
ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
padding: 0;
margin: 0;
}
h1, h2, h3, h4, h5, h6, strong {
${font.bold}
}
button {
background: none;
border: none;
}
/* Workaround for IE11 focus highlighting for select elements */
select::-ms-value {
background: none;
color: #42413d;
}
[role="button"], button, input, select, textarea {
outline: none;
&:focus {
outline: none;
}
&:disabled {
opacity: 1;
}
}
[role="button"], button, input, textarea {
appearance: none;
}
select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #000;
}
select::-ms-expand {
display: none;
}
select option {
color: ${color.textDarkest};
}
p {
line-height: 1.4285;
a {
${mixin.link()}
}
}
textarea {
line-height: 1.4285;
}
body, select {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
touch-action: manipulation;
}
${mixin.placeholderColor(color.textLight)}
`;

26
web/src/App/Navbar.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import { Home, Stack } from 'shared/icons';
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from 'shared/components/Navbar';
import { Link } from 'react-router-dom';
const GlobalNavbar = () => {
return (
<Navbar>
<PrimaryLogo />
<ButtonContainer>
<Link to="/">
<ActionButton name="Home">
<Home size={28} color="#c2c6dc" />
</ActionButton>
</Link>
<Link to="/projects">
<ActionButton name="Projects">
<Stack size={28} color="#c2c6dc" />
</ActionButton>
</Link>
</ButtonContainer>
</Navbar>
);
};
export default GlobalNavbar;

View File

@ -0,0 +1,152 @@
import { createGlobalStyle } from 'styled-components';
/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
export default createGlobalStyle`
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
main {
display: block;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
a {
background-color: transparent;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}
`;

23
web/src/App/Routes.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import { Router, Switch, Route } from 'react-router-dom';
import * as H from 'history';
import Projects from 'Projects';
import Project from 'Projects/Project';
import Login from 'Auth';
type RoutesProps = {
history: H.History;
};
const Routes = ({ history }: RoutesProps) => (
<Router history={history}>
<Switch>
<Route exact path="/projects" component={Projects} />
<Route exact path="/projects/:projectId" component={Project} />
<Route exact path="/login" component={Login} />
</Switch>
</Router>
);
export default Routes;

26
web/src/App/TopNavbar.tsx Normal file
View File

@ -0,0 +1,26 @@
import React, { useState } from 'react';
import TopNavbar from 'shared/components/TopNavbar';
import DropdownMenu from 'shared/components/DropdownMenu';
const GlobalTopNavbar: React.FC = () => {
const [menu, setMenu] = useState({
top: 0,
left: 0,
isOpen: false,
});
const onProfileClick = (bottom: number, right: number) => {
setMenu({
isOpen: !menu.isOpen,
left: right,
top: bottom,
});
};
return (
<>
<TopNavbar onNotificationClick={() => console.log('beep')} onProfileClick={onProfileClick} />
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
</>
);
};
export default GlobalTopNavbar;

43
web/src/App/index.tsx Normal file
View File

@ -0,0 +1,43 @@
import React, { useState, useEffect } from 'react';
import { createBrowserHistory } from 'history';
import { setAccessToken } from 'shared/utils/accessToken';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import Routes from './Routes';
const history = createBrowserHistory();
const App = () => {
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('http://localhost:3333/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken } = response;
setAccessToken(accessToken);
}
// }
setLoading(false);
});
}, []);
if (loading) {
return <div>loading...</div>;
}
return (
<>
<NormalizeStyles />
<BaseStyles />
<Routes history={history} />
</>
);
};
export default App;

13
web/src/Auth/Styles.ts Normal file
View File

@ -0,0 +1,13 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
export const LoginWrapper = styled.div`
width: 60%;
`;

62
web/src/Auth/index.tsx Normal file
View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login';
import { Container, LoginWrapper } from './Styles';
const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
const history = useHistory();
const login = (
data: LoginFormData,
setComplete: (val: boolean) => void,
setError: (field: string, eType: string, message: string) => void,
) => {
fetch('http://localhost:3333/auth/login', {
credentials: 'include',
method: 'POST',
body: JSON.stringify({
username: data.username,
password: data.password,
}),
}).then(async x => {
if (x.status === 401) {
setInvalidLoginAttempt(invalidLoginAttempt + 1);
setError('username', 'invalid', 'Invalid username');
setError('password', 'invalid', 'Invalid password');
setComplete(true);
} else {
const response = await x.json();
const { accessToken } = response;
setAccessToken(accessToken);
setComplete(true);
history.push('/');
}
});
};
useEffect(() => {
fetch('http://localhost:3333/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 200) {
history.replace('/projects');
}
});
}, []);
return (
<Container>
<LoginWrapper>
<Login onSubmit={login} />
</LoginWrapper>
</Container>
);
};
export default Auth;

View File

@ -0,0 +1,322 @@
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useQuery, useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import { useParams } from 'react-router-dom';
import Navbar from 'App/Navbar';
import TopNavbar from 'App/TopNavbar';
import Lists from 'shared/components/Lists';
import QuickCardEditor from 'shared/components/QuickCardEditor';
interface ColumnState {
[key: string]: TaskGroup;
}
interface TaskState {
[key: string]: RemoteTask;
}
interface State {
columns: ColumnState;
tasks: TaskState;
}
interface QuickCardEditorState {
isOpen: boolean;
left: number;
top: number;
task?: RemoteTask;
}
const MainContent = styled.div`
padding: 0 0 50px 100px;
height: 100%;
background: #262c49;
`;
const Wrapper = styled.div`
font-size: 16px;
background-color: red;
`;
const Title = styled.span`
text-align: center;
font-size: 24px;
color: #fff;
`;
interface ProjectData {
findProject: Project;
}
interface UpdateTaskLocationData {
updateTaskLocation: Task;
}
interface UpdateTaskLocationVars {
taskID: string;
taskGroupID: string;
position: number;
}
interface ProjectVars {
projectId: string;
}
interface CreateTaskVars {
taskGroupID: string;
name: string;
position: number;
}
interface CreateTaskData {
createTask: RemoteTask;
}
interface ProjectParams {
projectId: string;
}
interface DeleteTaskData {
deleteTask: { taskID: string };
}
interface DeleteTaskVars {
taskID: string;
}
interface UpdateTaskNameData {
updateTaskName: RemoteTask;
}
interface UpdateTaskNameVars {
taskID: string;
name: string;
}
const UPDATE_TASK_NAME = gql`
mutation updateTaskName($taskID: String!, $name: String!) {
updateTaskName(input: { taskID: $taskID, name: $name }) {
taskID
name
position
}
}
`;
const GET_PROJECT = gql`
query getProject($projectId: String!) {
findProject(input: { projectId: $projectId }) {
name
taskGroups {
taskGroupID
name
position
tasks {
taskID
name
position
}
}
}
}
`;
const CREATE_TASK = gql`
mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
taskID
taskGroupID
name
position
}
}
`;
const DELETE_TASK = gql`
mutation deleteTask($taskID: String!) {
deleteTask(input: { taskID: $taskID }) {
taskID
}
}
`;
const UPDATE_TASK_LOCATION = gql`
mutation updateTaskLocation($taskID: String!, $taskGroupID: String!, $position: Float!) {
updateTaskLocation(input: { taskID: $taskID, taskGroupID: $taskGroupID, position: $position }) {
taskID
createdAt
name
position
}
}
`;
const initialState: State = { tasks: {}, columns: {} };
const initialQuickCardEditorState: QuickCardEditorState = { isOpen: false, top: 0, left: 0 };
const Project = () => {
const { projectId } = useParams<ProjectParams>();
const [listsData, setListsData] = useState(initialState);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const [updateTaskLocation, updateTaskLocationData] = useMutation<UpdateTaskLocationData, UpdateTaskLocationVars>(
UPDATE_TASK_LOCATION,
);
const [createTask, createTaskData] = useMutation<CreateTaskData, CreateTaskVars>(CREATE_TASK, {
onCompleted: newTaskData => {
const newListsData = {
...listsData,
tasks: {
...listsData.tasks,
[newTaskData.createTask.taskID]: {
taskGroupID: newTaskData.createTask.taskGroupID,
taskID: newTaskData.createTask.taskID,
name: newTaskData.createTask.name,
position: newTaskData.createTask.position,
labels: [],
},
},
};
setListsData(newListsData);
},
});
const [deleteTask, deleteTaskData] = useMutation<DeleteTaskData, DeleteTaskVars>(DELETE_TASK, {
onCompleted: deletedTask => {
const { [deletedTask.deleteTask.taskID]: removedTask, ...remainingTasks } = listsData.tasks;
const newListsData = {
...listsData,
tasks: remainingTasks,
};
setListsData(newListsData);
},
});
const [updateTaskName, updateTaskNameData] = useMutation<UpdateTaskNameData, UpdateTaskNameVars>(UPDATE_TASK_NAME, {
onCompleted: newTaskData => {
const newListsData = {
...listsData,
tasks: {
...listsData.tasks,
[newTaskData.updateTaskName.taskID]: {
...listsData.tasks[newTaskData.updateTaskName.taskID],
name: newTaskData.updateTaskName.name,
},
},
};
setListsData(newListsData);
},
});
const { loading, data } = useQuery<ProjectData, ProjectVars>(GET_PROJECT, {
variables: { projectId },
onCompleted: newData => {
let newListsData: State = { tasks: {}, columns: {} };
newData.findProject.taskGroups.forEach((taskGroup: TaskGroup) => {
newListsData.columns[taskGroup.taskGroupID] = {
taskGroupID: taskGroup.taskGroupID,
name: taskGroup.name,
position: taskGroup.position,
tasks: [],
};
taskGroup.tasks.forEach((task: RemoteTask) => {
newListsData.tasks[task.taskID] = {
taskID: task.taskID,
taskGroupID: taskGroup.taskGroupID,
name: task.name,
position: task.position,
labels: [],
};
});
});
setListsData(newListsData);
},
});
const onCardDrop = (droppedTask: any) => {
updateTaskLocation({
variables: { taskID: droppedTask.taskID, taskGroupID: droppedTask.taskGroupID, position: droppedTask.position },
});
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.taskID]: droppedTask,
},
};
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.taskGroupID]: droppedColumn,
},
};
setListsData(newState);
};
const onCardCreate = (taskGroupID: string, name: string) => {
const taskGroupTasks = Object.values(listsData.tasks).filter(
(task: RemoteTask) => task.taskGroupID === taskGroupID,
);
var position = 65535;
console.log(taskGroupID);
console.log(taskGroupTasks);
if (taskGroupTasks.length !== 0) {
const [lastTask] = taskGroupTasks.sort((a: any, b: any) => a.position - b.position).slice(-1);
console.log(`last tasks position ${lastTask.position}`);
position = Math.ceil(lastTask.position) * 2 + 1;
}
createTask({ variables: { taskGroupID: taskGroupID, name: name, position: position } });
};
const onQuickEditorOpen = (e: ContextMenuEvent) => {
const task = Object.values(listsData.tasks).find(task => task.taskID === e.cardId);
setQuickCardEditor({
top: e.top,
left: e.left,
isOpen: true,
task,
});
};
if (loading) {
return <Wrapper>Loading</Wrapper>;
}
if (data) {
return (
<>
<Navbar />
<MainContent>
<TopNavbar />
<Title>{data.findProject.name}</Title>
<Lists
onQuickEditorOpen={onQuickEditorOpen}
onCardCreate={onCardCreate}
{...listsData}
onCardDrop={onCardDrop}
onListDrop={onListDrop}
/>
</MainContent>
{quickCardEditor.isOpen && (
<QuickCardEditor
isOpen={true}
listId={quickCardEditor.task ? quickCardEditor.task.taskGroupID : ''}
cardId={quickCardEditor.task ? quickCardEditor.task.taskID : ''}
cardTitle={quickCardEditor.task ? quickCardEditor.task.name : ''}
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
onEditCard={(listId: string, cardId: string, cardName: string) =>
updateTaskName({ variables: { taskID: cardId, name: cardName } })
}
onOpenPopup={() => console.log()}
onArchiveCard={(listId: string, cardId: string) => deleteTask({ variables: { taskID: cardId } })}
labels={[]}
top={quickCardEditor.top}
left={quickCardEditor.left}
/>
)}
</>
);
}
return <Wrapper>Error</Wrapper>;
};
export default Project;

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import TopNavbar from 'App/TopNavbar';
import ProjectGridItem from 'shared/components/ProjectGridItem';
import { Link } from 'react-router-dom';
import Navbar from 'App/Navbar';
const MainContent = styled.div`
padding: 0 0 50px 80px;
height: 100%;
background: #262c49;
`;
const ProjectGrid = styled.div`
width: 60%;
margin: 25px auto;
display: flex;
align-items: center;
justify-content: center;
`;
const Wrapper = styled.div`
font-size: 16px;
background-color: red;
`;
interface ProjectData {
name: string;
organizations: Organization[];
}
const GET_PROJECTS = gql`
query getProjects {
organizations {
name
teams {
name
projects {
name
projectID
}
}
}
}
`;
const Projects = () => {
const { loading, data } = useQuery<ProjectData>(GET_PROJECTS);
console.log(loading, data);
if (loading) {
return <Wrapper>Loading</Wrapper>;
}
if (data) {
const { teams } = data.organizations[0];
const projects: Project[] = [];
teams.forEach(team =>
team.projects.forEach(project => {
projects.push({
taskGroups: [],
projectID: project.projectID,
teamTitle: team.name,
name: project.name,
color: '#aa62e3',
});
}),
);
return (
<>
<Navbar />
<MainContent>
<TopNavbar />
<ProjectGrid>
{projects.map(project => (
<Link to={`/projects/${project.projectID}/`}>
<ProjectGridItem project={project} />
</Link>
))}
</ProjectGrid>
</MainContent>
</>
);
}
return <Wrapper>Error</Wrapper>;
};
export default Projects;

65
web/src/citadel.d.ts vendored Normal file
View File

@ -0,0 +1,65 @@
type ContextMenuEvent = {
left: number;
top: number;
cardId: string;
listId: string;
};
interface RemoteTask {
taskID: string;
taskGroupID: string;
name: string;
position: number;
labels: Label[];
}
type TaskGroup = {
taskGroupID: string;
name: string;
position: number;
tasks: RemoteTask[];
};
type Project = {
projectID: string;
name: string;
color?: string;
teamTitle?: string;
taskGroups: TaskGroup[];
};
interface Organization {
name: string;
teams: Team[];
}
interface Team {
name: string;
projects: Project[];
}
type Label = {
labelId: string;
name: string;
color: string;
active: boolean;
};
type Task = {
title: string;
position: number;
};
type RefreshTokenResponse = {
accessToken: string;
};
type LoginFormData = {
username: string;
password: string;
};
type LoginProps = {
onSubmit: (
data: LoginFormData,
setComplete: (val: boolean) => void,
setError: (field: string, eType: string, message: string) => void,
) => void;
};

130
web/src/index.tsx Normal file
View File

@ -0,0 +1,130 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
let isRefreshing = false;
let pendingRequests: any = [];
const resolvePendingRequests = () => {
pendingRequests.map((callback: any) => callback());
pendingRequests = [];
};
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err!.extensions!.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
let forward$;
if (!isRefreshing) {
isRefreshing = true;
forward$ = fromPromise(
getNewToken()
.then((response: any) => {
// Store the new tokens for your auth link
setAccessToken(response.accessToken);
resolvePendingRequests();
return response.accessToken;
})
.catch((error: any) => {
pendingRequests = [];
// TODO
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return;
})
.finally(() => {
isRefreshing = false;
}),
).filter(value => Boolean(value));
} else {
// Will only emit once the Promise is resolved
forward$ = fromPromise(
new Promise(resolve => {
pendingRequests.push(() => resolve());
}),
);
}
return forward$.flatMap(() => forward(operation));
default:
// pass
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
// if you would also like to retry automatically on
// network errors, we recommend that you use
// apollo-link-retry
}
});
const requestLink = new ApolloLink(
(operation, forward) =>
new Observable((observer: any) => {
let handle: any;
Promise.resolve(operation)
.then((operation: any) => {
const accessToken = getAccessToken();
if (accessToken) {
operation.setContext({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
}
})
.then(() => {
handle = forward(operation).subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
.catch(observer.error.bind(observer));
return () => {
if (handle) handle.unsubscribe();
};
}),
);
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
);
if (networkError) console.log(`[Network error]: ${networkError}`);
}),
errorLink,
requestLink,
new HttpLink({
uri: 'http://localhost:3333/graphql',
credentials: 'same-origin',
}),
]),
cache: new InMemoryCache(),
});
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById('root'),
);

1
web/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

5
web/src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

View File

@ -0,0 +1,116 @@
import React, { useRef } from 'react';
import { action } from '@storybook/addon-actions';
import LabelColors from 'shared/constants/labelColors';
import Card from './index';
export default {
component: Card,
title: 'Card',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
const labelData = [
{
labelId: 'development',
name: 'Development',
color: LabelColors.BLUE,
active: false,
},
{
labelId: 'general',
name: 'General',
color: LabelColors.PINK,
active: false,
},
];
export const Default = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description=""
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
/>
);
};
export const Labels = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description=""
ref={$ref}
title="Hello, world"
labels={labelData}
onClick={action('on click')}
onContextMenu={action('on context click')}
/>
);
};
export const Badges = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const PastDue = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: true, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Everything = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};

View File

@ -0,0 +1,122 @@
import styled, { css } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
export const ClockIcon = styled(FontAwesomeIcon)``;
export const ListCardBadges = styled.div`
float: left;
display: flex;
max-width: 100%;
margin-left: -2px;
`;
export const ListCardBadge = styled.div`
color: #5e6c84;
display: flex;
align-items: center;
margin: 0 6px 4px 0;
max-width: 100%;
min-height: 20px;
overflow: hidden;
position: relative;
padding: 2px;
text-decoration: none;
text-overflow: ellipsis;
vertical-align: top;
`;
export const DescriptionBadge = styled(ListCardBadge)`
padding-right: 6px;
`;
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
${props =>
props.isPastDue &&
css`
padding-left: 4px;
background-color: #ec9488;
border-radius: 3px;
color: #fff;
`}
`;
export const ListCardBadgeText = styled.span`
font-size: 12px;
padding: 0 4px 0 6px;
vertical-align: top;
white-space: nowrap;
`;
export const ListCardContainer = styled.div<{ isActive: boolean }>`
max-width: 256px;
margin-bottom: 8px;
background-color: #fff;
border-radius: 3px;
${mixin.boxShadowCard}
cursor: pointer !important;
position: relative;
background-color: ${props => (props.isActive ? mixin.darken('#262c49', 0.1) : mixin.lighten('#262c49', 0.05))};
`;
export const ListCardInnerContainer = styled.div`
width: 100%;
height: 100%;
`;
export const ListCardDetails = styled.div`
overflow: hidden;
padding: 6px 8px 2px;
position: relative;
z-index: 10;
`;
export const ListCardLabels = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span`
height: 16px;
line-height: 16px;
padding: 0 8px;
max-width: 198px;
float: left;
font-size: 12px;
font-weight: 700;
margin: 0 4px 4px 0;
width: auto;
border-radius: 4px;
color: #fff;
display: block;
position: relative;
background-color: ${props => props.color};
`;
export const ListCardOperation = styled.span`
display: flex;
align-content: center;
justify-content: center;
background-color: ${props => mixin.darken('#262c49', 0.15)};
background-clip: padding-box;
background-origin: padding-box;
border-radius: 3px;
opacity: 0.8;
padding: 6px;
position: absolute;
right: 2px;
top: 2px;
z-index: 10;
`;
export const CardTitle = styled.span`
font-family: 'Droid Sans';
clear: both;
display: block;
margin: 0 0 4px;
overflow: hidden;
text-decoration: none;
word-wrap: break-word;
color: #c2c6dc;
`;

View File

@ -0,0 +1,144 @@
import React, { useState, useRef } from 'react';
import { DraggableProvidedDraggableProps } from 'react-beautiful-dnd';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
import {
DescriptionBadge,
DueDateCardBadge,
ListCardBadges,
ListCardBadge,
ListCardBadgeText,
ListCardContainer,
ListCardInnerContainer,
ListCardDetails,
ClockIcon,
ListCardLabels,
ListCardLabel,
ListCardOperation,
CardTitle,
} from './Styles';
type DueDate = {
isPastDue: boolean;
formattedDate: string;
};
type Checklist = {
complete: number;
total: number;
};
type Props = {
title: string;
description: string;
cardId: string;
listId: string;
onContextMenu: (e: ContextMenuEvent) => void;
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
dueDate?: DueDate;
checklists?: Checklist;
watched?: boolean;
labels?: Label[];
wrapperProps?: any;
};
const Card = React.forwardRef(
(
{
wrapperProps,
onContextMenu,
cardId,
listId,
onClick,
labels,
title,
dueDate,
description,
checklists,
watched,
}: Props,
$cardRef: any,
) => {
const [isActive, setActive] = useState(false);
const $innerCardRef: any = useRef(null);
const onOpenComposer = () => {
if (typeof $innerCardRef.current !== 'undefined') {
const pos = $innerCardRef.current.getBoundingClientRect();
onContextMenu({
top: pos.top,
left: pos.left,
listId,
cardId,
});
}
};
const onTaskContext = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpenComposer();
};
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
e.preventDefault();
e.stopPropagation();
onOpenComposer();
};
return (
<ListCardContainer
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
ref={$cardRef}
onClick={onClick}
onContextMenu={onTaskContext}
isActive={isActive}
{...wrapperProps}
>
<ListCardInnerContainer ref={$innerCardRef}>
<ListCardOperation>
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
</ListCardOperation>
<ListCardDetails>
<ListCardLabels>
{labels &&
labels.map(label => (
<ListCardLabel color={label.color} key={label.name}>
{label.name}
</ListCardLabel>
))}
</ListCardLabels>
<CardTitle>{title}</CardTitle>
<ListCardBadges>
{watched && (
<ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
</ListCardBadge>
)}
{dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge>
)}
{description && (
<DescriptionBadge>
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
</DescriptionBadge>
)}
{checklists && (
<ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faCheckSquare} size="xs" />
<ListCardBadgeText>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
</ListCardBadge>
)}
</ListCardBadges>
</ListCardDetails>
</ListCardInnerContainer>
</ListCardContainer>
);
},
);
Card.displayName = 'Card';
export default Card;

View File

@ -0,0 +1,18 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import CardComposer from './index';
export default {
component: CardComposer,
title: 'CardComposer',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return <CardComposer isOpen onClose={action('on close')} onCreateCard={action('on create card')} />;
};

View File

@ -0,0 +1,89 @@
import styled from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
export const CancelIcon = styled(FontAwesomeIcon)`
opacity: 0.8;
cursor: pointer;
font-size: 1.25em;
padding-left: 5px;
`;
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
padding-bottom: 8px;
display: ${props => (props.isOpen ? 'flex' : 'none')};
flex-direction: column;
`;
export const ListCard = styled.div`
background-color: #fff;
border-radius: 3px;
${mixin.boxShadowCard}
cursor: pointer;
display: block;
margin-bottom: 8px;
max-width: 300px;
min-height: 20px;
position: relative;
text-decoration: none;
z-index: 0;
`;
export const ListCardDetails = styled.div`
overflow: hidden;
padding: 6px 8px 2px;
position: relative;
z-index: 10;
`;
export const ListCardLabels = styled.div``;
export const ListCardEditor = styled(TextareaAutosize)`
font-family: 'Droid Sans';
overflow: hidden;
overflow-wrap: break-word;
resize: none;
height: 54px;
width: 100%;
background: none;
border: none;
box-shadow: none;
margin-bottom: 4px;
max-height: 162px;
min-height: 54px;
padding: 0;
font-size: 14px;
line-height: 20px;
&:focus {
border: none;
outline: none;
}
`;
export const ComposerControls = styled.div``;
export const ComposerControlsSaveSection = styled.div`
display: flex;
float: left;
align-items: center;
justify-content: center;
`;
export const ComposerControlsActionsSection = styled.div`
float: right;
`;
export const AddCardButton = styled.button`
background-color: #5aac44;
box-shadow: none;
border: none;
color: #fff;
cursor: pointer;
display: inline-block;
font-weight: 400;
line-height: 20px;
margin-right: 4px;
padding: 6px 12px;
text-align: center;
border-radius: 3px;
`;

View File

@ -0,0 +1,85 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import TextareaAutosize from 'react-autosize-textarea';
import {
CardComposerWrapper,
CancelIcon,
AddCardButton,
ListCard,
ListCardDetails,
ListCardEditor,
ComposerControls,
ComposerControlsSaveSection,
ComposerControlsActionsSection,
} from './Styles';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
type Props = {
isOpen: boolean;
onCreateCard: (cardName: string) => void;
onClose: () => void;
};
const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
const [cardName, setCardName] = useState('');
const $cardEditor: any = useRef(null);
const onClick = () => {
onCreateCard(cardName);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
onCreateCard(cardName);
}
};
const onBlur = () => {
if (cardName === '') {
onClose();
} else {
onCreateCard(cardName);
}
};
useOnEscapeKeyDown(isOpen, onClose);
useOnOutsideClick($cardEditor, true, () => onClose(), null);
useEffect(() => {
$cardEditor.current.focus();
}, []);
return (
<CardComposerWrapper isOpen={isOpen}>
<ListCard>
<ListCardDetails>
<ListCardEditor
onKeyDown={onKeyDown}
ref={$cardEditor}
onChange={e => {
setCardName(e.currentTarget.value);
}}
value={cardName}
placeholder="Enter a title for this card..."
/>
</ListCardDetails>
</ListCard>
<ComposerControls>
<ComposerControlsSaveSection>
<AddCardButton onClick={onClick}>Add Card</AddCardButton>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
</ComposerControlsSaveSection>
<ComposerControlsActionsSection />
</ComposerControls>
</CardComposerWrapper>
);
};
CardComposer.propTypes = {
isOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onCreateCard: PropTypes.func.isRequired,
};
CardComposer.defaultProps = {
isOpen: true,
};
export default CardComposer;

View File

@ -0,0 +1,56 @@
import React, { createRef, useState } from 'react';
import styled from 'styled-components';
import { action } from '@storybook/addon-actions';
import DropdownMenu from './index';
export default {
component: DropdownMenu,
title: 'DropdownMenu',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: '#262c49', default: true },
],
},
};
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const Button = styled.div`
font-size: 18px;
padding: 15px 20px;
color: #fff;
background: #000;
`;
export const Default = () => {
const [menu, setMenu] = useState({
top: 0,
left: 0,
isOpen: false,
});
const $buttonRef: any = createRef();
const onClick = () => {
console.log($buttonRef.current.getBoundingClientRect());
setMenu({
isOpen: !menu.isOpen,
left: $buttonRef.current.getBoundingClientRect().right,
top: $buttonRef.current.getBoundingClientRect().bottom,
});
};
return (
<>
<Container>
<Button onClick={onClick} ref={$buttonRef}>
Click me
</Button>
</Container>
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
</>
);
};

View File

@ -0,0 +1,74 @@
import styled from 'styled-components/macro';
export const Container = styled.div<{ left: number; top: number }>`
position: absolute;
left: ${props => props.left}px;
top: ${props => props.top}px;
padding-top: 10px;
position: absolute;
height: auto;
width: auto;
transform: translate(-100%);
transition: opacity 0.25s, transform 0.25s, width 0.3s ease;
z-index: 40000;
`;
export const Wrapper = styled.div`
padding: 5px;
padding-top: 8px;
border-radius: 5px;
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
position: relative;
margin: 0;
color: #c2c6dc;
background: #262c49;
border-color: #414561;
`;
export const WrapperDiamond = styled.div`
top: 10px;
right: 10px;
position: absolute;
width: 10px;
height: 10px;
display: block;
transform: rotate(45deg) translate(-7px);
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
z-index: 10;
background: #262c49;
border-color: #414561;
`;
export const ActionsList = styled.ul`
min-width: 9rem;
margin: 0;
padding: 0;
`;
export const ActionItem = styled.li`
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
}
`;
export const ActionTitle = styled.span`
margin-left: 0.5rem;
`;
export const Separator = styled.div`
height: 1px;
border-top: 1px solid #414561;
margin: 0.25rem !important;
`;

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Exit, User } from 'shared/icons';
import { Separator, Container, WrapperDiamond, Wrapper, ActionsList, ActionItem, ActionTitle } from './Styles';
type DropdownMenuProps = {
left: number;
top: number;
};
const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top }) => {
return (
<Container left={left} top={top}>
<Wrapper>
<ActionItem>
<User size={16} color="#c2c6dc" />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<Separator />
<ActionsList>
<ActionItem>
<Exit size={16} color="#c2c6dc" />
<ActionTitle>Logout</ActionTitle>
</ActionItem>
</ActionsList>
</Wrapper>
<WrapperDiamond />
</Container>
);
};
export default DropdownMenu;

View File

@ -0,0 +1,178 @@
import React, { createRef } from 'react';
import { action } from '@storybook/addon-actions';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import List, { ListCards } from './index';
export default {
component: List,
title: 'List',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData = [
{
labelId: 'development',
name: 'Development',
color: LabelColors.BLUE,
active: false,
},
{
labelId: 'general',
name: 'General',
color: LabelColors.PINK,
active: false,
},
];
const createCard = () => {
const $ref = createRef<HTMLDivElement>();
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Default = () => {
return (
<List
id=""
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen={false}
/>
</ListCards>
</List>
);
};
export const WithCardComposer = () => {
return (
<List
id="1"
name="General"
isComposerOpen
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen
/>
</ListCards>
</List>
);
};
export const WithCard = () => {
const $cardRef: any = createRef();
return (
<List
id="1"
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<Card
cardId="1"
listId="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen={false}
/>
</ListCards>
</List>
);
};
export const WithCardAndComposer = () => {
const $cardRef: any = createRef();
return (
<List
id="1"
name="General"
isComposerOpen
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<Card
cardId="1"
listId="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen
/>
</ListCards>
</List>
);
};

View File

@ -0,0 +1,119 @@
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
export const Container = styled.div`
width: 272px;
margin: 0 4px;
height: 100%;
box-sizing: border-box;
display: inline-block;
vertical-align: top;
white-space: nowrap;
`;
export const AddCardContainer = styled.div`
min-height: 38px;
max-height: 38px;
display: ${props => (props.hidden ? 'none' : 'flex')};
justify-content: space-between;
`;
export const AddCardButton = styled.a`
border-radius: 3px;
color: #5e6c84;
display: flex;
align-items: center;
cursor: pointer;
flex: 1 0 auto;
margin: 2px 8px 8px 8px;
padding: 4px 8px;
position: relative;
text-decoration: none;
user-select: none;
&:hover {
background-color: rgba(9, 30, 66, 0.08);
color: #172b4d;
text-decoration: none;
}
`;
export const Wrapper = styled.div`
// background-color: #ebecf0;
// background: rgb(244, 245, 247);
background: #10163a;
color: #c2c6dc;
border-radius: 5px;
box-sizing: border-box;
display: flex;
flex-direction: column;
max-height: 100%;
position: relative;
white-space: normal;
`;
export const HeaderEditTarget = styled.div<{ isHidden: boolean }>`
cursor: pointer;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: ${props => (props.isHidden ? 'none' : 'block')};
`;
export const HeaderName = styled(TextareaAutosize)`
font-family: 'Droid Sans';
border: none;
resize: none;
overflow: hidden;
overflow-wrap: break-word;
background: transparent;
border-radius: 3px;
box-shadow: none;
font-weight: 600;
margin: -4px 0;
padding: 4px 8px;
letter-spacing: normal;
word-spacing: normal;
text-transform: none;
text-indent: 0px;
text-shadow: none;
flex-direction: column;
text-align: start;
color: #c2c6dc;
`;
export const Header = styled.div<{ isEditing: boolean }>`
flex: 0 0 auto;
padding: 10px 8px;
position: relative;
min-height: 20px;
padding-right: 36px;
${props =>
props.isEditing &&
css`
& ${HeaderName} {
background: #fff;
border: none;
box-shadow: inset 0 0 0 2px #0079bf;
}
`}
`;
export const AddCardButtonText = styled.span`
padding-left: 5px;
font-family: 'Droid Sans';
`;
export const ListCards = styled.div`
margin: 0 4px;
padding: 0 4px;
flex: 1 1 auto;
min-height: 30px;
overflow-y: auto;
overflow-x: hidden;
`;

View File

@ -0,0 +1,101 @@
import React, { useState, useRef } from 'react';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import {
Container,
Wrapper,
Header,
HeaderName,
HeaderEditTarget,
AddCardContainer,
AddCardButton,
AddCardButtonText,
ListCards,
} from './Styles';
type Props = {
children: React.ReactNode;
id: string;
name: string;
onSaveName: (name: string) => void;
isComposerOpen: boolean;
onOpenComposer: (id: string) => void;
tasks: Task[];
wrapperProps?: any;
headerProps?: any;
index?: number;
};
const List = React.forwardRef(
(
{ id, name, onSaveName, isComposerOpen, onOpenComposer, children, wrapperProps, headerProps }: Props,
$wrapperRef: any,
) => {
const [listName, setListName] = useState(name);
const [isEditingTitle, setEditingTitle] = useState(false);
const $listNameRef: any = useRef<HTMLTextAreaElement>();
const onClick = () => {
setEditingTitle(true);
if ($listNameRef) {
$listNameRef.current.select();
}
};
const onBlur = () => {
setEditingTitle(false);
onSaveName(listName);
};
const onEscape = () => {
$listNameRef.current.blur();
};
const onChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
setListName(event.currentTarget.value);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
$listNameRef.current.blur();
}
};
useOnEscapeKeyDown(isEditingTitle, onEscape);
return (
<Container ref={$wrapperRef} {...wrapperProps}>
<Wrapper>
<Header {...headerProps} isEditing={isEditingTitle}>
<HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />
<HeaderName
ref={$listNameRef}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
spellCheck={false}
value={listName}
/>
</Header>
{children && children}
<AddCardContainer hidden={isComposerOpen}>
<AddCardButton onClick={() => onOpenComposer(id)}>
<FontAwesomeIcon icon={faPlus} size="xs" color="#42526e" />
<AddCardButtonText>Add another card</AddCardButtonText>
</AddCardButton>
</AddCardContainer>
</Wrapper>
</Container>
);
},
);
List.defaultProps = {
children: null,
isComposerOpen: false,
wrapperProps: {},
headerProps: {},
};
List.displayName = 'List';
export default List;
export { ListCards };

View File

@ -0,0 +1,184 @@
import React, { useState } from 'react';
import Lists from './index';
import { action } from '@storybook/addon-actions';
export default {
component: Lists,
title: 'Lists',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const initialListsData = {
columns: {
'column-1': {
taskGroupID: 'column-1',
name: 'General',
taskIds: ['task-3', 'task-4', 'task-1', 'task-2'],
position: 1,
tasks: [],
},
'column-2': {
taskGroupID: 'column-2',
name: 'Development',
taskIds: [],
position: 2,
tasks: [],
},
},
tasks: {
'task-1': {
taskID: 'task-1',
taskGroupID: 'column-1',
name: 'Create roadmap',
position: 2,
labels: [],
},
'task-2': {
taskID: 'task-2',
taskGroupID: 'column-1',
position: 1,
name: 'Create authentication',
labels: [],
},
'task-3': {
taskID: 'task-3',
taskGroupID: 'column-1',
position: 3,
name: 'Create login',
labels: [],
},
'task-4': {
taskID: 'task-4',
taskGroupID: 'column-1',
position: 4,
name: 'Create plugins',
labels: [],
},
},
};
export const Default = () => {
const [listsData, setListsData] = useState(initialListsData);
const onCardDrop = (droppedTask: any) => {
console.log(droppedTask);
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.id]: droppedTask,
},
};
console.log(newState);
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.id]: droppedColumn,
},
};
setListsData(newState);
};
return (
<Lists
{...listsData}
onQuickEditorOpen={action('card composer open')}
onCardDrop={onCardDrop}
onListDrop={onListDrop}
onCardCreate={action('card create')}
/>
);
};
const createColumn = (id: any, name: any, position: any) => {
return {
taskGroupID: id,
name,
position,
tasks: [],
};
};
const initialListsDataLarge = {
columns: {
'column-1': createColumn('column-1', 'General', 1),
'column-2': createColumn('column-2', 'General', 2),
'column-3': createColumn('column-3', 'General', 3),
'column-4': createColumn('column-4', 'General', 4),
'column-5': createColumn('column-5', 'General', 5),
'column-6': createColumn('column-6', 'General', 6),
'column-7': createColumn('column-7', 'General', 7),
'column-8': createColumn('column-8', 'General', 8),
'column-9': createColumn('column-9', 'General', 9),
},
tasks: {
'task-1': {
taskID: 'task-1',
taskGroupID: 'column-1',
name: 'Create roadmap',
position: 2,
labels: [],
},
'task-2': {
taskID: 'task-2',
taskGroupID: 'column-1',
position: 1,
name: 'Create authentication',
labels: [],
},
'task-3': {
taskID: 'task-3',
taskGroupID: 'column-1',
position: 3,
name: 'Create login',
labels: [],
},
'task-4': {
taskID: 'task-4',
taskGroupID: 'column-1',
position: 4,
name: 'Create plugins',
labels: [],
},
},
};
export const ListsWithManyList = () => {
const [listsData, setListsData] = useState(initialListsDataLarge);
const onCardDrop = (droppedTask: any) => {
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.id]: droppedTask,
},
};
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.id]: droppedColumn,
},
};
setListsData(newState);
};
return (
<Lists
{...listsData}
onQuickEditorOpen={action('card composer open')}
onCardCreate={action('card create')}
onCardDrop={onCardDrop}
onListDrop={onListDrop}
/>
);
};

View File

@ -0,0 +1,11 @@
import styled from 'styled-components';
export const Container = styled.div`
flex-grow: 1;
user-select: none;
white-space: nowrap;
margin-bottom: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
`;

View File

@ -0,0 +1,196 @@
import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/arrays';
import List, { ListCards } from 'shared/components/List';
import Card from 'shared/components/Card';
import { Container } from './Styles';
import CardComposer from 'shared/components/CardComposer';
const getNewDraggablePosition = (afterDropDraggables: any, draggableIndex: any) => {
const prevDraggable = afterDropDraggables[draggableIndex - 1];
const nextDraggable = afterDropDraggables[draggableIndex + 1];
if (!prevDraggable && !nextDraggable) {
return 1;
}
if (!prevDraggable) {
return nextDraggable.position - 1;
}
if (!nextDraggable) {
return prevDraggable.position + 1;
}
const newPos = (prevDraggable.position + nextDraggable.position) / 2.0;
return newPos;
};
const getSortedDraggables = (draggables: any) => {
return draggables.sort((a: any, b: any) => a.position - b.position);
};
const isPositionChanged = (source: any, destination: any) => {
if (!destination) return false;
const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index;
return !isSameList || !isSamePosition;
};
const getAfterDropDraggableList = (
beforeDropDraggables: any,
droppedDraggable: any,
isList: any,
isSameList: any,
destination: any,
) => {
if (isList) {
return moveItemWithinArray(beforeDropDraggables, droppedDraggable, destination.index);
}
return isSameList
? moveItemWithinArray(beforeDropDraggables, droppedDraggable, destination.index)
: insertItemIntoArray(beforeDropDraggables, droppedDraggable, destination.index);
};
interface Columns {
[key: string]: TaskGroup;
}
interface Tasks {
[key: string]: RemoteTask;
}
type Props = {
columns: Columns;
tasks: Tasks;
onCardDrop: any;
onListDrop: any;
onCardCreate: (taskGroupID: string, name: string) => void;
onQuickEditorOpen: (e: ContextMenuEvent) => void;
};
type OnDragEndProps = {
draggableId: any;
source: any;
destination: any;
type: any;
};
const Lists = ({ columns, tasks, onCardDrop, onListDrop, onCardCreate, onQuickEditorOpen }: Props) => {
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
if (typeof destination === 'undefined') return;
if (!isPositionChanged(source, destination)) return;
const isList = type === 'column';
const isSameList = destination.droppableId === source.droppableId;
const droppedDraggable = isList ? columns[draggableId] : tasks[draggableId];
const beforeDropDraggables = isList
? getSortedDraggables(Object.values(columns))
: getSortedDraggables(Object.values(tasks).filter((t: any) => t.taskGroupID === destination.droppableId));
const afterDropDraggables = getAfterDropDraggableList(
beforeDropDraggables,
droppedDraggable,
isList,
isSameList,
destination,
);
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
if (isList) {
onListDrop({
...droppedDraggable,
position: newPosition,
});
} else {
const newCard = {
...droppedDraggable,
position: newPosition,
taskGroupID: destination.droppableId,
};
onCardDrop(newCard);
}
};
const orderedColumns = getSortedDraggables(Object.values(columns));
const [currentComposer, setCurrentComposer] = useState('');
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="horizontal" type="column" droppableId="root">
{provided => (
<Container {...provided.droppableProps} ref={provided.innerRef}>
{orderedColumns.map((column: TaskGroup, index: number) => {
const columnCards = getSortedDraggables(
Object.values(tasks).filter((t: any) => t.taskGroupID === column.taskGroupID),
);
return (
<Draggable draggableId={column.taskGroupID} key={column.taskGroupID} index={index}>
{columnDragProvided => (
<List
id={column.taskGroupID}
name={column.name}
key={column.taskGroupID}
onOpenComposer={id => setCurrentComposer(id)}
isComposerOpen={currentComposer === column.taskGroupID}
onSaveName={name => console.log(name)}
index={index}
tasks={columnCards}
ref={columnDragProvided.innerRef}
wrapperProps={columnDragProvided.draggableProps}
headerProps={columnDragProvided.dragHandleProps}
>
<Droppable type="tasks" droppableId={column.taskGroupID}>
{columnDropProvided => (
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{columnCards.map((task: RemoteTask, taskIndex: any) => {
return (
<Draggable key={task.taskID} draggableId={task.taskID} index={taskIndex}>
{taskProvided => {
return (
<Card
wrapperProps={{
...taskProvided.draggableProps,
...taskProvided.dragHandleProps,
}}
ref={taskProvided.innerRef}
cardId={task.taskID}
listId={column.taskGroupID}
description=""
title={task.name}
labels={task.labels}
onClick={e => console.log(e)}
onContextMenu={onQuickEditorOpen}
/>
);
}}
</Draggable>
);
})}
{columnDropProvided.placeholder}
{currentComposer === column.taskGroupID && (
<CardComposer
onClose={() => {
setCurrentComposer('');
}}
onCreateCard={name => {
setCurrentComposer('');
onCardCreate(column.taskGroupID, name);
}}
isOpen={true}
/>
)}
</ListCards>
)}
</Droppable>
</List>
)}
</Draggable>
);
})}
{provided.placeholder}
</Container>
)}
</Droppable>
</DragDropContext>
);
};
export default Lists;

View File

@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import styled from 'styled-components';
import Login from './index';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export default {
component: Login,
title: 'Login',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
const LoginWrapper = styled.div`
width: 60%;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Container>
<LoginWrapper>
<Login onSubmit={action('on submit')} />
</LoginWrapper>
</Container>
</>
);
};
export const WithSubmission = () => {
const onSubmit = async (data: LoginFormData, setComplete: (val: boolean) => void, setError: any) => {
await sleep(2000);
if (data.username !== 'test' || data.password !== 'test') {
setError('username', 'invalid', 'Invalid username');
setError('password', 'invalid', 'Invalid password');
}
setComplete(true);
};
return (
<>
<NormalizeStyles />
<BaseStyles />
<Container>
<LoginWrapper>
<Login onSubmit={onSubmit} />
</LoginWrapper>
</Container>
</>
);
};

View File

@ -0,0 +1,103 @@
import styled from 'styled-components';
export const Wrapper = styled.div`
background: #eff2f7;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
`;
export const Column = styled.div`
width: 50%;
display: flex;
justify-content: center;
align-items: center;
`;
export const LoginFormWrapper = styled.div`
background: #10163a;
width: 100%;
`;
export const LoginFormContainer = styled.div`
min-height: 505px;
padding: 2rem;
`;
export const Title = styled.h1`
color: #ebeefd;
font-size: 18px;
margin-bottom: 14px;
`;
export const SubTitle = styled.h2`
color: #c2c6dc;
font-size: 14px;
margin-bottom: 14px;
`;
export const Form = styled.form`
display: flex;
flex-direction: column;
`;
export const FormLabel = styled.label`
color: #c2c6dc;
font-size: 12px;
position: relative;
margin-top: 14px;
`;
export const FormTextInput = styled.input`
width: 100%;
background: #262c49;
border: 1px solid rgba(0, 0, 0, 0.2);
margin-top: 4px;
padding: 0.7rem 1rem 0.7rem 3rem;
font-size: 1rem;
color: #c2c6dc;
border-radius: 5px;
`;
export const FormIcon = styled.div`
top: 30px;
left: 16px;
position: absolute;
`;
export const FormError = styled.span`
font-size: 0.875rem;
color: rgb(234, 84, 85);
`;
export const LoginButton = styled.input`
padding: 0.75rem 2rem;
font-size: 1rem;
border-radius: 6px;
background: rgb(115, 103, 240);
outline: none;
border: none;
cursor: pointer;
color: #fff;
&:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
`;
export const ActionButtons = styled.div`
margin-top: 17.5px;
display: flex;
justify-content: space-between;
`;
export const RegisterButton = styled.button`
padding: 0.679rem 2rem;
border-radius: 6px;
border: 1px solid rgb(115, 103, 240);
background: transparent;
font-size: 1rem;
color: rgba(115, 103, 240);
cursor: pointer;
`;

View File

@ -0,0 +1,81 @@
import React, { useState } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock } from 'shared/icons';
import { useForm } from 'react-hook-form';
import {
Form,
ActionButtons,
RegisterButton,
LoginButton,
FormError,
FormIcon,
FormLabel,
FormTextInput,
Wrapper,
Column,
LoginFormWrapper,
LoginFormContainer,
Title,
SubTitle,
} from './Styles';
const Login = ({ onSubmit }: LoginProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>();
console.log(formState);
const loginSubmit = (data: LoginFormData) => {
setComplete(false);
onSubmit(data, setComplete, setError);
};
return (
<Wrapper>
<Column>
<AccessAccount width={275} height={250} />
</Column>
<Column>
<LoginFormWrapper>
<LoginFormContainer>
<Title>Login</Title>
<SubTitle>Welcome back, please login into your account.</SubTitle>
<Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="username">
Username
<FormTextInput
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="password">
Password
<FormTextInput
type="text"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormIcon>
<Lock color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.password && <FormError>{errors.password.message}</FormError>}
<ActionButtons>
<RegisterButton>Register</RegisterButton>
<LoginButton type="submit" value="Login" disabled={!isComplete} />
</ActionButtons>
</Form>
</LoginFormContainer>
</LoginFormWrapper>
</Column>
</Wrapper>
);
};
export default Login;

View File

@ -0,0 +1,41 @@
import React from 'react';
import styled from 'styled-components';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import { Home, Stack, Users, Question } from 'shared/icons';
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from './index';
export default {
component: Navbar,
title: 'Navbar',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#cdd3e1' },
],
},
};
const MainContent = styled.div`
padding: 0 0 50px 80px;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Navbar>
<PrimaryLogo />
<ButtonContainer>
<ActionButton name="Home">
<Home size={28} color="#c2c6dc" />
</ActionButton>
<ActionButton name="Home">
<Home size={28} color="#c2c6dc" />
</ActionButton>
</ButtonContainer>
</Navbar>
</>
);
};

View File

@ -0,0 +1,105 @@
import styled, { css } from 'styled-components';
export const LogoWrapper = styled.div`
margin: 20px 0px 20px;
position: relative;
width: 100%;
height: 42px;
line-height: 42px;
padding-left: 64px;
color: rgb(222, 235, 255);
cursor: pointer;
user-select: none;
transition: color 0.1s ease 0s;
`;
export const Logo = styled.div`
position: absolute;
left: 19px;
`;
export const LogoTitle = styled.div`
position: relative;
right: 12px;
visibility: hidden;
opacity: 0;
font-size: 24px;
font-weight: 600;
transition: right 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
color: #7367f0;
`;
export const ActionContainer = styled.div`
position: relative;
`;
export const ActionButtonTitle = styled.span`
position: relative;
visibility: hidden;
left: -5px;
opacity: 0;
font-weight: 600;
transition: left 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
font-size: 18px;
color: #c2c6dc;
`;
export const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s ease;
`;
export const ActionButtonContainer = styled.div`
padding: 0 12px;
position: relative;
`;
export const ActionButtonWrapper = styled.div<{ active?: boolean }>`
${props =>
props.active &&
css`
background: rgb(115, 103, 240);
box-shadow: 0 0 10px 1px rgba(115, 103, 240, 0.7);
`}
border-radius: 6px;
cursor: pointer;
padding: 10px 15px;
display: flex;
align-items: center;
&:hover ${ActionButtonTitle} {
transform: translateX(5px);
}
&:hover ${IconWrapper} {
transform: translateX(5px);
}
`;
export const Container = styled.aside`
z-index: 100;
position: fixed;
top: 0px;
left: 0px;
overflow-x: hidden;
height: 100vh;
width: 80px;
transform: translateZ(0px);
background: #10163a;
transition: all 0.1s ease 0s;
&:hover {
width: 260px;
box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px;
}
&:hover ${LogoTitle} {
right: 0px;
visibility: visible;
opacity: 1;
}
&:hover ${ActionButtonTitle} {
left: 15px;
visibility: visible;
opacity: 1;
}
`;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Citadel } from 'shared/icons';
import {
Container,
LogoWrapper,
IconWrapper,
Logo,
LogoTitle,
ActionContainer,
ActionButtonContainer,
ActionButtonWrapper,
ActionButtonTitle,
} from './Styles';
type ActionButtonProps = {
name: string;
active?: boolean;
};
export const ActionButton: React.FC<ActionButtonProps> = ({ name, active, children }) => {
return (
<ActionButtonWrapper active={active ?? false}>
<IconWrapper>{children}</IconWrapper>
<ActionButtonTitle>{name}</ActionButtonTitle>
</ActionButtonWrapper>
);
};
export const ButtonContainer: React.FC = ({ children }) => (
<ActionContainer>
<ActionButtonContainer>{children}</ActionButtonContainer>
</ActionContainer>
);
export const PrimaryLogo = () => {
return (
<LogoWrapper>
<Logo>
<Citadel size={42} />
</Logo>
<LogoTitle>Citadel</LogoTitle>
</LogoWrapper>
);
};
const Navbar: React.FC = ({ children }) => {
return <Container>{children}</Container>;
};
export default Navbar;

View File

@ -0,0 +1,31 @@
import React, { useState } from 'react';
import LabelColors from 'shared/constants/labelColors';
import { Checkmark } from 'shared/icons';
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
type Props = {
label: Label;
onLabelEdit: (labelId: string, labelName: string, color: string) => void;
};
const LabelManager = ({ label, onLabelEdit }: Props) => {
const [currentLabel, setCurrentLabel] = useState('');
return (
<EditLabelForm>
<FieldLabel>Name</FieldLabel>
<FieldName id="labelName" type="text" name="name" value={currentLabel} />
<FieldLabel>Select a color</FieldLabel>
<div>
{Object.values(LabelColors).map(labelColor => (
<LabelBox color={labelColor}>
<Checkmark color="#fff" size={12} />
</LabelBox>
))}
</div>
<div>
<SaveButton type="submit" value="Save" />
<DeleteButton type="submit" value="Delete" />
</div>
</EditLabelForm>
);
};
export default LabelManager;

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { Pencil, Checkmark } from 'shared/icons';
import { LabelSearch, ActiveIcon, Labels, Label, CardLabel, Section, SectionTitle, LabelIcon } from './Styles';
type Props = {
labels?: Label[];
onLabelToggle: (labelId: string) => void;
onLabelEdit: (labelId: string, labelName: string, color: string) => void;
};
const LabelManager = ({ labels, onLabelToggle, onLabelEdit }: Props) => {
const [currentLabel, setCurrentLabel] = useState('');
return (
<>
<LabelSearch type="text" />
<Section>
<SectionTitle>Labels</SectionTitle>
<Labels>
{labels &&
labels.map(label => (
<Label>
<LabelIcon>
<Pencil />
</LabelIcon>
<CardLabel
key={label.labelId}
color={label.color}
active={currentLabel === label.labelId}
onMouseEnter={() => {
setCurrentLabel(label.labelId);
}}
onClick={() => onLabelToggle(label.labelId)}
>
{label.name}
{label.active && (
<ActiveIcon>
<Checkmark color="#fff" />
</ActiveIcon>
)}
</CardLabel>
</Label>
))}
</Labels>
</Section>
</>
);
};
export default LabelManager;

View File

@ -0,0 +1,76 @@
import React, { createRef, useState } from 'react';
import { action } from '@storybook/addon-actions';
import LabelColors from 'shared/constants/labelColors';
import MenuTypes from 'shared/constants/menuTypes';
import PopupMenu from './index';
export default {
component: PopupMenu,
title: 'PopupMenu',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData = [
{
labelId: 'development',
name: 'Development',
color: LabelColors.BLUE,
active: true,
},
{
labelId: 'general',
name: 'General',
color: LabelColors.PINK,
active: false,
},
];
export const LabelsPopup = () => {
const [isPopupOpen, setPopupOpen] = useState(false);
return (
<>
{isPopupOpen && (
<PopupMenu
title="Label"
menuType={MenuTypes.LABEL_MANAGER}
top={10}
onClose={() => setPopupOpen(false)}
left={10}
onLabelEdit={action('label edit')}
onLabelToggle={action('label toggle')}
labels={labelData}
/>
)}
<button type="submit" onClick={() => setPopupOpen(true)}>
Open
</button>
</>
);
};
export const LabelsLabelEditor = () => {
const [isPopupOpen, setPopupOpen] = useState(false);
return (
<>
{isPopupOpen && (
<PopupMenu
title="Change Label"
menuType={MenuTypes.LABEL_EDITOR}
top={10}
onClose={() => setPopupOpen(false)}
left={10}
onLabelEdit={action('label edit')}
onLabelToggle={action('label toggle')}
labels={labelData}
/>
)}
<button type="submit" onClick={() => setPopupOpen(true)}>
Open
</button>
</>
);
};

View File

@ -0,0 +1,251 @@
import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const Container = styled.div<{ top: number; left: number; ref: any }>`
left: ${props => props.left}px;
top: ${props => props.top}px;
background: #fff;
border-radius: 3px;
box-shadow: 0 8px 16px -4px rgba(9, 30, 66, 0.25), 0 0 0 1px rgba(9, 30, 66, 0.08);
display: block;
overflow: hidden;
position: absolute;
width: 304px;
z-index: 70;
&:focus {
outline: none;
border: none;
}
`;
export const Header = styled.div`
height: 40px;
position: relative;
margin-bottom: 8px;
text-align: center;
`;
export const HeaderTitle = styled.span`
box-sizing: border-box;
color: #5e6c84;
display: block;
line-height: 40px;
border-bottom: 1px solid rgba(9, 30, 66, 0.13);
margin: 0 12px;
overflow: hidden;
padding: 0 32px;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 1;
`;
export const Content = styled.div`
max-height: 632px;
overflow-x: hidden;
overflow-y: auto;
padding: 0 12px 12px;
`;
export const LabelSearch = styled.input`
margin: 4px 0 12px;
width: 100%;
background-color: #fafbfc;
border: none;
box-shadow: inset 0 0 0 2px #dfe1e6;
color: #172b4d;
box-sizing: border-box;
border-radius: 3px;
display: block;
line-height: 20px;
padding: 8px 12px;
font-size: 14px;
font-family: 'Droid Sans';
font-weight: 400;
transition-property: background-color, border-color, box-shadow;
transition-duration: 85ms;
transition-timing-function: ease;
`;
export const Section = styled.div`
margin-top: 12px;
`;
export const SectionTitle = styled.h4`
color: #5e6c84;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
line-height: 16px;
margin-top: 16px;
text-transform: uppercase;
`;
export const Labels = styled.ul`
list-style: none;
margin: 0;
padding: 0;
margin-bottom: 8px;
`;
export const Label = styled.li`
padding-right: 36px;
position: relative;
`;
export const CardLabel = styled.span<{ active: boolean; color: string }>`
${props =>
props.active &&
css`
margin-left: 4px;
box-shadow: -8px 0 ${mixin.darken(props.color, 0.15)};
border-radius: 3px;
`}
cursor: pointer;
font-weight: 700;
margin: 0 0 4px;
min-height: 20px;
padding: 6px 12px;
position: relative;
transition: padding 85ms, margin 85ms, box-shadow 85ms;
background-color: ${props => props.color};
color: #fff;
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const CloseButton = styled.div`
padding: 10px 12px 10px 8px;
position: absolute;
top: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
height: 20px;
width: 20px;
cursor: pointer;
`;
export const LabelIcon = styled.div`
border-radius: 3px;
padding: 6px;
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
height: 20px;
font-size: 16px;
line-height: 20px;
width: 20px;
cursor: pointer;
&:hover {
background: rgba(9, 30, 66, 0.08);
}
`;
export const ActiveIcon = styled.div`
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
right: 0;
opacity: 0.85;
font-size: 16px;
line-height: 20px;
width: 20px;
`;
export const EditLabelForm = styled.form`
display: flex;
flex-direction: column;
`;
export const FieldLabel = styled.label`
font-weight: 700;
color: #5e6c84;
font-size: 12px;
line-height: 16px;
margin-top: 12px;
margin-bottom: 4px;
display: block;
`;
export const FieldName = styled.input`
margin: 4px 0 12px;
width: 100%;
background-color: #fafbfc;
border: none;
box-shadow: inset 0 0 0 2px #dfe1e6;
color: #172b4d;
box-sizing: border-box;
border-radius: 3px;
display: block;
line-height: 20px;
margin-bottom: 12px;
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
`;
export const LabelBox = styled.span<{ color: string }>`
float: left;
height: 32px;
margin: 0 8px 8px 0;
padding: 0;
width: 48px;
background-color: ${props => props.color};
border-radius: 4px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
`;
export const SaveButton = styled.input`
background-color: #5aac44;
box-shadow: none;
border: none;
color: #fff;
padding-left: 24px;
padding-right: 24px;
ursor: pointer;
display: inline-block;
font-weight: 400;
line-height: 20px;
margin: 8px 4px 0 0;
padding: 6px 12px;
text-align: center;
border-radius: 3px;
`;
export const DeleteButton = styled.input`
background-color: #cf513d;
box-shadow: none;
border: none;
color: #fff;
cursor: pointer;
display: inline-block;
font-weight: 400;
line-height: 20px;
margin: 8px 4px 0 0;
padding: 6px 12px;
text-align: center;
border-radius: 3px;
float: right;
`;

View File

@ -0,0 +1,49 @@
import React, { useRef } from 'react';
import { Cross } from 'shared/icons';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import MenuTypes from 'shared/constants/menuTypes';
import LabelColors from 'shared/constants/labelColors';
import LabelManager from './LabelManager';
import LabelEditor from './LabelEditor';
import { Container, Header, HeaderTitle, Content, Label, CloseButton } from './Styles';
type Props = {
title: string;
top: number;
left: number;
menuType: number;
labels?: Label[];
onClose: () => void;
onLabelToggle: (labelId: string) => void;
onLabelEdit: (labelId: string, labelName: string, color: string) => void;
};
const PopupMenu = ({ title, menuType, labels, top, left, onClose, onLabelToggle, onLabelEdit }: Props) => {
const $containerRef = useRef();
useOnOutsideClick($containerRef, true, onClose, null);
return (
<Container left={left} top={top} ref={$containerRef}>
<Header>
<HeaderTitle>{title}</HeaderTitle>
<CloseButton onClick={() => onClose()}>
<Cross />
</CloseButton>
</Header>
<Content>
{menuType === MenuTypes.LABEL_MANAGER && (
<LabelManager onLabelEdit={onLabelEdit} onLabelToggle={onLabelToggle} labels={labels} />
)}
{menuType === MenuTypes.LABEL_EDITOR && (
<LabelEditor
onLabelEdit={onLabelEdit}
label={{ active: false, color: LabelColors.GREEN, name: 'General', labelId: 'general' }}
/>
)}
</Content>
</Container>
);
};
export default PopupMenu;

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { action } from '@storybook/addon-actions';
import ProjectGridItem from './';
export default {
component: ProjectGridItem,
title: 'ProjectGridItem',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const projectsData = [
{ taskGroups: [], teamTitle: 'Personal', projectID: 'aaaa', name: 'Citadel', color: '#aa62e3' },
{ taskGroups: [], teamTitle: 'Personal', projectID: 'bbbb', name: 'Editorial Calender', color: '#aa62e3' },
{ taskGroups: [], teamTitle: 'Personal', projectID: 'cccc', name: 'New Blog', color: '#aa62e3' },
];
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
const ProjectsWrapper = styled.div`
width: 60%;
display: flex;
align-items: center;
justify-content: center;
`;
export const Default = () => {
return (
<Container>
<ProjectsWrapper>
{projectsData.map(project => (
<ProjectGridItem project={project} />
))}
</ProjectsWrapper>
</Container>
);
};

View File

@ -0,0 +1,44 @@
import styled from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const ProjectContent = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`;
export const ProjectTitle = styled.span`
font-size: 18px;
font-weight: 700;
transition: transform 0.25s ease;
text-align: center;
`;
export const TeamTitle = styled.span`
margin-top: 5px;
font-size: 14px;
font-weight: normal;
text-align: center;
color: #c2c6dc;
`;
export const ProjectWrapper = styled.div<{ color: string }>`
display: flex;
align-items: center;
padding: 15px 25px;
border-radius: 20px;
${mixin.boxShadowCard}
background: ${props => mixin.darken(props.color, 0.35)};
color: #fff;
cursor: pointer;
margin: 0 10px;
width: 120px;
height: 120px;
align-items: center;
justify-content: center;
transition: transform 0.25s ease;
&:hover {
transform: translateY(-5px);
}
`;

View File

@ -0,0 +1,21 @@
import React from 'react';
import { ProjectWrapper, ProjectContent, ProjectTitle, TeamTitle } from './Styles';
type Props = {
project: Project;
};
const ProjectsList = ({ project }: Props) => {
const color = project.color ?? '#c2c6dc';
return (
<ProjectWrapper color={color}>
<ProjectContent>
<ProjectTitle>{project.name}</ProjectTitle>
<TeamTitle>{project.teamTitle}</TeamTitle>
</ProjectContent>
</ProjectWrapper>
);
};
export default ProjectsList;

View File

@ -0,0 +1,96 @@
import React, { createRef, useState } from 'react';
import { action } from '@storybook/addon-actions';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import List, { ListCards } from 'shared/components/List';
import QuickCardEditor from 'shared/components/QuickCardEditor';
export default {
component: QuickCardEditor,
title: 'QuickCardEditor',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData = [
{
labelId: 'development',
name: 'Development',
color: LabelColors.BLUE,
active: false,
},
{
labelId: 'general',
name: 'General',
color: LabelColors.PINK,
active: false,
},
];
export const Default = () => {
const $cardRef: any = createRef();
const [isEditorOpen, setEditorOpen] = useState(false);
const [top, setTop] = useState(0);
const [left, setLeft] = useState(0);
return (
<>
{isEditorOpen && (
<QuickCardEditor
isOpen={isEditorOpen}
listId="1"
cardId="1"
cardTitle="Hello, world"
onCloseEditor={() => setEditorOpen(false)}
onEditCard={action('edit card')}
onOpenPopup={action('open popup')}
onArchiveCard={action('archive card')}
labels={labelData}
top={top}
left={left}
/>
)}
<List
id="1"
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<Card
cardId="1"
listId="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={e => {
setTop(e.top);
setLeft(e.left);
setEditorOpen(true);
}}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen={false}
/>
</ListCards>
</List>
</>
);
};

View File

@ -0,0 +1,144 @@
import styled, { keyframes } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
export const Wrapper = styled.div<{ open: boolean }>`
background: rgba(0, 0, 0, 0.6);
bottom: 0;
color: #fff;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 30;
visibility: ${props => (props.open ? 'show' : 'hidden')};
`;
export const Container = styled.div<{ top: number; left: number }>`
position: absolute;
width: 256px;
top: ${props => props.top}px;
left: ${props => props.left}px;
`;
export const Editor = styled.div`
background-color: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(9, 30, 66, 0.25);
padding: 6px 8px 2px;
cursor: default;
display: block;
margin-bottom: 8px;
max-width: 300px;
min-height: 20px;
position: relative;
text-decoration: none;
z-index: 1;
`;
export const EditorDetails = styled.div`
overflow: hidden;
padding: 0;
position: relative;
z-index: 10;
`;
export const EditorTextarea = styled(TextareaAutosize)`
font-family: 'Droid Sans';
overflow: hidden;
overflow-wrap: break-word;
resize: none;
height: 54px;
width: 100%;
background: none;
border: none;
box-shadow: none;
margin-bottom: 4px;
max-height: 162px;
min-height: 54px;
padding: 0;
font-size: 16px;
line-height: 20px;
&:focus {
border: none;
outline: none;
}
`;
export const SaveButton = styled.button`
cursor: pointer;
background-color: #5aac44;
box-shadow: none;
border: none;
color: #fff;
font-weight: 400;
line-height: 20px;
margin-top: 8px;
margin-right: 4px;
padding: 6px 24px;
text-align: center;
border-radius: 3px;
`;
export const FadeInAnimation = keyframes`
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
`;
export const EditorButtons = styled.div`
left: 100%;
position: absolute;
top: 0;
width: 240px;
z-index: 0;
animation: ${FadeInAnimation} 85ms ease-in 1;
`;
export const EditorButton = styled.div`
cursor: pointer;
background: rgba(0, 0, 0, 0.6);
border-radius: 3px;
clear: both;
color: #e6e6e6;
display: block;
float: left;
margin: 0 0 4px 8px;
padding: 6px 12px 6px 8px;
text-decoration: none;
transition: transform 85ms ease-in;
`;
export const CloseButton = styled.div`
padding: 9px;
position: absolute;
right: 0;
top: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
opacity: 0.8;
z-index: 40;
cursor: pointer;
`;
export const ListCardLabels = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span`
height: 16px;
line-height: 16px;
padding: 0 8px;
max-width: 198px;
float: left;
font-size: 12px;
font-weight: 700;
margin: 0 4px 4px 0;
width: auto;
border-radius: 4px;
color: #fff;
display: block;
position: relative;
background-color: ${props => props.color};
`;

View File

@ -0,0 +1,119 @@
import React, { useRef, useState, useEffect } from 'react';
import Cross from 'shared/icons/Cross';
import {
Wrapper,
Container,
Editor,
EditorDetails,
EditorTextarea,
SaveButton,
EditorButtons,
EditorButton,
CloseButton,
ListCardLabels,
ListCardLabel,
} from './Styles';
type Props = {
listId: string;
cardId: string;
cardTitle: string;
onCloseEditor: () => void;
onEditCard: (listId: string, cardId: string, cardName: string) => void;
onOpenPopup: (popupType: number, top: number, left: number) => void;
onArchiveCard: (listId: string, cardId: string) => void;
labels?: Label[];
isOpen: boolean;
top: number;
left: number;
};
const QuickCardEditor = ({
listId,
cardId,
cardTitle,
onCloseEditor,
onOpenPopup,
onArchiveCard,
onEditCard,
labels,
isOpen,
top,
left,
}: Props) => {
const [currentCardTitle, setCardTitle] = useState(cardTitle);
const $editorRef: any = useRef();
const $labelsRef: any = useRef();
useEffect(() => {
$editorRef.current.focus();
$editorRef.current.select();
}, []);
const handleCloseEditor = (e: any) => {
e.stopPropagation();
onCloseEditor();
};
const handleKeyDown = (e: any) => {
if (e.key === 'Enter') {
e.preventDefault();
onEditCard(listId, cardId, currentCardTitle);
onCloseEditor();
}
};
return (
<Wrapper onClick={handleCloseEditor} open={isOpen}>
<CloseButton onClick={handleCloseEditor}>
<Cross size={16} color="#000" />
</CloseButton>
<Container left={left} top={top}>
<Editor>
<ListCardLabels>
{labels &&
labels.map(label => (
<ListCardLabel color={label.color} key={label.name}>
{label.name}
</ListCardLabel>
))}
</ListCardLabels>
<EditorDetails>
<EditorTextarea
onChange={e => setCardTitle(e.currentTarget.value)}
onClick={e => {
e.stopPropagation();
}}
onKeyDown={handleKeyDown}
value={currentCardTitle}
ref={$editorRef}
/>
</EditorDetails>
</Editor>
<SaveButton onClick={e => onEditCard(listId, cardId, currentCardTitle)}>Save</SaveButton>
<EditorButtons>
<EditorButton
ref={$labelsRef}
onClick={e => {
e.stopPropagation();
const pos = $labelsRef.current.getBoundingClientRect();
onOpenPopup(1, pos.top + $labelsRef.current.clientHeight + 4, pos.left);
}}
>
Edit Labels
</EditorButton>
<EditorButton
onClick={e => {
e.stopPropagation();
onArchiveCard(listId, cardId);
onCloseEditor();
}}
>
Archive
</EditorButton>
</EditorButtons>
</Container>
</Wrapper>
);
};
export default QuickCardEditor;

View File

@ -0,0 +1,29 @@
import React from 'react';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import Sidebar from './index';
import Navbar from 'shared/components/Navbar';
export default {
component: Sidebar,
title: 'Sidebar',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Navbar />
<Sidebar />
</>
);
};

View File

@ -0,0 +1,15 @@
import styled from 'styled-components';
export const Container = styled.div`
position: fixed;
z-index: 99;
top: 0px;
left: 80px;
height: 100vh;
width: 230px;
overflow-x: hidden;
overflow-y: auto;
padding: 0px 16px 24px;
background: rgb(244, 245, 247);
border-right: 1px solid rgb(223, 225, 230);
`;

View File

@ -0,0 +1,9 @@
import React from 'react';
import { Container } from './Styles';
const Sidebar = () => {
return <Container></Container>;
};
export default Sidebar;

View File

@ -0,0 +1,70 @@
import styled from 'styled-components';
export const NavbarWrapper = styled.div`
height: 103px;
padding: 1.3rem 2.2rem 2.2rem;
width: 100%;
`;
export const NavbarHeader = styled.header`
border-radius: 0.5rem;
padding: 0.8rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
background: rgb(16, 22, 58);
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.05);
`;
export const Breadcrumbs = styled.div`
color: rgb(94, 108, 132);
font-size: 15px;
`;
export const BreadcrumpSeparator = styled.span`
position: relative;
top: 2px;
font-size: 18px;
margin: 0px 10px;
`;
export const ProjectActions = styled.div``;
export const GlobalActions = styled.div`
display: flex;
align-items: center;
`;
export const ProfileContainer = styled.div`
display: flex;
align-items: center;
`;
export const ProfileNameWrapper = styled.div`
text-align: right;
line-height: 1.25;
`;
export const NotificationContainer = styled.div`
margin-right: 20px;
cursor: pointer;
`;
export const ProfileNamePrimary = styled.div`
color: #c2c6dc;
font-weight: 600;
`;
export const ProfileNameSecondary = styled.small`
color: #c2c6dc;
`;
export const ProfileIcon = styled.div`
margin-left: 10px;
width: 40px;
height: 40px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
background: rgb(115, 103, 240);
cursor: pointer;
`;

View File

@ -0,0 +1,46 @@
import React, { createRef, useState } from 'react';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import TopNavbar from './index';
import { action } from '@storybook/addon-actions';
import DropdownMenu from 'shared/components/DropdownMenu';
export default {
component: TopNavbar,
title: 'TopNavbar',
// Our exports that end in "Data" are not stories.
excludeStories: /.*Data$/,
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: '#262c49', default: true },
],
},
};
export const Default = () => {
const [menu, setMenu] = useState({
top: 0,
left: 0,
isOpen: false,
});
const onClick = (bottom: number, right: number) => {
setMenu({
isOpen: !menu.isOpen,
left: right,
top: bottom,
});
};
return (
<>
<NormalizeStyles />
<BaseStyles />
<TopNavbar onNotificationClick={action('notifications click')} onProfileClick={onClick} />
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
</>
);
};

View File

@ -0,0 +1,61 @@
import React, { useRef } from 'react';
import { Bell } from 'shared/icons';
import {
NotificationContainer,
GlobalActions,
ProjectActions,
NavbarWrapper,
NavbarHeader,
Breadcrumbs,
BreadcrumpSeparator,
ProfileIcon,
ProfileContainer,
ProfileNameWrapper,
ProfileNamePrimary,
ProfileNameSecondary,
} from './Styles';
type NavBarProps = {
onProfileClick: (bottom: number, right: number) => void;
onNotificationClick: () => void;
};
const NavBar: React.FC<NavBarProps> = ({ onProfileClick, onNotificationClick }) => {
const $profileRef: any = useRef(null);
const handleProfileClick = () => {
console.log('click');
const boundingRect = $profileRef.current.getBoundingClientRect();
onProfileClick(boundingRect.bottom, boundingRect.right);
};
return (
<NavbarWrapper>
<NavbarHeader>
<ProjectActions>
<Breadcrumbs>
Projects
<BreadcrumpSeparator>/</BreadcrumpSeparator>
project name
<BreadcrumpSeparator>/</BreadcrumpSeparator>
Board
</Breadcrumbs>
</ProjectActions>
<GlobalActions>
<NotificationContainer onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
</NotificationContainer>
<ProfileContainer>
<ProfileNameWrapper>
<ProfileNamePrimary>Jordan Knott</ProfileNamePrimary>
<ProfileNameSecondary>Manager</ProfileNameSecondary>
</ProfileNameWrapper>
<ProfileIcon ref={$profileRef} onClick={handleProfileClick}>
JK
</ProfileIcon>
</ProfileContainer>
</GlobalActions>
</NavbarHeader>
</NavbarWrapper>
);
};
export default NavBar;

View File

@ -0,0 +1,13 @@
const KeyCodes = {
TAB: 9,
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
ARROW_LEFT: 37,
ARROW_UP: 38,
ARROW_RIGHT: 39,
ARROW_DOWN: 40,
M: 77,
};
export default KeyCodes;

View File

@ -0,0 +1,14 @@
const LabelColors = {
GREEN: '#61bd4f',
YELLOW: '#f2d600',
ORANGE: '#ff9f1a',
RED: '#eb5a46',
PURPLE: '#c377e0',
BLUE: '#0079bf',
SKY: '#00c2e0',
LIME: '#51e898',
PINK: '#ff78cb',
BLACK: '#344563',
};
export default LabelColors;

View File

@ -0,0 +1,6 @@
const MenuTypes = {
LABEL_MANAGER: 1,
LABEL_EDITOR: 2,
};
export default MenuTypes;

View File

@ -0,0 +1,14 @@
import { useRef } from 'react';
import { isEqual } from 'lodash';
const useDeepCompareMemoize = (value: any) => {
const valueRef = useRef();
if (!isEqual(value, valueRef.current)) {
valueRef.current = value;
}
return valueRef.current;
};
export default useDeepCompareMemoize;

View File

@ -0,0 +1,19 @@
import { useEffect } from 'react';
import KeyCodes from 'shared/constants/keyCodes';
const useOnEscapeKeyDown = (isListening: boolean, onEscapeKeyDown: () => void) => {
useEffect(() => {
const handleKeyDown = (event: any) => {
if (event.keyCode === KeyCodes.ESCAPE) {
onEscapeKeyDown();
}
};
if (isListening) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isListening, onEscapeKeyDown]);
};
export default useOnEscapeKeyDown;

View File

@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';
const useOnOutsideClick = (
$ignoredElementRefs: any,
isListening: boolean,
onOutsideClick: () => void,
$listeningElementRef: any,
) => {
const $mouseDownTargetRef = useRef();
const $ignoredElementRefsMemoized = [$ignoredElementRefs].flat();
useEffect(() => {
const handleMouseDown = (event: any) => {
$mouseDownTargetRef.current = event.target;
};
const handleMouseUp = (event: any) => {
if (typeof $ignoredElementRefsMemoized !== 'undefined') {
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
($elementRef: any) =>
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target),
);
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
onOutsideClick();
}
}
};
const $listeningElement = ($listeningElementRef || {}).current || document;
if (isListening) {
$listeningElement.addEventListener('mousedown', handleMouseDown);
$listeningElement.addEventListener('mouseup', handleMouseUp);
}
return () => {
$listeningElement.removeEventListener('mousedown', handleMouseDown);
$listeningElement.removeEventListener('mouseup', handleMouseUp);
};
}, [$ignoredElementRefsMemoized, $listeningElementRef, isListening, onOutsideClick]);
};
export default useOnOutsideClick;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Bell = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M16.023 12.5c0-4.5-4-3.5-4-7 0-0.29-0.028-0.538-0.079-0.749-0.263-1.766-1.44-3.183-2.965-3.615 0.014-0.062 0.021-0.125 0.021-0.191 0-0.52-0.45-0.945-1-0.945s-1 0.425-1 0.945c0 0.065 0.007 0.129 0.021 0.191-1.71 0.484-2.983 2.208-3.020 4.273-0.001 0.030-0.001 0.060-0.001 0.091 0 3.5-4 2.5-4 7 0 1.191 2.665 2.187 6.234 2.439 0.336 0.631 1.001 1.061 1.766 1.061s1.43-0.43 1.766-1.061c3.568-0.251 6.234-1.248 6.234-2.439 0-0.004-0-0.007-0-0.011l0.024 0.011zM12.91 13.345c-0.847 0.226-1.846 0.389-2.918 0.479-0.089-1.022-0.947-1.824-1.992-1.824s-1.903 0.802-1.992 1.824c-1.072-0.090-2.071-0.253-2.918-0.479-1.166-0.311-1.724-0.659-1.928-0.845 0.204-0.186 0.762-0.534 1.928-0.845 1.356-0.362 3.1-0.561 4.91-0.561s3.554 0.199 4.91 0.561c1.166 0.311 1.724 0.659 1.928 0.845-0.204 0.186-0.762 0.534-1.928 0.845z" />
</svg>
);
};
Bell.defaultProps = {
size: 16,
color: '#000',
};
export default Bell;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Checkmark = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M13.5 2l-7.5 7.5-3.5-3.5-2.5 2.5 6 6 10-10z" />
</svg>
);
};
Checkmark.defaultProps = {
size: 16,
color: '#000',
};
export default Checkmark;

View File

@ -0,0 +1,30 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Citadel = ({ size, color }: Props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 12.7 12.7">
<g transform="translate(-.26 -24.137) scale(.1249)">
<path
d="M50.886 286.515l-40.4-44.46 44.459-40.401 40.401 44.46z"
fill="none"
stroke={color}
strokeWidth="11.90597031"
/>
<circle cx="52.917" cy="244.083" r="11.025" fill={color} />
</g>
</svg>
);
};
Citadel.defaultProps = {
size: 16,
color: '#7367f0',
};
export default Citadel;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Cross = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M15.854 12.854c-0-0-0-0-0-0l-4.854-4.854 4.854-4.854c0-0 0-0 0-0 0.052-0.052 0.090-0.113 0.114-0.178 0.066-0.178 0.028-0.386-0.114-0.529l-2.293-2.293c-0.143-0.143-0.351-0.181-0.529-0.114-0.065 0.024-0.126 0.062-0.178 0.114 0 0-0 0-0 0l-4.854 4.854-4.854-4.854c-0-0-0-0-0-0-0.052-0.052-0.113-0.090-0.178-0.114-0.178-0.066-0.386-0.029-0.529 0.114l-2.293 2.293c-0.143 0.143-0.181 0.351-0.114 0.529 0.024 0.065 0.062 0.126 0.114 0.178 0 0 0 0 0 0l4.854 4.854-4.854 4.854c-0 0-0 0-0 0-0.052 0.052-0.090 0.113-0.114 0.178-0.066 0.178-0.029 0.386 0.114 0.529l2.293 2.293c0.143 0.143 0.351 0.181 0.529 0.114 0.065-0.024 0.126-0.062 0.178-0.114 0-0 0-0 0-0l4.854-4.854 4.854 4.854c0 0 0 0 0 0 0.052 0.052 0.113 0.090 0.178 0.114 0.178 0.066 0.386 0.029 0.529-0.114l2.293-2.293c0.143-0.143 0.181-0.351 0.114-0.529-0.024-0.065-0.062-0.126-0.114-0.178z" />
</svg>
);
};
Cross.defaultProps = {
size: 16,
color: '#000',
};
export default Cross;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Exit = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M12 10v-2h-5v-2h5v-2l3 3zM11 9v4h-5v3l-6-3v-13h11v5h-1v-4h-8l4 2v9h4v-3z" />
</svg>
);
};
Exit.defaultProps = {
size: 16,
color: '#000',
};
export default Exit;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Home = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M16 9.226l-8-6.21-8 6.21v-2.532l8-6.21 8 6.21zM14 9v6h-4v-4h-4v4h-4v-6l6-4.5z" />
</svg>
);
};
Home.defaultProps = {
size: 16,
color: '#000',
};
export default Home;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Lock = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M9.25 7h-0.25v-3c0-1.654-1.346-3-3-3h-2c-1.654 0-3 1.346-3 3v3h-0.25c-0.412 0-0.75 0.338-0.75 0.75v7.5c0 0.412 0.338 0.75 0.75 0.75h8.5c0.412 0 0.75-0.338 0.75-0.75v-7.5c0-0.412-0.338-0.75-0.75-0.75zM3 4c0-0.551 0.449-1 1-1h2c0.551 0 1 0.449 1 1v3h-4v-3z" />
</svg>
);
};
Lock.defaultProps = {
size: 16,
color: '#000',
};
export default Lock;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Pencil = ({ size, color }: Props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill={color} width={size} height={size} viewBox="0 0 16 16">
<path d="M13.5 0c1.381 0 2.5 1.119 2.5 2.5 0 0.563-0.186 1.082-0.5 1.5l-1 1-3.5-3.5 1-1c0.418-0.314 0.937-0.5 1.5-0.5zM1 11.5l-1 4.5 4.5-1 9.25-9.25-3.5-3.5-9.25 9.25zM11.181 5.681l-7 7-0.862-0.862 7-7 0.862 0.862z" />
</svg>
);
};
Pencil.defaultProps = {
size: 16,
color: '#000',
};
export default Pencil;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Question = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M7 11h2v2h-2zM11 4c0.552 0 1 0.448 1 1v3l-3 2h-2v-1l3-2v-1h-5v-2h6zM8 1.5c-1.736 0-3.369 0.676-4.596 1.904s-1.904 2.86-1.904 4.596c0 1.736 0.676 3.369 1.904 4.596s2.86 1.904 4.596 1.904c1.736 0 3.369-0.676 4.596-1.904s1.904-2.86 1.904-4.596c0-1.736-0.676-3.369-1.904-4.596s-2.86-1.904-4.596-1.904zM8 0v0c4.418 0 8 3.582 8 8s-3.582 8-8 8c-4.418 0-8-3.582-8-8s3.582-8 8-8z" />
</svg>
);
};
Question.defaultProps = {
size: 16,
color: '#000',
};
export default Question;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Stack = ({ size, color }: Props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill={color} width={size} height={size} viewBox="0 0 16 16">
<path d="M16 5l-8-4-8 4 8 4 8-4zM8 2.328l5.345 2.672-5.345 2.672-5.345-2.672 5.345-2.672zM14.398 7.199l1.602 0.801-8 4-8-4 1.602-0.801 6.398 3.199zM14.398 10.199l1.602 0.801-8 4-8-4 1.602-0.801 6.398 3.199z" />
</svg>
);
};
Stack.defaultProps = {
size: 16,
color: '#000',
};
export default Stack;

View File

@ -0,0 +1,21 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const User = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M9 11.041v-0.825c1.102-0.621 2-2.168 2-3.716 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h14c0-2.015-2.608-3.682-6-3.959z" />
</svg>
);
};
User.defaultProps = {
size: 16,
color: '#000',
};
export default User;

View File

@ -0,0 +1,22 @@
import React from 'react';
type Props = {
size: number | string;
color: string;
};
const Users = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M12 12.041v-0.825c1.102-0.621 2-2.168 2-3.716 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h14c0-2.015-2.608-3.682-6-3.959z" />
<path d="M5.112 12.427c0.864-0.565 1.939-0.994 3.122-1.256-0.235-0.278-0.449-0.588-0.633-0.922-0.475-0.863-0.726-1.813-0.726-2.748 0-1.344 0-2.614 0.478-3.653 0.464-1.008 1.299-1.633 2.488-1.867-0.264-1.195-0.968-1.98-2.841-1.98-3 0-3 2.015-3 4.5 0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h4.359c0.227-0.202 0.478-0.393 0.753-0.573z" />
</svg>
);
};
Users.defaultProps = {
size: 16,
color: '#000',
};
export default Users;

View File

@ -0,0 +1,14 @@
import Cross from './Cross';
import Bell from './Bell';
import Pencil from './Pencil';
import Checkmark from './Checkmark';
import User from './User';
import Users from './Users';
import Lock from './Lock';
import Citadel from './Citadel';
import Home from './Home';
import Stack from './Stack';
import Question from './Question';
import Exit from './Exit';
export { Cross, Bell, Exit, Pencil, Stack, Question, Home, Citadel, Checkmark, User, Users, Lock };

View File

@ -0,0 +1,134 @@
import React from 'react';
type Props = {
width: number;
height: number;
};
const AccessAccount = ({ width, height }: Props) => {
return (
<svg
id="a9a7ffe7-bffb-40a8-a3c8-a3664a9c484c"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 796 711.7711"
>
<title>access_account</title>
<path
d="M299.079,648.56106l-8.89026-35.06486a455.3229,455.3229,0,0,0-48.30717-17.33113L240.759,612.46113l-4.55175-17.95328C215.84943,588.69462,202,586.134,202,586.134s18.70738,71.13842,57.94476,125.52465l45.72014,8.031-35.51871,5.12114a184.211,184.211,0,0,0,15.888,16.83723c57.07929,52.9818,120.65488,77.29013,142.00008,54.29413s-7.623-84.58813-64.70233-137.56993c-17.69515-16.42488-39.924-29.6057-62.175-39.97928Z"
transform="translate(-202 -94.11445)"
fill="#e6e6e6"
/>
<path
d="M383.63224,610.48142l10.51462-34.61248a455.32041,455.32041,0,0,0-32.39463-39.80627l-9.3844,13.36992L357.7514,531.711c-14.42234-15.49938-24.95448-24.85018-24.95448-24.85018s-20.75719,70.56756-15.28054,137.40647L352.50363,674.775l-33.05275-13.97575a184.2128,184.2128,0,0,0,4.89768,22.626c21.47608,74.85917,63.33463,128.5305,93.49375,119.87826s37.19806-76.3516,15.722-151.21077c-6.6578-23.20708-18.87351-45.98058-32.55921-66.36238Z"
transform="translate(-202 -94.11445)"
fill="#e6e6e6"
/>
<path
d="M884.17981,752.05584l6.61544-7.14478a122.56157,122.56157,0,0,0-3.1639-13.4473l-3.84424,2.134,3.38713-3.65813c-1.66992-5.44865-3.12076-8.95111-3.12076-8.95111s-13.32284,14.64664-19.85509,31.47479l4.88491,11.50059-6.36018-7.27012a49.58586,49.58586,0,0,0-1.47426,6.05443c-3.60112,20.65124.22421,38.56849,8.54414,40.0193s17.98384-14.1142,21.585-34.76544a65.28076,65.28076,0,0,0-.08151-19.8969Z"
transform="translate(-202 -94.11445)"
fill="#e6e6e6"
/>
<path
d="M982.04942,766.18571l9.35626-2.69673a122.55844,122.55844,0,0,0,4.24249-13.14691l-4.39392-.16027,4.79042-1.38071C997.43156,743.27362,998,739.52541,998,739.52541s-18.97582,5.65159-33.26621,16.68071l-1.763,12.37-1.68666-9.51114a49.58626,49.58626,0,0,0-4.39158,4.42083c-13.75737,15.817-19.74417,33.13225-13.37186,38.67479s22.69062-2.78652,36.448-18.60348a65.281,65.281,0,0,0,10.215-17.07476Z"
transform="translate(-202 -94.11445)"
fill="#e6e6e6"
/>
<path
d="M720.49687,142.406V754.56957a48.30136,48.30136,0,0,1-48.29157,48.29169H434.31173A48.30567,48.30567,0,0,1,386,754.56957V142.406a48.30564,48.30564,0,0,1,48.31173-48.29157H463.1698a22.96636,22.96636,0,0,0,21.246,31.61713h135.6313A22.96611,22.96611,0,0,0,641.293,94.11445H672.2053A48.30134,48.30134,0,0,1,720.49687,142.406Z"
transform="translate(-202 -94.11445)"
fill="#3f3d56"
/>
<path
d="M519.72822,347.655a23.87666,23.87666,0,0,1,11.9461-20.68789,23.89222,23.89222,0,1,0,0,41.37573A23.87652,23.87652,0,0,1,519.72822,347.655Z"
transform="translate(-202 -94.11445)"
fill="#fff"
/>
<path
d="M549.76412,347.655a23.87668,23.87668,0,0,1,11.94609-20.68789,23.89222,23.89222,0,1,0,0,41.37573A23.87653,23.87653,0,0,1,549.76412,347.655Z"
transform="translate(-202 -94.11445)"
fill="#fff"
/>
<circle cx="377.11738" cy="253.54057" r="23.89219" fill="#6c63ff" />
<rect x="244.57422" y="409.90405" width="213.56445" height="2" fill="#fff" />
<circle cx="251.31877" cy="390.67147" r="6.74414" fill="#6c63ff" />
<rect x="244.57422" y="477.34545" width="213.56445" height="2" fill="#fff" />
<circle cx="251.31877" cy="458.1129" r="6.74414" fill="#6c63ff" />
<path
d="M619.0459,422.27875H479.79883a5.00588,5.00588,0,0,1-5-5V278.03168a5.00589,5.00589,0,0,1,5-5H619.0459a5.00589,5.00589,0,0,1,5,5V417.27875A5.00589,5.00589,0,0,1,619.0459,422.27875ZM479.79883,275.03168a3.00328,3.00328,0,0,0-3,3V417.27875a3.00328,3.00328,0,0,0,3,3H619.0459a3.00328,3.00328,0,0,0,3-3V278.03168a3.00328,3.00328,0,0,0-3-3Z"
transform="translate(-202 -94.11445)"
fill="#fff"
/>
<rect x="382.82955" y="522.18225" width="75.30959" height="31.47267" rx="4" fill="#6c63ff" />
<rect x="0.79492" y="707.76733" width="795.20508" height="2" fill="#3f3d56" />
<path
d="M846.52086,419.196s-2.76836,17.533,0,20.30134-17.533,25.838-17.533,25.838l-16.61018-23.06969s7.38231-11.99624,4.61394-22.14691Z"
transform="translate(-202 -94.11445)"
fill="#ffb8b8"
/>
<polygon
points="583.621 457.039 571.62 585.306 576.23 684.967 598.377 678.508 599.304 588.998 625.145 511.485 640.829 595.459 638.98 680.355 666.664 681.279 668.513 587.155 671.756 455.695 583.621 457.039"
fill="#2f2e41"
/>
<path
d="M841.90692,771.70088v20.30133S837.293,806.76682,852.05759,805.844s13.84181-7.3823,13.84181-7.3823l-5.53672-24.91527Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
<path
d="M799.45869,771.70088v20.30133s4.61393,14.76461-10.15067,13.84182-13.84182-7.3823-13.84182-7.3823l5.53673-24.91527Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
<circle cx="628.83347" cy="314.00805" r="21.22412" fill="#ffb8b8" />
<path
d="M829.91068,455.18468l1.84558-9.22788h4.61394l8.9225-12.83445,7.68768,7.29772,1.84557,43.371H804.99541l4.61394-46.13939,6.71934-4.52936s-1.18261,15.60281,11.73642,15.60281Z"
transform="translate(-202 -94.11445)"
fill="#575a89"
/>
<path
d="M810.53214,445.034s7.64172,9.67444,19.04686,8.98977,22.47859-9.91256,22.47859-9.91256L867.745,564.07363s-17.533,1.84558-23.06969-7.3823l-42.44824-.92279.92279-111.65732Z"
transform="translate(-202 -94.11445)"
fill="#d0cde1"
/>
<path
d="M816.762,431.5308l-40.373,19.96273,10.15067,57.21284s3.69115,21.22412,0,29.52921-7.38231,63.67235-7.38231,63.67235,41.52545,4.61394,35.98873-60.904S816.762,431.5308,816.762,431.5308Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
<path
d="M792.07638,569.61036l5.53673,5.53673s16.61018,28.60642,23.99248,18.45575-12.919-27.68363-12.919-27.68363l-9.22787-7.3823Z"
transform="translate(-202 -94.11445)"
fill="#ffb8b8"
/>
<path
d="M845.31375,431.5308l39.9642,19.96273-13.84182,68.28629s.92279,22.14691,5.53673,34.14315,2.76836,47.06217,2.76836,47.06217-5.53673,21.22412-17.533-31.37478S845.31375,431.5308,845.31375,431.5308Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
<path
d="M876.05007,536.39H867.745s-27.68363-3.69115-25.83806,7.3823,27.68364,8.30509,27.68364,8.30509l10.15066-.92278Z"
transform="translate(-202 -94.11445)"
fill="#ffb8b8"
/>
<path
d="M781.92572,448.72516l-6.45952,2.76837s-6.45951,13.84181-7.3823,18.45575S756.08766,529.93049,758.856,536.39s30.452,40.60266,30.452,40.60266l15.68739-16.61018L781.92572,529.0077l6.45951-39.67988Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
<path
d="M871.43613,449.648l12.20966,1.03029,2.55495.81529s25.838,70.13187,20.30133,81.20532-29.52921,31.37478-29.52921,31.37478l-6.45952-28.60642,11.99624-11.07345-11.99624-36.91151Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
<path
d="M848.79006,391.757s5.55251-10.41917-6.663-11.36636c0,0-11.105-6.63038-19.989.94719,0,0-7.77351-1.89439-9.99452,3.78879,0,0-1.1105-2.84159,2.221-4.736,0,0-7.77352-1.8944-7.77352,7.57757,0,0-3.3315,9.472,0,17.99674s4.442,9.472,4.442,9.472-5.47461-17.87294,7.85141-18.82013,28.2399-9.12218,29.3504,1.297,3.33151,13.26075,3.33151,13.26075S861.56084,396.01938,848.79006,391.757Z"
transform="translate(-202 -94.11445)"
fill="#2f2e41"
/>
</svg>
);
};
export default AccessAccount;

View File

@ -0,0 +1,17 @@
let accessToken = '';
export function setAccessToken(newToken: string) {
accessToken = newToken;
}
export function getAccessToken() {
return accessToken;
}
export async function getNewToken() {
return fetch('http://localhost:3333/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(x => {
return x.json();
});
}

View File

@ -0,0 +1,22 @@
export const moveItemWithinArray = (arr: any, item: any, newIndex: number) => {
const arrClone = [...arr];
const oldIndex = arrClone.indexOf(item);
arrClone.splice(newIndex, 0, arrClone.splice(oldIndex, 1)[0]);
return arrClone;
};
export const insertItemIntoArray = (arr: any, item: any, index: number) => {
const arrClone = [...arr];
arrClone.splice(index, 0, item);
return arrClone;
};
export const updateArrayItemById = (arr: any, itemId: any, fields: any) => {
const arrClone = [...arr];
const item = arrClone.find(({ id }) => id === itemId);
if (item) {
const itemIndex = arrClone.indexOf(item);
arrClone.splice(itemIndex, 1, { ...item, ...fields });
}
return arrClone;
};

View File

@ -0,0 +1,107 @@
import { css } from 'styled-components';
import Color from 'color';
export const color = {
primary: '#0052cc', // Blue
success: '#0B875B', // green
danger: '#E13C3C', // red
warning: '#F89C1C', // orange
secondary: '#F4F5F7', // light grey
textDarkest: '#172b4d',
textDark: '#42526E',
textMedium: '#5E6C84',
textLight: '#8993a4',
textLink: '#0052cc',
backgroundDarkPrimary: '#0747A6',
backgroundMedium: '#dfe1e6',
backgroundLight: '#ebecf0',
backgroundLightest: '#F4F5F7',
backgroundLightPrimary: '#D2E5FE',
backgroundLightSuccess: '#E4FCEF',
borderLightest: '#dfe1e6',
borderLight: '#C1C7D0',
borderInputFocus: '#4c9aff',
};
export const font = {
regular: 'font-family: "Droid Sans"; font-weight: normal;',
size: (size: number) => `font-size: ${size}px;`,
bold: 'font-family: "Droid Sans"; font-weight: normal;',
medium: 'font-family: "Droid Sans"; font-weight: normal;',
};
export const mixin = {
darken: (colorValue: string, amount: number) =>
Color(colorValue)
.darken(amount)
.string(),
lighten: (colorValue: string, amount: number) =>
Color(colorValue)
.lighten(amount)
.string(),
rgba: (colorValue: string, opacity: number) =>
Color(colorValue)
.alpha(opacity)
.string(),
boxShadowCard: css`
box-shadow: rgba(9, 30, 66, 0.25) 0px 1px 2px 0px;
`,
boxShadowMedium: css`
box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
`,
boxShadowDropdown: css`
box-shadow: rgba(9, 30, 66, 0.25) 0px 4px 8px -2px, rgba(9, 30, 66, 0.31) 0px 0px 1px;
`,
truncateText: css`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,
clickable: css`
cursor: pointer;
user-select: none;
`,
hardwareAccelerate: css`
transform: translateZ(0);
`,
cover: css`
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
`,
link: (colorValue = color.textLink) => css`
cursor: pointer;
color: ${colorValue};
${font.medium}
&:hover, &:visited, &:active {
color: ${colorValue};
}
&:hover {
text-decoration: underline;
}
`,
placeholderColor: (colorValue: string) => css`
::-webkit-input-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
:-moz-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
::-moz-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
:-ms-input-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
`,
};

29
web/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"baseUrl": "src"
},
"include": [
"src"
],
"types": [
"react-beautiful-dnd"
]
}

15509
web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff