├── next-env.d.ts ├── src ├── pages │ ├── b │ │ └── [uid].tsx │ ├── board.tsx │ ├── privacy.tsx │ ├── changelog.tsx │ ├── index.tsx │ ├── _app.tsx │ ├── _document.tsx │ └── invitation.tsx ├── types │ └── typings.d.ts ├── hooks │ ├── use-session.tsx │ ├── use-previous.ts │ ├── use-is-mounted.ts │ ├── use-first-render.ts │ ├── use-is-mobile-safari.ts │ ├── use-lock-body-scroll.ts │ ├── use-emitter.ts │ ├── use-app-toast.tsx │ ├── use-key-submit.ts │ ├── use-markdown-checkbox.ts │ ├── use-thin-display.ts │ ├── use-undo.tsx │ ├── use-rect.ts │ ├── use-click-outside.ts │ └── use-due-date.ts ├── modules │ ├── LandingPage │ │ ├── Article.module.css │ │ ├── Pricing.css │ │ ├── PricingCard.css │ │ ├── Header.tsx │ │ ├── Footer.tsx │ │ ├── Article.tsx │ │ ├── Pricing.tsx │ │ └── PricingCard.tsx │ ├── Activity │ │ ├── data │ │ │ └── activity.doc.ts │ │ ├── domain │ │ │ ├── card-activity.ts │ │ │ └── activity.ts │ │ └── components │ │ │ └── Activities.tsx │ ├── Card │ │ ├── components │ │ │ ├── CardModal │ │ │ │ ├── CardOptionButton.tsx │ │ │ │ ├── CardOptionAssignToMe.tsx │ │ │ │ ├── CardOptionColors.tsx │ │ │ │ ├── CardModal.scss │ │ │ │ ├── CardOptions.scss │ │ │ │ └── CardMenu.tsx │ │ │ ├── CardBasic.tsx │ │ │ ├── CardMarkdown.tsx │ │ │ ├── CardBadges │ │ │ │ └── index.tsx │ │ │ └── Card.tsx │ │ ├── data │ │ │ └── card.doc.ts │ │ ├── hooks │ │ │ ├── use-card-assignees.ts │ │ │ └── use-assign.ts │ │ └── index.tsx │ ├── Board │ │ ├── data │ │ │ └── board-invite.doc.ts │ │ ├── hooks │ │ │ ├── use-board.ts │ │ │ └── use-board-team.ts │ │ ├── components │ │ │ ├── BoardHeader │ │ │ │ ├── AddTagsModal.tsx │ │ │ │ ├── BoardTitle.tsx │ │ │ │ ├── Team.tsx │ │ │ │ ├── BoardButton.tsx │ │ │ │ ├── AddNewListModal.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── BoardMenu.tsx │ │ │ └── CardModalProvider │ │ │ │ └── index.tsx │ │ ├── pages │ │ │ └── Board.tsx │ │ └── store │ │ │ └── boards.tsx │ ├── Dashboard │ │ ├── components │ │ │ ├── UserSimpleProfileCard.tsx │ │ │ ├── BoardAdder.tsx │ │ │ └── BoardCard.tsx │ │ └── index.tsx │ └── Auth │ │ └── components │ │ ├── AuthenticatedPage.tsx │ │ └── FirebaseAuth.tsx ├── helpers │ ├── is-alphanumeric.ts │ ├── sortUsersAlpha.ts │ └── find-check-boxes.ts ├── documents │ ├── color.doc.ts │ ├── user.doc.ts │ └── list.doc.ts ├── components │ ├── shared │ │ ├── Feedback │ │ │ ├── index.tsx │ │ │ ├── GiveFeedback.tsx │ │ │ └── emoji.module.css │ │ ├── Heading.tsx │ │ ├── Badge │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── Divider.tsx │ │ ├── AutosuggestInput │ │ │ ├── index.stories.tsx │ │ │ ├── styles.tsx │ │ │ └── index.tsx │ │ ├── Truncate.tsx │ │ ├── Empty │ │ │ ├── Empty.tsx │ │ │ ├── StartBoardEmpty.tsx │ │ │ └── CreateContentEmpty.tsx │ │ ├── AddTags │ │ │ ├── AddTags.scss │ │ │ └── AddTags.tsx │ │ ├── ClickOutside.tsx │ │ ├── index.ts │ │ ├── Animated │ │ │ ├── AnimatedOpacity.tsx │ │ │ ├── AnimateSlideUpinView.tsx │ │ │ └── AnimatedSlideUp.tsx │ │ ├── TagList.tsx │ │ ├── BadgeTags.tsx │ │ ├── Spinner.tsx │ │ ├── CardPlaceholder.tsx │ │ ├── DueCalendar │ │ │ ├── DueDate.tsx │ │ │ └── index.tsx │ │ ├── ToastUndo.tsx │ │ ├── SafariButtonWarning.tsx │ │ ├── BadgeTaskProgress.tsx │ │ ├── List.tsx │ │ ├── PopupMenu │ │ │ └── index.stories.tsx │ │ ├── Toast │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── Loader.tsx │ │ ├── Input.tsx │ │ ├── BadgeDueDate.tsx │ │ ├── Avatar.tsx │ │ ├── Tag.tsx │ │ ├── UserMenu.tsx │ │ ├── AddBoardModal.tsx │ │ ├── Button.tsx │ │ ├── BoardList │ │ │ ├── ListMenu.tsx │ │ │ └── ListHeader.tsx │ │ ├── DraggableElement.tsx │ │ ├── CardCounter.tsx │ │ ├── SideMenu │ │ │ └── index.tsx │ │ ├── Menu.tsx │ │ ├── Checkbox.tsx │ │ ├── Dialog.tsx │ │ ├── HoverCard │ │ │ └── index.tsx │ │ ├── Assignee.tsx │ │ ├── Editable.tsx │ │ ├── Calendar.tsx │ │ ├── ColorPicker.tsx │ │ ├── MarkdownText.tsx │ │ ├── Color.tsx │ │ ├── CardAdder.tsx │ │ ├── Cards.tsx │ │ ├── NewBoardForm.tsx │ │ └── Assignees.tsx │ ├── pages │ │ └── Home.tsx │ ├── providers │ │ └── InterfaceProvider.tsx │ └── Header │ │ └── index.tsx ├── welcome.stories.tsx ├── store │ ├── index.tsx │ └── users.tsx ├── libs │ ├── api.ts │ ├── analytics.ts │ └── emitter.ts ├── ui │ ├── fonts.ts │ ├── colors.ts │ └── theme.ts └── services │ └── firebase.service.ts ├── .gitignore ├── public └── static │ └── images │ ├── card.png │ ├── icon.ico │ ├── icon.png │ ├── card-modal.png │ ├── color-icon.png │ ├── postits-1366.jpg │ ├── postits-1920.jpg │ ├── board-members.png │ ├── default-avatar.png │ ├── book-store-board.png │ ├── google-logo.svg │ └── kanban-logo.svg ├── postcss.config.js ├── . eslintignore ├── .prettierrc ├── tailwind.config.js ├── .storybook ├── main.js ├── manager.js └── preview.js ├── .vscode ├── settings.json └── launch.json ├── .babelrc ├── now.json ├── next.config.js ├── tsconfig.json ├── LICENSE ├── .eslintrc.js └── README.md /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/pages/b/[uid].tsx: -------------------------------------------------------------------------------- 1 | import Board from 'modules/Board/pages/Board'; 2 | 3 | export default Board; 4 | -------------------------------------------------------------------------------- /src/pages/board.tsx: -------------------------------------------------------------------------------- 1 | import Board from 'modules/Board/pages/Board'; 2 | 3 | export default Board; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | stats.json 4 | .next 5 | .env-dev 6 | .env 7 | .env.local 8 | .vercel -------------------------------------------------------------------------------- /public/static/images/card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/card.png -------------------------------------------------------------------------------- /public/static/images/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/icon.ico -------------------------------------------------------------------------------- /public/static/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/icon.png -------------------------------------------------------------------------------- /public/static/images/card-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/card-modal.png -------------------------------------------------------------------------------- /public/static/images/color-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/color-icon.png -------------------------------------------------------------------------------- /src/types/typings.d.ts: -------------------------------------------------------------------------------- 1 | type WithChildren = { 2 | children: React.ReactNode | React.ReactNode[]; 3 | } & Props; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | 'postcss-preset-env': {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/static/images/postits-1366.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/postits-1366.jpg -------------------------------------------------------------------------------- /public/static/images/postits-1920.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/postits-1920.jpg -------------------------------------------------------------------------------- /src/hooks/use-session.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'modules/Auth/components/SessionProvider'; 2 | 3 | export { useSession }; 4 | -------------------------------------------------------------------------------- /public/static/images/board-members.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/board-members.png -------------------------------------------------------------------------------- /public/static/images/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/default-avatar.png -------------------------------------------------------------------------------- /public/static/images/book-store-board.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easyflow-live/easyflow/HEAD/public/static/images/book-store-board.png -------------------------------------------------------------------------------- /. eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint nyc coverage output 4 | coverage 5 | # Next build folder 6 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "bracketSpacing": true, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/LandingPage/Article.module.css: -------------------------------------------------------------------------------- 1 | .Article .print { 2 | box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 3 | 0 10px 10px -5px rgba(0, 0, 0, 0.04); 4 | } 5 | -------------------------------------------------------------------------------- /src/helpers/is-alphanumeric.ts: -------------------------------------------------------------------------------- 1 | export const isAlphanumeric = (input: string) => { 2 | const letterAndNumber = /^[0-9a-zA-Z]+$/; 3 | 4 | return !!input.match(letterAndNumber); 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/Activity/data/activity.doc.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'firestorter'; 2 | import { Activity } from '../domain/activity'; 3 | 4 | export default class ActivityDocument extends Document {} 5 | -------------------------------------------------------------------------------- /src/documents/color.doc.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'firestorter'; 2 | 3 | export interface Color { 4 | name: string; 5 | code: string; 6 | } 7 | 8 | export default class ColorDocument extends Document {} 9 | -------------------------------------------------------------------------------- /src/components/shared/Feedback/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './FeedbackInput'; 2 | // export { default as FooterFeedback } from './footer-feedback'; 3 | // export { default as HeaderFeedback } from './header-feedback'; 4 | -------------------------------------------------------------------------------- /src/modules/Activity/domain/card-activity.ts: -------------------------------------------------------------------------------- 1 | export enum CardActivity { 2 | MOVE = 'move', 3 | EDIT = 'edit', 4 | NEW = 'new', 5 | REMOVE = 'remove', 6 | ASSIGNEE = 'assignee', 7 | COMPLETE = 'complete', 8 | } 9 | -------------------------------------------------------------------------------- /src/components/shared/Heading.tsx: -------------------------------------------------------------------------------- 1 | interface HeadingProps { 2 | text: string; 3 | } 4 | 5 | const Heading = ({ text }: HeadingProps) => ( 6 |

{text}

7 | ); 8 | 9 | export default Heading; 10 | -------------------------------------------------------------------------------- /src/modules/Activity/domain/activity.ts: -------------------------------------------------------------------------------- 1 | import { CardActivity } from 'modules/Activity/domain/card-activity'; 2 | 3 | export interface Activity { 4 | date: number; 5 | memberCreator: any; 6 | type: CardActivity; 7 | data: any; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/shared/Badge/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import Badge from '.'; 2 | 3 | export default { title: 'Atomos/Badge', component: Badge }; 4 | 5 | export const basic = () => ( 6 | 7 | Feature 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | extend: { 4 | colors: { 5 | 'gray-750': '#3d495d', 6 | }, 7 | width: { 8 | '95': '95%', 9 | }, 10 | }, 11 | }, 12 | variants: {}, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /src/welcome.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { linkTo } from '@storybook/addon-links'; 3 | import { Welcome } from '@storybook/react/demo'; 4 | 5 | export default { title: 'Welcome' }; 6 | 7 | export const toStorybook = () => ; 8 | -------------------------------------------------------------------------------- /src/helpers/sortUsersAlpha.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../store/users'; 2 | 3 | export const sortUsersAlpha = (a: User, b: User) => { 4 | if (a.username < b.username) { 5 | return -1; 6 | } 7 | if (a.username > b.username) { 8 | return 1; 9 | } 10 | return 0; 11 | }; 12 | -------------------------------------------------------------------------------- /src/hooks/use-previous.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function usePrevious(value: T) { 4 | const ref = useRef(undefined); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }, [value]); 9 | 10 | return ref.current; 11 | } 12 | -------------------------------------------------------------------------------- /src/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { useUsersStore, UsersStoreProvider } from './users'; 2 | import { 3 | useBoardsStore, 4 | BoardsStoreProvider, 5 | } from 'modules/Board/store/boards'; 6 | 7 | export { 8 | useBoardsStore, 9 | BoardsStoreProvider, 10 | useUsersStore, 11 | UsersStoreProvider, 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/use-is-mounted.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | export const useIsMounted = (): boolean => { 4 | const isMounted = useRef(false); 5 | useEffect(() => { 6 | isMounted.current = true; 7 | 8 | return () => { 9 | isMounted.current = false; 10 | }; 11 | }, []); 12 | return isMounted.current; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/shared/Divider.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | 3 | interface DividerProps { 4 | className?: string; 5 | } 6 | 7 | const Divider = ({ className = '' }: DividerProps) => ( 8 |
14 | ); 15 | 16 | export default Divider; 17 | -------------------------------------------------------------------------------- /src/components/shared/AutosuggestInput/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AutosuggestInput from '.'; 4 | 5 | export default { 6 | title: 'Atomos/AutosuggestInput', 7 | component: AutosuggestInput, 8 | }; 9 | 10 | export const basic = () => ( 11 | {}} 14 | /> 15 | ); 16 | -------------------------------------------------------------------------------- /src/hooks/use-first-render.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | export const useFirstRender = (callback?: () => void): [boolean] => { 4 | const firstRender = useRef(true); 5 | 6 | useEffect(() => { 7 | if (firstRender.current) { 8 | firstRender.current = false; 9 | callback && callback(); 10 | } 11 | }); 12 | 13 | return [firstRender.current]; 14 | }; 15 | -------------------------------------------------------------------------------- /src/helpers/find-check-boxes.ts: -------------------------------------------------------------------------------- 1 | // Return the total number of checkboxes and the number of checked checkboxes inside a given text 2 | export const findCheckboxes = ( 3 | text: string 4 | ): { total: number; checked: number } => { 5 | const checkboxes = text.match(/\[(\s|x)\]/g) || []; 6 | const checked = checkboxes.filter(checkbox => checkbox === '[x]').length; 7 | return { total: checkboxes.length, checked }; 8 | }; 9 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.@(ts|tsx|js|jsx|mdx)'], 5 | addons: ['@storybook/addon-actions', '@storybook/addon-links'], 6 | webpackFinal: async config => { 7 | config.resolve.modules = [ 8 | path.resolve(__dirname, '../src'), 9 | ...config.resolve.modules, 10 | ]; 11 | 12 | return config; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/use-is-mobile-safari.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useIsMobileSafari = () => { 4 | const [isMobileSafari, setIsSafari] = useState(false); 5 | 6 | useEffect(() => { 7 | setIsSafari( 8 | /^((?!chrome|android|macintosh|crios|fxios).)*safari/i.test( 9 | window.navigator.userAgent 10 | ) 11 | ); 12 | }, []); 13 | 14 | return isMobileSafari; 15 | }; 16 | -------------------------------------------------------------------------------- /src/hooks/use-lock-body-scroll.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react'; 2 | 3 | export const useLockBodyScroll = (enable: boolean) => { 4 | useLayoutEffect(() => { 5 | const originalStyle = window.getComputedStyle(document.body).overflow; 6 | 7 | if (enable) { 8 | document.body.style.overflow = 'hidden'; 9 | } 10 | 11 | return () => (document.body.style.overflow = originalStyle); 12 | }, [enable]); 13 | }; 14 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import { themes } from '@storybook/theming'; 3 | import { create } from '@storybook/theming/create'; 4 | 5 | const customTheme = create({ 6 | appBg: '#1a202c', 7 | appContentBg: '#2d3748', 8 | appBorderColor: '#2d3748', 9 | brandImage: 'https://easyflow.live/static/images/icon.ico', 10 | }); 11 | 12 | addons.setConfig({ 13 | theme: { ...themes.dark, ...customTheme }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/shared/Truncate.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | interface TruncateProps { 5 | className?: string; 6 | title?: string; 7 | } 8 | 9 | const Truncate = ({ 10 | children, 11 | className, 12 | title, 13 | }: PropsWithChildren) => ( 14 | 15 | {children} 16 | 17 | ); 18 | 19 | export default Truncate; 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug.node.autoAttach": "on", 3 | "editor.formatOnPaste": true, 4 | 5 | "typescript.updateImportsOnFileMove.enabled": "always", 6 | "typescript.preferences.quoteStyle": "single", 7 | 8 | "javascript.updateImportsOnFileMove.enabled": "always", 9 | "javascript.preferences.quoteStyle": "single", 10 | 11 | "prettier.jsxSingleQuote": true, 12 | "prettier.singleQuote": true, 13 | 14 | "editor.tabSize": 2, 15 | "editor.insertSpaces": true 16 | } 17 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "styled-components", 6 | { 7 | "ssr": true, 8 | "displayName": true, 9 | "preprocess": false 10 | } 11 | ], 12 | [ 13 | "@babel/plugin-proposal-decorators", 14 | { 15 | "legacy": true 16 | } 17 | ], 18 | [ 19 | "@babel/plugin-proposal-class-properties", 20 | { 21 | "loose": true 22 | } 23 | ] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/shared/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | type BadgeProps = { 5 | title?: string; 6 | className?: string; 7 | children: React.ReactNode; 8 | }; 9 | 10 | const Badge = ({ className, ...props }: BadgeProps) => ( 11 |
18 | ); 19 | 20 | export default Badge; 21 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardModal/CardOptionButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const CardOptionButton = styled.button` 4 | position: relative; 5 | display: flex; 6 | align-items: center; 7 | border: 0; 8 | padding: 0.75rem; 9 | background: transparent; 10 | font-size: inherit; 11 | cursor: pointer; 12 | min-width: 100%; 13 | 14 | &:hover, 15 | &:focus { 16 | background-color: #2d3748; 17 | } 18 | `; 19 | 20 | export default CardOptionButton; 21 | -------------------------------------------------------------------------------- /src/modules/LandingPage/Pricing.css: -------------------------------------------------------------------------------- 1 | .PricingCards { 2 | display: flex; 3 | } 4 | 5 | .PricingCards .PricingCard:not(:nth-child(1)) { 6 | margin-left: 24px; 7 | } 8 | 9 | @media (max-width: 640px) { 10 | .PricingCards { 11 | flex-direction: column; 12 | align-items: stretch; 13 | } 14 | 15 | .PricingCards .PricingCard:nth-child(n) { 16 | width: 100%; 17 | margin-left: 0; 18 | } 19 | 20 | .PricingCards .PricingCard:not(:nth-child(1)) { 21 | margin-top: 1rem; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { themes } from '@storybook/theming'; 2 | import { ChakraProvider } from '@chakra-ui/react'; 3 | 4 | import '../src/styles/style.css'; 5 | import { customTheme } from '../src/ui/theme'; 6 | 7 | // or global addParameters 8 | export const parameters = { 9 | docs: { 10 | theme: themes.dark, 11 | }, 12 | }; 13 | 14 | 15 | export const decorators = [ 16 | Story => ( 17 | 18 | 19 | 20 | ), 21 | ]; -------------------------------------------------------------------------------- /src/components/shared/Empty/Empty.tsx: -------------------------------------------------------------------------------- 1 | import { ReactChild } from 'react'; 2 | 3 | interface EmptyProps { 4 | image: ReactChild; 5 | message: string; 6 | button?: ReactChild; 7 | messageClass?: string; 8 | } 9 | 10 | export const Empty = ({ image, message, button, messageClass }: EmptyProps) => ( 11 |
12 | {image} 13 |

{message}

14 | {button} 15 |
16 | ); 17 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "alias": "easyflow.live", 3 | "version": 2, 4 | "build": { 5 | "env": { 6 | "REACT_APP_API_KEY": "@app_api_key", 7 | "REACT_APP_AUTH_DOMAIN": "@app_auth_domain", 8 | "REACT_APP_DATABASE_URL": "@app_database_url", 9 | "REACT_APP_PROJECT_ID": "@app_project_id", 10 | "REACT_APP_STORAGE_BUCKET": "@app_storage_bucket", 11 | "REACT_APP_MESSAGING_SENDER_ID": "@app_messaging_sender_id", 12 | "REACT_APP_ID": "@app_id", 13 | "PORT": "3000" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/use-emitter.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect } from 'react'; 2 | 3 | import { emitter, EmitterTypes } from 'libs/emitter'; 4 | 5 | export function useEmitter( 6 | key: K, 7 | callback: (payload: EmitterTypes[K]) => void, 8 | deps: DependencyList = [] 9 | ) { 10 | useEffect(() => { 11 | if (!(key && callback)) return; 12 | 13 | const listener = emitter.addListener(key, callback); 14 | return () => listener.remove(); 15 | }, [key, callback, ...deps]); 16 | 17 | return emitter; 18 | } 19 | -------------------------------------------------------------------------------- /src/libs/api.ts: -------------------------------------------------------------------------------- 1 | type InviteEmail = { 2 | to: string; 3 | userName: string; 4 | userEmail: string; 5 | ownerName: string; 6 | boardName: string; 7 | boardUrl: string; 8 | inviteId: string; 9 | }; 10 | 11 | export const sendInviteEmail = async (body: InviteEmail) => { 12 | return ( 13 | await fetch('/api/invite', { 14 | method: 'POST', 15 | headers: { 16 | Accept: 'application/json', 17 | 'Content-Type': 'application/json', 18 | }, 19 | body: JSON.stringify(body), 20 | }) 21 | ).json(); 22 | }; 23 | -------------------------------------------------------------------------------- /src/ui/fonts.ts: -------------------------------------------------------------------------------- 1 | export const systemFontStack = [ 2 | '-apple-system', 3 | 'BlinkMacSystemFont', 4 | '"Segoe UI"', 5 | 'Roboto', 6 | '"Helvetica Neue"', 7 | 'Arial', 8 | '"Noto Sans"', 9 | 'sans-serif', 10 | '"Apple Color Emoji"', 11 | '"Segoe UI Emoji"', 12 | '"Segoe UI Symbol"', 13 | '"Noto Color Emoji"', 14 | 'sans-serif', 15 | ].join(','); 16 | 17 | export const systemMonoFontStack = [ 18 | '"SFMono-Regular"', 19 | 'Menlo', 20 | 'Consolas', 21 | '"Liberation Mono"', 22 | 'Courier', 23 | 'monospace', 24 | ].join(','); 25 | -------------------------------------------------------------------------------- /src/components/shared/AddTags/AddTags.scss: -------------------------------------------------------------------------------- 1 | .react-tagsinput { 2 | position: relative; 3 | } 4 | 5 | .react-tagsinput-input { 6 | width: 100%; 7 | color: white; 8 | background-color: #718096; 9 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 10 | padding-left: 0.75rem; 11 | padding-right: 0.75rem; 12 | padding-top: 0.5rem; 13 | padding-bottom: 0.5rem; 14 | line-height: 1.25; 15 | border-width: 0px; 16 | border-radius: 0.25rem; 17 | } 18 | 19 | .react-tagsinput-input::placeholder { 20 | color: #1a202c; 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/privacy.tsx: -------------------------------------------------------------------------------- 1 | import { Title } from 'react-head'; 2 | 3 | import Privacy from 'modules/LandingPage/Privacy'; 4 | import Footer from 'modules/LandingPage/Footer'; 5 | import Header from 'modules/LandingPage/Header'; 6 | import { useSession } from 'hooks/use-session'; 7 | 8 | export default () => { 9 | const { user, initializing } = useSession(); 10 | 11 | if (initializing) return null; 12 | 13 | return ( 14 | <> 15 | Easy Flow Privacy 16 | {!user &&
} 17 | 18 |
19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/libs/analytics.ts: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | 3 | export const initGA = () => { 4 | ReactGA.initialize('UA-141378639-1'); 5 | }; 6 | 7 | export const logPageView = url => { 8 | ReactGA.set({ page: url }); 9 | ReactGA.pageview(url); 10 | }; 11 | 12 | export const logEvent = (category = '', action = '') => { 13 | if (category && action) { 14 | ReactGA.event({ category, action }); 15 | } 16 | }; 17 | 18 | export const logException = (description = '', fatal = false) => { 19 | if (description) { 20 | ReactGA.exception({ description, fatal }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/pages/changelog.tsx: -------------------------------------------------------------------------------- 1 | import { Title } from 'react-head'; 2 | 3 | import Changelog from 'components/pages/Changelog'; 4 | import Header from 'modules/LandingPage/Header'; 5 | import Footer from 'modules/LandingPage/Footer'; 6 | import { useSession } from 'hooks/use-session'; 7 | 8 | export default () => { 9 | const { user, initializing } = useSession(); 10 | 11 | if (initializing) return null; 12 | 13 | return ( 14 | <> 15 | Easy Flow Changelog 16 | {!user &&
} 17 | 18 |
19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/shared/ClickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ReactChild } from 'react'; 2 | import onClickOutside from 'react-onclickoutside'; 3 | 4 | interface ClickOutsideWrapperProps { 5 | handleClickOutside: () => void; 6 | children: ReactChild; 7 | } 8 | 9 | // Wrap component in this component to handle click outisde of that component 10 | class ClickOutsideWrapper extends Component { 11 | handleClickOutside = () => this.props.handleClickOutside(); 12 | render = () => this.props.children; 13 | } 14 | 15 | export default onClickOutside(ClickOutsideWrapper); 16 | -------------------------------------------------------------------------------- /src/hooks/use-app-toast.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { toast } from 'react-toastify'; 3 | 4 | import Toast from 'components/shared/Toast'; 5 | 6 | type ToastProps = { 7 | title: string; 8 | id: string | number; 9 | onCloseComplete?: () => void; 10 | undo?: (id?: string) => void; 11 | }; 12 | 13 | export const useAppToast = () => { 14 | const customToast = useCallback( 15 | (props: ToastProps) => { 16 | toast(, { 17 | onClose: props.onCloseComplete, 18 | }); 19 | }, 20 | [toast] 21 | ); 22 | 23 | return customToast; 24 | }; 25 | -------------------------------------------------------------------------------- /src/modules/LandingPage/PricingCard.css: -------------------------------------------------------------------------------- 1 | .PricingCard { 2 | -ms-flex-align: start; 3 | align-items: start; 4 | display: grid; 5 | grid-gap: 2.5rem; 6 | grid-template-columns: 1fr; 7 | grid-template-rows: min-content auto; 8 | min-height: 415px; 9 | position: relative; 10 | transform: translate3d(0, 0, 0); 11 | } 12 | 13 | .PricingCard button:hover { 14 | transition: transform 0.3s; 15 | transform: translate3d(0, -5px, 0); 16 | } 17 | 18 | .PricingCard button { 19 | transition: all 0.3s; 20 | } 21 | 22 | .PricingCard.PricingCard--disabled:hover { 23 | transform: translate3d(0, 0, 0); 24 | } 25 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const Dotenv = require('dotenv-webpack'); 2 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 3 | const webpack = require('webpack'); 4 | 5 | module.exports = { 6 | webpack: (config, { dev }) => { 7 | config.plugins.push(new Dotenv({ systemvars: true })); 8 | 9 | if (!dev) { 10 | if (Array.isArray(config.optimization.minimizer)) { 11 | config.optimization.minimizer.push(new OptimizeCSSAssetsPlugin({})); 12 | } 13 | } 14 | 15 | config.plugins.push(new webpack.DefinePlugin({ __DEV__: dev })); 16 | 17 | return config; 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Avatar } from './Avatar'; 2 | export { default as Badge } from './Badge'; 3 | export { default as Button } from './Button'; 4 | export { default as Checkbox } from './Checkbox'; 5 | export { default as Divider } from './Divider'; 6 | export { default as Editable } from './Editable'; 7 | export { default as Heading } from './Heading'; 8 | export { default as Input } from './Input'; 9 | export { default as Modal } from './Modal'; 10 | export { default as Tag } from './Tag'; 11 | export { default as Truncate } from './Truncate'; 12 | export { default as SafariButtonWarning } from './SafariButtonWarning'; 13 | -------------------------------------------------------------------------------- /src/modules/Board/data/board-invite.doc.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'firestorter'; 2 | import firebase from 'firebase/app'; 3 | 4 | import BoardDocument from 'modules/Board/data/board.doc'; 5 | import UserDocument from 'documents/user.doc'; 6 | 7 | export enum InviteStatus { 8 | PENDING = 'pending', 9 | ACCEPTED = 'accepted', 10 | REJECTED = 'rejected', 11 | } 12 | 13 | interface BoardInvite { 14 | board: BoardDocument['ref']; 15 | user: UserDocument['ref']; 16 | fromUser: UserDocument['ref']; 17 | status: InviteStatus; 18 | createdAt: firebase.firestore.Timestamp; 19 | } 20 | export default class BoardInviteDocument extends Document {} 21 | -------------------------------------------------------------------------------- /public/static/images/google-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/use-key-submit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | type Callback = () => void; 4 | const ENTER_CODE = 13; 5 | const ESCAPE_CODE = 27; 6 | 7 | export const useKeySubmit = ( 8 | onEnter: Callback, 9 | onEscape: Callback 10 | ): ((event: React.KeyboardEvent) => void) => { 11 | const handleKeyDown = useCallback( 12 | (event: React.KeyboardEvent) => { 13 | if (event.keyCode === ENTER_CODE && !event.shiftKey) { 14 | event.preventDefault(); 15 | onEnter && onEnter(); 16 | } else if (event.keyCode === ESCAPE_CODE) { 17 | onEscape && onEscape(); 18 | } 19 | }, 20 | [onEnter, onEscape] 21 | ); 22 | 23 | return handleKeyDown; 24 | }; 25 | -------------------------------------------------------------------------------- /src/modules/Dashboard/components/UserSimpleProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, HStack, Text, Avatar } from '@chakra-ui/react'; 2 | 3 | type UserSimpleProfileCardProps = { 4 | username: string; 5 | photo: string; 6 | email: string; 7 | }; 8 | 9 | export function UserSimpleProfileCard({ 10 | username, 11 | photo, 12 | email, 13 | }: UserSimpleProfileCardProps) { 14 | return ( 15 | 16 | 17 | 18 | 19 | {username} 20 | 21 | {email} 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/shared/Animated/AnimatedOpacity.tsx: -------------------------------------------------------------------------------- 1 | import { ReactChild } from 'react'; 2 | import { HTMLMotionProps, motion } from 'framer-motion'; 3 | import { chakra, ChakraProps } from '@chakra-ui/system'; 4 | 5 | const MotionDiv = chakra(motion.div); 6 | 7 | type AnitedOpacityProps = { 8 | show: boolean; 9 | children: ReactChild; 10 | } & ChakraProps & 11 | HTMLMotionProps<'div'>; 12 | 13 | export const AnimatedOpacity = ({ 14 | show, 15 | children, 16 | ...props 17 | }: AnitedOpacityProps) => { 18 | return ( 19 | 24 | {children} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/shared/Animated/AnimateSlideUpinView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactChild } from 'react'; 2 | import { useInView } from 'react-intersection-observer'; 3 | import { AnimatedSlideUp } from './AnimatedSlideUp'; 4 | 5 | interface AnimateSlideUpinViewProps { 6 | children?: ReactChild; 7 | className?: string; 8 | } 9 | 10 | const AnimateSlideUpinView = ({ 11 | children, 12 | ...props 13 | }: AnimateSlideUpinViewProps) => { 14 | const [ref, inView] = useInView({ 15 | threshold: 0.5, 16 | triggerOnce: true, 17 | }); 18 | 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | }; 25 | 26 | export default AnimateSlideUpinView; 27 | -------------------------------------------------------------------------------- /src/components/shared/TagList.tsx: -------------------------------------------------------------------------------- 1 | import Tag from 'components/shared/Tag'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | interface TagListProps { 5 | tags: string[]; 6 | onRemoveTag(tag: string): void; 7 | className?: string; 8 | removable?: boolean; 9 | } 10 | 11 | const TagList = ({ tags, onRemoveTag, className, removable }: TagListProps) => ( 12 |
    13 | {tags && 14 | tags.map((tag: string, index: number) => ( 15 |
  • 16 | 17 |
  • 18 | ))} 19 |
20 | ); 21 | 22 | export default observer(TagList); 23 | -------------------------------------------------------------------------------- /src/components/shared/BadgeTags.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import Tag from './Tag'; 4 | 5 | interface BadgeTags { 6 | tags: string[]; 7 | removable: boolean; 8 | onTagClick?(tag: string): void; 9 | } 10 | 11 | const BadgeTags = observer( 12 | ({ tags, removable, onTagClick }: BadgeTags) => 13 | tags && ( 14 |
15 | {tags.map((t, index) => ( 16 | 23 | ))} 24 |
25 | ) 26 | ); 27 | 28 | export default BadgeTags; 29 | -------------------------------------------------------------------------------- /src/components/shared/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { FaSpinner } from 'react-icons/fa'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | const rotate = keyframes` 5 | from { 6 | transform: rotate(0deg); 7 | } 8 | 9 | to { 10 | transform: rotate(360deg); 11 | } 12 | `; 13 | 14 | const AnimatedSpinner = styled.span` 15 | animation: ${rotate} 2s linear infinite; 16 | `; 17 | 18 | interface SpinnerProps { 19 | size?: string; 20 | color?: string; 21 | className?: string; 22 | } 23 | 24 | export const Spinner = ({ 25 | className, 26 | size = '18', 27 | color = '#fff', 28 | }: SpinnerProps) => ( 29 | 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /src/components/shared/CardPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import ContentLoader from 'react-content-loader'; 2 | 3 | const CardPlaceholder = () => ( 4 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | 23 | export default CardPlaceholder; 24 | -------------------------------------------------------------------------------- /src/modules/Auth/components/AuthenticatedPage.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import { ErrorProps } from 'next/error'; 3 | import Error from 'next/error'; 4 | 5 | import LandingPage from 'modules/LandingPage'; 6 | import { useSession } from 'hooks/use-session'; 7 | import Loader from 'components/shared/Loader'; 8 | 9 | interface PageProps { 10 | children: ReactElement; 11 | isAnonymous?: boolean; 12 | redirect?: boolean; 13 | error?: ErrorProps; 14 | } 15 | 16 | export default ({ children, isAnonymous, redirect, error }: PageProps) => { 17 | const { user } = useSession(); 18 | 19 | if (redirect) return ; 20 | if (error) return ; 21 | if (!user && !isAnonymous) return ; 22 | 23 | return children; 24 | }; 25 | -------------------------------------------------------------------------------- /src/ui/colors.ts: -------------------------------------------------------------------------------- 1 | export interface ThemeableColor { 2 | dark: string; 3 | light: string; 4 | } 5 | 6 | export type ThemeableColors = { 7 | [color: string]: ThemeableColor; 8 | }; 9 | 10 | export const colors = { 11 | gray: { 12 | 50: '#fcfeff', 13 | 100: '#f7fafc', 14 | 200: '#edf2f7', 15 | 300: '#e2e8f0', 16 | 400: '#cbd5e0', 17 | 500: '#a0aec0', 18 | 600: '#718096', 19 | 700: '#4a5568', 20 | 750: '#3d495d', 21 | 800: '#2d3748', 22 | 900: '#1a202c', 23 | }, 24 | pink: { 25 | 50: '#fffafb', 26 | 100: '#fff5f7', 27 | 200: '#fed7e2', 28 | 300: '#fbb6ce', 29 | 400: '#f687b3', 30 | 500: '#ed64a6', 31 | 600: '#d53f8c', 32 | 700: '#b83280', 33 | 800: '#97266d', 34 | 900: '#702459', 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Title } from 'react-head'; 3 | import { observer } from 'mobx-react-lite'; 4 | import { Box } from '@chakra-ui/react'; 5 | 6 | import Heading from 'components/shared/Heading'; 7 | import Dashboard from 'modules/Dashboard'; 8 | import { useSession } from 'hooks/use-session'; 9 | 10 | const Home = () => { 11 | const { userDoc } = useSession(); 12 | 13 | return ( 14 | <> 15 | Boards | Easy Flow 16 | 17 | 18 | {userDoc ? ( 19 | <> 20 | 21 | 22 | 23 | ) : null} 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default observer(Home); 30 | -------------------------------------------------------------------------------- /src/components/shared/DueCalendar/DueDate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import { useDueDate } from 'hooks/use-due-date'; 5 | 6 | interface DueDateProps { 7 | date: any; 8 | completed?: boolean; 9 | } 10 | 11 | const DueDate: React.FC = props => { 12 | const { date, completed } = props; 13 | 14 | const { dueTitle, dueDateString, dueDateColor } = useDueDate(date, completed); 15 | 16 | return date ? ( 17 | 23 | {dueDateString} 24 | 25 | ) : ( 26 | No date 27 | ); 28 | }; 29 | 30 | export default observer(DueDate); 31 | -------------------------------------------------------------------------------- /src/components/shared/ToastUndo.tsx: -------------------------------------------------------------------------------- 1 | interface ToastUndoProps { 2 | title: string; 3 | id: string; 4 | undo: (id: string) => void; 5 | closeToast?: () => void; 6 | } 7 | 8 | const ToastUndo = ({ title, id, undo, closeToast }: ToastUndoProps) => { 9 | const handleClick = () => { 10 | undo(id); 11 | closeToast(); 12 | }; 13 | 14 | return ( 15 |
16 |

17 | {title}{' '} 18 | 24 |

25 |
26 | ); 27 | }; 28 | 29 | export default ToastUndo; 30 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardModal/CardOptionAssignToMe.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { FaUser } from 'react-icons/fa'; 3 | 4 | import { useAssign } from 'modules/Card/hooks/use-assign'; 5 | import CardDocument from 'modules/Card/data/card.doc'; 6 | import CardOptionButton from './CardOptionButton'; 7 | 8 | interface Props { 9 | card: CardDocument; 10 | listId: string; 11 | } 12 | 13 | const CardOptionAssignToMe = observer(({ card, listId }: Props) => { 14 | const toggleAssignment = useAssign(card, listId); 15 | 16 | return ( 17 | 18 |
19 | 20 |
21 |  Toggle assignment 22 |
23 | ); 24 | }); 25 | 26 | export default CardOptionAssignToMe; 27 | -------------------------------------------------------------------------------- /src/components/shared/SafariButtonWarning.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaExclamationTriangle } from 'react-icons/fa'; 3 | 4 | import { useIsMobileSafari } from 'hooks/use-is-mobile-safari'; 5 | import FirebaseAuth from 'modules/Auth/components/FirebaseAuth'; 6 | 7 | const SafariButtonWarning = () => { 8 | const isMobileSafari = useIsMobileSafari(); 9 | 10 | return isMobileSafari ? ( 11 |

12 | 13 | 14 | This app is unavailable when using Safari on IPhone because of iOS 15 | native issues. Please use the app from Google Chrome or Firefox. We hope 16 | to improve this as soon as possible. 17 | 18 |

19 | ) : ( 20 | 21 | ); 22 | }; 23 | 24 | export default SafariButtonWarning; 25 | -------------------------------------------------------------------------------- /src/modules/Board/hooks/use-board.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import BoardDocument from 'modules/Board/data/board.doc'; 4 | import { useSession } from 'hooks/use-session'; 5 | 6 | export const useBoard = (boardUid: string): [BoardDocument, boolean] => { 7 | const { userDoc } = useSession(); 8 | const [board, setBoard] = useState(null); 9 | const [isLoading, setIsLoading] = useState(false); 10 | 11 | useEffect(() => { 12 | if (board) return; 13 | 14 | setIsLoading(true); 15 | if (!userDoc) { 16 | setBoard(new BoardDocument(`boards/${boardUid}`)); 17 | } else if (userDoc && userDoc.boards) { 18 | setBoard(userDoc.boards.docs.find(d => d.id === boardUid)); 19 | } 20 | 21 | setIsLoading(board && board.isLoading); 22 | }, [board, boardUid, userDoc]); 23 | 24 | return [board, isLoading]; 25 | }; 26 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import nextCookies from 'next-cookies'; 2 | import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; 3 | 4 | import Home from 'components/pages/Home'; 5 | import LandingPage from 'modules/LandingPage'; 6 | import { useSession } from 'hooks/use-session'; 7 | 8 | export const getServerSideProps: GetServerSideProps = async (ctx) => { 9 | const cookies = nextCookies(ctx); 10 | const sessionToken = cookies['next-auth.session-token']; 11 | const authObject = cookies.auth; 12 | 13 | const hasAuth = !!sessionToken || !!authObject; 14 | 15 | return { 16 | props: { auth: hasAuth }, 17 | }; 18 | }; 19 | 20 | const Index: InferGetServerSidePropsType = ({ 21 | auth, 22 | }) => { 23 | const { user } = useSession(); 24 | 25 | return auth || user ? : ; 26 | }; 27 | 28 | export default Index; 29 | -------------------------------------------------------------------------------- /src/components/shared/BadgeTaskProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { MdDoneAll } from 'react-icons/md'; 4 | import cn from 'classnames'; 5 | 6 | import Badge from './Badge'; 7 | 8 | interface BadgeTaskProgressProps { 9 | total: number; 10 | checked: number; 11 | className?: string; 12 | } 13 | 14 | const BadgeTaskProgress: React.FC = props => { 15 | const { total, checked, className } = props; 16 | if (total === 0) { 17 | return null; 18 | } 19 | 20 | const bgColor = checked === total ? 'bg-green-500' : 'bg-gray-500'; 21 | 22 | return ( 23 | 24 | 25 |   26 | {checked}/{total} 27 | 28 | ); 29 | }; 30 | 31 | export default observer(BadgeTaskProgress); 32 | -------------------------------------------------------------------------------- /src/components/shared/Feedback/GiveFeedback.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | import UserDocument from 'documents/user.doc'; 4 | import { Collection } from 'firestorter'; 5 | 6 | import FeedbackInput from './FeedbackInput'; 7 | 8 | export const GiveFeedback = ({ user }: { user: UserDocument }) => { 9 | const feedbackRef = useRef(null); 10 | 11 | useEffect(() => { 12 | if (user) { 13 | feedbackRef.current = new Collection(`feedbacks`); 14 | } 15 | }, [user]); 16 | 17 | const createFeedback = async (props, done) => { 18 | try { 19 | await feedbackRef.current.add({ 20 | ...props, 21 | user: user.ref, 22 | createdAt: Date.now(), 23 | }); 24 | done(null); 25 | } catch (error) { 26 | done(error); 27 | } 28 | }; 29 | 30 | return ; 31 | }; 32 | -------------------------------------------------------------------------------- /src/modules/LandingPage/Header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | const Header = () => ( 4 |
5 |
9 |
10 |
11 |
12 |
13 | 14 | 15 | easyflow 16 | 17 | 18 |
19 |
20 |
21 |
22 |
23 |
24 | ); 25 | 26 | export default Header; 27 | -------------------------------------------------------------------------------- /src/modules/Card/data/card.doc.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'firestorter'; 2 | import firebase from 'firebase/app'; 3 | import 'firebase/firestore'; 4 | 5 | import UserDocument from 'documents/user.doc'; 6 | import ListDocument from 'documents/list.doc'; 7 | import ColorDocument from 'documents/color.doc'; 8 | 9 | export interface Card { 10 | id: string; 11 | title?: string; 12 | text: string; 13 | index: number; 14 | color: string; 15 | colorRef: ColorDocument['ref']; 16 | date: string | Date; 17 | completed: boolean; 18 | assignee: UserDocument['ref'][]; 19 | tags: string[]; 20 | createdAt: number; 21 | listBefore: ListDocument['ref']; 22 | listAfter: ListDocument['ref']; 23 | } 24 | 25 | export default class CardDocument extends Document { 26 | removeTag(tag: string) { 27 | return this.update({ 28 | tags: firebase.firestore.FieldValue.arrayRemove(tag), 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/shared/Empty/StartBoardEmpty.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { ScrumBoard } from 'components/shared/images/ScrumBoard'; 4 | import AddBoardModal from 'components/shared/AddBoardModal'; 5 | import Button from 'components/shared/Button'; 6 | import { Empty } from './Empty'; 7 | 8 | const AddBoardButton = () => { 9 | const [isOpen, setIsOpen] = useState(false); 10 | 11 | const toggle = () => setIsOpen(!isOpen); 12 | 13 | return ( 14 | <> 15 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export const StartProjectEmpty = () => ( 25 | } 27 | message='Create a board to start a project and get things done.' 28 | button={} 29 | messageClass='mt-8' 30 | /> 31 | ); 32 | -------------------------------------------------------------------------------- /src/modules/Board/hooks/use-board-team.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import BoardDocument from 'modules/Board/data/board.doc'; 4 | import { useUsersStore } from 'store'; 5 | import { User } from 'store/users'; 6 | 7 | export const useBoardTeam = (board: BoardDocument) => { 8 | const { loadUsers, isLoading, getUser, users } = useUsersStore(); 9 | const ownerRef = useRef(null); 10 | 11 | useEffect(() => { 12 | loadUsers(board.data.users); 13 | }, [board.data.users, loadUsers]); 14 | 15 | if (!board.data.owner) { 16 | return { 17 | assignees: [], 18 | owner: null, 19 | isLoading, 20 | }; 21 | } 22 | 23 | ownerRef.current = getUser(board.data.owner.id); 24 | const ids = (board.data.users && board.data.users.map(b => b.id)) || []; 25 | 26 | return { 27 | assignees: users.filter(u => ids.includes(u.id)), 28 | owner: ownerRef.current, 29 | isLoading, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/shared/List.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Children, 3 | isValidElement, 4 | DetailedHTMLProps, 5 | LiHTMLAttributes, 6 | HTMLAttributes, 7 | } from 'react'; 8 | import cn from 'classnames'; 9 | 10 | interface ListProps 11 | extends DetailedHTMLProps< 12 | HTMLAttributes, 13 | HTMLUListElement 14 | > {} 15 | 16 | export const List = ({ children, ...props }: ListProps) => ( 17 |
    18 | {Children.map(children, child => { 19 | if (!isValidElement(child)) return; 20 | 21 | return child; 22 | })} 23 |
24 | ); 25 | 26 | interface ListItemProps 27 | extends DetailedHTMLProps, HTMLLIElement> {} 28 | 29 | export const ListItem = ({ className, ...props }: ListItemProps) => ( 30 |
  • 37 | ); 38 | -------------------------------------------------------------------------------- /src/hooks/use-markdown-checkbox.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useRef } from 'react'; 3 | 4 | // identify the clicked checkbox by its index and give it a new checked attribute 5 | export const useMarkdownCheckbox = ( 6 | text: string 7 | ): (({ checked, index }: { checked: boolean; index: number }) => string) => { 8 | const jRef = useRef(0); 9 | const max = text?.match(/\[(\s|x)\]/g)?.length; 10 | 11 | const toggle = useCallback( 12 | ({ checked, index }) => 13 | text.replace(/\[(\s|x)\]/g, match => { 14 | let newString: string; 15 | 16 | if (index === jRef.current) { 17 | newString = checked ? '[x]' : '[ ]'; 18 | } else { 19 | newString = match; 20 | } 21 | jRef.current += 1; 22 | 23 | if (jRef.current === max) { 24 | jRef.current = 0; 25 | } 26 | 27 | return newString; 28 | }), 29 | [text, max] 30 | ); 31 | 32 | return toggle; 33 | }; 34 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardModal/CardOptionColors.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { KeyboardEvent } from 'react'; 3 | 4 | import { useBoardsStore } from 'store'; 5 | 6 | interface CardOptionColorsProps { 7 | onClick: (props: any) => void; 8 | onKeyDown: (event: KeyboardEvent) => void; 9 | } 10 | 11 | const CardOptionColors = ({ onClick, onKeyDown }: CardOptionColorsProps) => { 12 | const { colors } = useBoardsStore(); 13 | 14 | return colors.length > 0 ? ( 15 |
    16 | {colors.map(color => ( 17 | onClick({ ...color.data, ref: color.ref })} 23 | /> 24 | ))} 25 |
    26 | ) : null; 27 | }; 28 | 29 | export default observer(CardOptionColors); 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "experimentalDecorators": true, 5 | "target": "esnext", 6 | "module": "esnext", 7 | "jsx": "preserve", 8 | "allowJs": true, 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "emitDecoratorMetadata": true, 14 | "removeComments": false, 15 | "preserveConstEnums": true, 16 | "resolveJsonModule": true, 17 | "sourceMap": true, 18 | "skipLibCheck": true, 19 | "baseUrl": "./src", 20 | "lib": ["dom", "es2016"], 21 | "strict": false, 22 | "forceConsistentCasingInFileNames": true, 23 | "noEmit": true, 24 | "esModuleInterop": true, 25 | "isolatedModules": true 26 | }, 27 | "exclude": ["node_modules"], 28 | "include": [ 29 | "next-env.d.ts", 30 | "src/**/*.ts", 31 | "src/**/*.tsx", 32 | "src/pages/**/*.ts", 33 | "src/pages/**/*.tsx" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Next: Chrome", 8 | "url": "http://localhost:3000", 9 | "webRoot": "${workspaceFolder}" 10 | }, 11 | { 12 | "type": "node", 13 | "request": "launch", 14 | "name": "Next: Node", 15 | "runtimeExecutable": "${workspaceFolder}/node_modules/next/dist/bin/next", 16 | "runtimeArgs": [ 17 | ], 18 | "env": { 19 | "NODE_OPTIONS": "--inspect" 20 | }, 21 | "port": 9229, 22 | "console": "internalConsole" 23 | } 24 | ], 25 | "compounds": [ 26 | { 27 | "name": "Next: Full", 28 | "configurations": [ 29 | "Next: Node", 30 | "Next: Chrome" 31 | ] 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /src/hooks/use-thin-display.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState, useEffect } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | const useResizeObserver = (element, callback) => { 5 | useLayoutEffect(() => { 6 | if (!element || !element.current) { 7 | return; 8 | } 9 | 10 | let resizeObserver = new ResizeObserver(() => callback()); 11 | resizeObserver.observe(element.current); 12 | 13 | return () => { 14 | if (!resizeObserver) { 15 | return; 16 | } 17 | 18 | resizeObserver.disconnect(); 19 | resizeObserver = null; 20 | }; 21 | }, [element, callback]); 22 | }; 23 | 24 | export const useThinDisplay = () => { 25 | const [isThin, setIsThin] = useState(false); 26 | 27 | useResizeObserver({ current: window.document.scrollingElement }, () => 28 | setIsThin(window.innerWidth < 550) 29 | ); 30 | 31 | useEffect(() => { 32 | setIsThin(window.innerWidth < 550); 33 | }, []); 34 | 35 | return isThin; 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/shared/PopupMenu/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import PopupMenu, { PopupProvider } from '.'; 5 | 6 | const Overlay = styled.div` 7 | width: 100%; 8 | height: 100%; 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | background-color: rgba(0, 0, 0, 0.4); 13 | `; 14 | 15 | const Centered = styled.div` 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | width: 100%; 20 | position: relative; 21 | height: 100vh; 22 | `; 23 | 24 | export default { title: 'Molecules/PopupMenu', component: PopupMenu }; 25 | 26 | export const basicDialog = () => ( 27 | 28 | 29 | 30 | {}} 33 | top={0} 34 | left={0} 35 | > 36 |
    Feature
    37 |
    38 |
    39 |
    40 |
    41 | ); 42 | -------------------------------------------------------------------------------- /src/components/shared/Animated/AnimatedSlideUp.tsx: -------------------------------------------------------------------------------- 1 | import { animated, useSpring } from 'react-spring'; 2 | import { forwardRef, ReactChild } from 'react'; 3 | 4 | type Ref = HTMLDivElement; 5 | 6 | interface AnimatedSlideUpProps { 7 | show: boolean; 8 | children: ReactChild; 9 | className?: string; 10 | } 11 | 12 | export const AnimatedSlideUp = forwardRef( 13 | (props, ref) => { 14 | const { show, children, className } = props; 15 | 16 | const spring = useSpring({ 17 | from: { 18 | opacity: 0, 19 | transform: 'translate3d(0, 20px, 0)', 20 | freq: '0.0175, 0.0', 21 | }, 22 | to: { 23 | opacity: show ? 1 : 0, 24 | transform: show ? 'translate3d(0, 0, 0)' : 'translate3d(0, 20px, 0)', 25 | freq: '0.0, 0.0', 26 | }, 27 | config: { duration: 500 }, 28 | }); 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /src/documents/user.doc.ts: -------------------------------------------------------------------------------- 1 | import { Document, Collection } from 'firestorter'; 2 | 3 | import BoardDocument from 'modules/Board/data/board.doc'; 4 | 5 | interface User { 6 | email: string; 7 | photo: string; 8 | token: string; 9 | username: string; 10 | roles: { admin: boolean }; 11 | } 12 | export default class UserDocument extends Document { 13 | private _boards: Collection; 14 | 15 | get boards(): Collection { 16 | if (this._boards) return this._boards; 17 | 18 | this._boards = new Collection(() => 'boards', { 19 | createDocument: (src, opts) => 20 | new BoardDocument(src, { 21 | ...opts, 22 | debug: __DEV__, 23 | debugName: 'Board document', 24 | }), 25 | query: ref => 26 | ref 27 | .where('users', 'array-contains', this.ref) 28 | .where('archived', '==', false), 29 | debug: __DEV__, 30 | debugName: 'Board collection', 31 | }); 32 | 33 | return this._boards; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/shared/Toast/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from '@emotion/styled'; 3 | 4 | import Toast from '.'; 5 | 6 | const Overlay = styled.div` 7 | width: 100%; 8 | height: 100%; 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | background-color: rgba(0, 0, 0, 0.4); 13 | `; 14 | 15 | const Centered = styled.div` 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | width: 100%; 20 | position: relative; 21 | height: 100vh; 22 | `; 23 | 24 | export default { title: 'Molecules/Toast', component: Toast }; 25 | 26 | export const basicToast = () => ( 27 | 28 | 29 | {}} id={'0'} /> 30 | 31 | 32 | ); 33 | 34 | export const undoToast = () => ( 35 | 36 | 37 | {}} 40 | undo={() => {}} 41 | id={'0'} 42 | /> 43 | 44 | 45 | ); 46 | -------------------------------------------------------------------------------- /src/modules/LandingPage/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | const Footer = () => ( 4 | 34 | ); 35 | 36 | export default Footer; 37 | -------------------------------------------------------------------------------- /src/components/shared/AddTags/AddTags.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Document } from 'firestorter'; 3 | import firebase from 'firebase/app'; 4 | import { observer } from 'mobx-react-lite'; 5 | import 'firebase/firestore'; 6 | 7 | import TagsInput from 'react-tagsinput'; 8 | 9 | interface AddTagsProps { 10 | document: Document; 11 | } 12 | 13 | const AddTags = ({ document }: AddTagsProps) => { 14 | const [tags, setTags] = useState([]); 15 | const [isSubmit, setIsSubmit] = useState(false); 16 | 17 | const splitTags = tagsSet => tagsSet.join(',').split(','); 18 | 19 | const handleChange = async newTags => { 20 | setTags(newTags); 21 | setIsSubmit(true); 22 | await document.update({ 23 | tags: firebase.firestore.FieldValue.arrayUnion(...splitTags(newTags)), 24 | }); 25 | setIsSubmit(false); 26 | }; 27 | 28 | return ( 29 | null} 33 | disabled={isSubmit} 34 | /> 35 | ); 36 | }; 37 | 38 | export default observer(AddTags); 39 | -------------------------------------------------------------------------------- /src/modules/Activity/components/Activities.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import BoardDocument from 'modules/Board/data/board.doc'; 4 | import { useUsersStore } from 'store'; 5 | import ActivityCard, { 6 | ActivityCardPlaceholder, 7 | } from 'modules/Activity/components/ActivityCard'; 8 | 9 | interface ActivitiesProps { 10 | board: BoardDocument; 11 | } 12 | 13 | const Activities = ({ board }: ActivitiesProps) => { 14 | const { getUser } = useUsersStore(); 15 | 16 | return ( 17 |
    18 | {board.actions.isLoading ? ( 19 | <> 20 | 21 | 22 | 23 | 24 | ) : ( 25 | board.actions.docs.map(action => ( 26 | 31 | )) 32 | )} 33 |
    34 | ); 35 | }; 36 | 37 | export default observer(Activities); 38 | -------------------------------------------------------------------------------- /src/documents/list.doc.ts: -------------------------------------------------------------------------------- 1 | import { Document, Collection } from 'firestorter'; 2 | 3 | import CardDocument from 'modules/Card/data/card.doc'; 4 | import UserDocument from 'documents/user.doc'; 5 | 6 | export interface List { 7 | title: string; 8 | index: number; 9 | uid: string; 10 | color: string; 11 | owner: UserDocument['ref']; 12 | public: boolean; 13 | tags: string[]; 14 | users: UserDocument['ref'][]; 15 | cardsLimit: number; 16 | } 17 | 18 | export default class ListDocument extends Document { 19 | private _cards: Collection; 20 | 21 | get cards(): Collection { 22 | if (this._cards) return this._cards; 23 | 24 | this._cards = new Collection(() => `${this.path}/cards`, { 25 | createDocument: (src, opts) => 26 | new CardDocument(src, { 27 | ...opts, 28 | debug: __DEV__, 29 | debugName: 'Card document', 30 | }), 31 | query: ref => ref.orderBy('index'), 32 | debug: __DEV__, 33 | debugName: 'Card collection', 34 | }); 35 | 36 | return this._cards; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardModal/CardModal.scss: -------------------------------------------------------------------------------- 1 | .modal-underlay { 2 | position: fixed; 3 | left: 0; 4 | right: 0; 5 | top: 0; 6 | bottom: 0; 7 | background: rgba(0, 0, 0, 0.8); 8 | } 9 | 10 | .modal { 11 | position: absolute; 12 | display: flex; 13 | align-items: flex-start; 14 | outline: 0; 15 | } 16 | 17 | .modal-textarea-wrapper { 18 | display: flex; 19 | justify-content: space-between; 20 | flex-shrink: 0; 21 | flex-direction: column; 22 | box-sizing: border-box; 23 | margin-bottom: 6px; 24 | border-radius: 3px; 25 | background: white; 26 | transition: background 0.2s; 27 | } 28 | .modal-textarea-wrapper .DueComplete { 29 | display: inline-block; 30 | } 31 | 32 | .modal-textarea { 33 | flex-grow: 1; 34 | box-sizing: border-box; 35 | width: 100%; 36 | padding: 10px 8px; 37 | border: 0; 38 | border-radius: inherit; 39 | color: inherit; 40 | background: inherit; 41 | font-family: inherit; 42 | font-size: 14px; 43 | line-height: inherit; 44 | resize: none; 45 | outline: none; 46 | 47 | scrollbar-width: thin; 48 | scrollbar-color: #4a5568 #a0aec0; 49 | } 50 | -------------------------------------------------------------------------------- /src/components/shared/Loader.tsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | const rotate = keyframes` 4 | from { 5 | transform: rotate(0deg); 6 | } 7 | 8 | to { 9 | transform: rotate(360deg); 10 | } 11 | `; 12 | 13 | const Loader = styled.div` 14 | position: fixed; 15 | top: 0; 16 | right: 0; 17 | bottom: 0; 18 | left: 0; 19 | background-color: rgba(0, 0, 0, 0.47058823529411764); 20 | opacity: 0.8; 21 | z-index: 1; 22 | 23 | &::before { 24 | content: ''; 25 | width: 3rem; 26 | height: 3rem; 27 | border: 0.5rem solid rgba(255, 255, 255, 0.4); 28 | border-right-color: rgba(255, 255, 255, 0.8); 29 | border-radius: 50%; 30 | box-sizing: border-box; 31 | position: absolute; 32 | top: 50%; 33 | left: 50%; 34 | margin-top: -1rem; 35 | margin-left: -1rem; 36 | animation: ${rotate} 2s linear infinite; 37 | } 38 | 39 | &::after { 40 | content: 'We are loading your content...'; 41 | color: white; 42 | position: absolute; 43 | top: 55%; 44 | left: calc(50% - 100px); 45 | } 46 | `; 47 | 48 | export default Loader; 49 | -------------------------------------------------------------------------------- /src/modules/Card/hooks/use-card-assignees.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import CardDocument from 'modules/Card/data/card.doc'; 4 | import { useUsersStore } from 'store'; 5 | import { sortUsersAlpha } from 'helpers/sortUsersAlpha'; 6 | 7 | export const useCardAssignees = (card: CardDocument) => { 8 | const { users, loadUsers } = useUsersStore(); 9 | const [assignees, setAssignees] = useState([]); 10 | 11 | useEffect(() => { 12 | // (backwards compatibility) 13 | if (card.data.assignee && !Array.isArray(card.data.assignee)) { 14 | card.data.assignee = [card.data.assignee]; 15 | } 16 | 17 | if (card.data.assignee) { 18 | loadUsers(card.data.assignee); 19 | } 20 | }, [card.data.assignee, loadUsers]); 21 | 22 | useEffect(() => { 23 | if (card.data.assignee) { 24 | const ids = card.data.assignee.map(a => a.id); 25 | const assignees = users 26 | .filter(u => ids.includes(u.id)) 27 | .sort(sortUsersAlpha); 28 | 29 | setAssignees(assignees); 30 | } 31 | }, [users, card.data.assignee]); 32 | 33 | return { assignees }; 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Fellipe Pinheiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/components/shared/Input.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | InputGroup, 3 | InputRightElement, 4 | Input as ChakraInput, 5 | InputProps as ChakraInputProps, 6 | } from '@chakra-ui/react'; 7 | import { Spinner } from './Spinner'; 8 | 9 | export interface InputProps extends ChakraInputProps { 10 | isLoading?: boolean; 11 | isFullWidth?: boolean; 12 | } 13 | 14 | const Input = ({ isFullWidth = true, isLoading, ...props }: InputProps) => ( 15 | 16 | 34 | 35 | {isLoading && ( 36 | 37 | 38 | 39 | )} 40 | 41 | ); 42 | 43 | export default Input; 44 | -------------------------------------------------------------------------------- /src/modules/Board/components/BoardHeader/AddTagsModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import BoardDocument from 'modules/Board/data/board.doc'; 5 | import TagList from 'components/shared/TagList'; 6 | import AddTags from 'components/shared/AddTags/AddTags'; 7 | import Dialog from 'components/shared/Dialog'; 8 | 9 | interface AddTagsModalProps { 10 | board: BoardDocument; 11 | isOpen?: boolean; 12 | toggleIsOpen: () => void; 13 | } 14 | 15 | const AddTagsModal = ({ board, toggleIsOpen, isOpen }: AddTagsModalProps) => { 16 | const handleRemoveClick = (tag: string) => { 17 | board.removeTag(tag); 18 | }; 19 | 20 | return ( 21 | 22 |
    23 | 24 | 25 | {board && board.data.tags && ( 26 | 32 | )} 33 |
    34 |
    35 | ); 36 | }; 37 | 38 | export default observer(AddTagsModal); 39 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardBasic.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactChild } from 'react'; 2 | import cn from 'classnames'; 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | export interface CardBasicProps { 6 | bgColor: string; 7 | previewMode?: boolean; 8 | isHidden?: boolean; 9 | renderBadges?: () => ReactChild; 10 | onClick?: (e: React.MouseEvent) => void; 11 | onKeyDown?: (event: React.KeyboardEvent) => void; 12 | } 13 | 14 | const CardBasic: FC = ({ 15 | children, 16 | previewMode, 17 | bgColor, 18 | isHidden, 19 | renderBadges, 20 | onClick, 21 | onKeyDown, 22 | }) => { 23 | return ( 24 |
    34 |
    {children}
    35 | 36 | {renderBadges && renderBadges()} 37 |
    38 | ); 39 | }; 40 | 41 | export default observer(CardBasic); 42 | -------------------------------------------------------------------------------- /src/components/shared/BadgeDueDate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { MdAlarm } from 'react-icons/md'; 4 | 5 | import { Badge, Checkbox } from 'components/shared'; 6 | import { useDueDate } from 'hooks/use-due-date'; 7 | 8 | interface BadgeDueDateProps { 9 | date: any; 10 | completed?: boolean; 11 | id: string; 12 | showCheckbox?: boolean; 13 | onComplete: (completed: boolean) => void; 14 | } 15 | 16 | const BadgeDueDate: React.FC = props => { 17 | const { date, completed, id, showCheckbox, onComplete } = props; 18 | const { dueDateColor, dueTitle, dueDateString } = useDueDate(date, completed); 19 | 20 | return ( 21 | date && ( 22 | 23 | 24 |   25 | {dueDateString} 26 |   27 | {showCheckbox && ( 28 | onComplete(e.target.checked)} 32 | /> 33 | )} 34 | 35 | ) 36 | ); 37 | }; 38 | 39 | export default observer(BadgeDueDate); 40 | -------------------------------------------------------------------------------- /src/hooks/use-undo.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react'; 2 | 3 | import { useAppToast } from './use-app-toast'; 4 | 5 | interface Props { 6 | onAction?: () => void; 7 | onCloseComplete: () => void; 8 | toastId?: string; 9 | toastTitle: string; 10 | } 11 | 12 | export const useUndo = ({ 13 | onAction, 14 | onCloseComplete, 15 | toastId, 16 | toastTitle, 17 | }: Props): { action: () => void; isHidden: boolean } => { 18 | const toast = useAppToast(); 19 | const isHiddenRef = useRef(false); 20 | const [, forceUpdate] = useState(false); 21 | 22 | const _onClose = useCallback(() => { 23 | if (!isHiddenRef.current) return; 24 | 25 | onCloseComplete(); 26 | }, [onCloseComplete]); 27 | 28 | const undo = useCallback(() => { 29 | isHiddenRef.current = false; 30 | forceUpdate(s => !s); 31 | }, []); 32 | 33 | const action = useCallback(() => { 34 | isHiddenRef.current = true; 35 | onAction?.(); 36 | 37 | toast({ 38 | id: toastId, 39 | title: toastTitle, 40 | onCloseComplete: _onClose, 41 | undo, 42 | }); 43 | }, [onAction, _onClose, undo, toast, toastTitle]); 44 | 45 | return { action, isHidden: isHiddenRef.current }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/shared/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { memo, DetailedHTMLProps, ImgHTMLAttributes } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | type Size = 'small' | 'medium' | 'big'; 5 | 6 | const sizes = { 7 | small: 'h-6 w-6', 8 | medium: 'h-8 w-8', 9 | big: 'h-10 w-10', 10 | }; 11 | 12 | interface AvatarProps 13 | extends DetailedHTMLProps< 14 | ImgHTMLAttributes, 15 | HTMLImageElement 16 | > { 17 | username: string; 18 | borderColor?: string; 19 | boxShadowColor?: string; 20 | size?: Size; 21 | } 22 | 23 | const getBorder = (borderColor: string) => 24 | borderColor ? `border-2 border-solid ${borderColor}` : ''; 25 | 26 | const Avatar = memo( 27 | ({ 28 | username, 29 | borderColor, 30 | className = '', 31 | size = 'medium', 32 | src = '/static/images/default-avatar.png', 33 | ...props 34 | }: AvatarProps) => ( 35 | {`Avatar 47 | ) 48 | ); 49 | 50 | export default Avatar; 51 | -------------------------------------------------------------------------------- /src/components/shared/Tag.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | interface TagProps { 5 | title: string; 6 | color?: string; 7 | removable?: boolean; 8 | className?: string; 9 | onClick?(tag: string): void; 10 | } 11 | 12 | const Tag = ({ 13 | title, 14 | color = 'purple', 15 | onClick, 16 | removable, 17 | className, 18 | }: TagProps) => { 19 | const handleClick = () => onClick(title); 20 | 21 | const textColor = `text-${color}-700`; 22 | 23 | return ( 24 |
    33 | 36 | {title} 37 | 45 | x 46 | 47 |
    48 | ); 49 | }; 50 | 51 | export default memo(Tag); 52 | -------------------------------------------------------------------------------- /src/components/shared/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Menu, { MenuItem, Button } from './Menu'; 4 | import { useSession } from 'hooks/use-session'; 5 | 6 | interface UserMenuProps { 7 | trigger: React.ReactChild; 8 | userName: string; 9 | userEmail: string; 10 | } 11 | 12 | const UserName = ({ name }: { name: string }) => ( 13 | {name} 14 | ); 15 | const UserEmail = ({ email }: { email: string }) => ( 16 | {email} 17 | ); 18 | 19 | const UserMenu = ({ trigger, userName, userEmail }: UserMenuProps) => { 20 | const { logout } = useSession(); 21 | 22 | return ( 23 | {trigger}} 25 | header={ 26 | <> 27 |
    28 | 29 |
    30 |
    31 | 32 |
    33 | 34 | } 35 | items={ 36 | 40 | Sign out 41 | 42 | } 43 | /> 44 | ); 45 | }; 46 | 47 | export default UserMenu; 48 | -------------------------------------------------------------------------------- /src/ui/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react'; 2 | import { Theme as CharkaTheme } from '@chakra-ui/theme'; 3 | import { colors } from './colors'; 4 | import { systemFontStack, systemMonoFontStack } from './fonts'; 5 | 6 | export const customTheme: CharkaTheme = extendTheme({ 7 | fonts: { 8 | body: systemFontStack, 9 | heading: systemFontStack, 10 | mono: systemMonoFontStack, 11 | }, 12 | colors: { 13 | ...colors, 14 | }, 15 | shadows: { 16 | sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 17 | md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', 18 | lg: 19 | '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 20 | xl: 21 | '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 22 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 23 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', 24 | outline: '0 0 0 3px rgba(237, 100, 166, 0.5)', 25 | none: 'none', 26 | }, 27 | styles: { 28 | global: { 29 | body: { 30 | backgroundColor: 'gray.800', 31 | color: 'gray.200', 32 | fontFamily: 'body', 33 | fontsize: '16px', 34 | }, 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'react-hooks'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/eslint-recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:react/recommended', 10 | 'prettier/react', 11 | 'prettier/@typescript-eslint', 12 | ], 13 | rules: { 14 | '@typescript-eslint/explicit-function-return-type': 'off', 15 | '@typescript-eslint/ban-ts-ignore': 'off', 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/no-empty-interface': 'off', 19 | 'react/react-in-jsx-scope': 'off', 20 | 'react/display-name': 'off', 21 | 'no-use-before-define': ['error', { functions: false }], 22 | 'react-hooks/rules-of-hooks': 'error', 23 | 'react-hooks/exhaustive-deps': 'warn', 24 | 'react/prop-types': 'off', 25 | '@typescript-eslint/no-empty-function': 'off', 26 | }, 27 | settings: { 28 | react: { 29 | pragma: 'React', // Pragma to use, default to "React" 30 | version: 'detect', // React version. "detect" automatically picks the version you have installed. 31 | }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/shared/AddBoardModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import { useSession } from 'hooks/use-session'; 5 | import BoardDocument from 'modules/Board/data/board.doc'; 6 | import { useAppToast } from 'hooks/use-app-toast'; 7 | import Dialog from './Dialog'; 8 | import NewBoardForm from './NewBoardForm'; 9 | 10 | interface AddBoardModalProps { 11 | isOpen?: boolean; 12 | toggleIsOpen?(): void; 13 | } 14 | 15 | const AddBoardModal = ({ toggleIsOpen, isOpen }: AddBoardModalProps) => { 16 | const toast = useAppToast(); 17 | const { userDoc } = useSession(); 18 | 19 | const onSubmit = async props => { 20 | const { title, code, index } = props; 21 | 22 | await BoardDocument.create({ 23 | owner: userDoc.ref, 24 | users: [userDoc.ref], 25 | title, 26 | code, 27 | index, 28 | }) 29 | .then(() => toast({ id: code, title: 'A new board was created!' })) 30 | .finally(toggleIsOpen); 31 | }; 32 | 33 | return ( 34 | 35 |
    36 | 37 |
    38 |
    39 | ); 40 | }; 41 | 42 | export default observer(AddBoardModal); 43 | -------------------------------------------------------------------------------- /src/modules/Dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { Collection } from 'firestorter'; 3 | 4 | import BoardDocument from 'modules/Board/data/board.doc'; 5 | import { AnimatedOpacity } from 'components/shared/Animated/AnimatedOpacity'; 6 | import { StartProjectEmpty } from 'components/shared/Empty/StartBoardEmpty'; 7 | import { BoardAdder } from './components/BoardAdder'; 8 | import { Box } from '@chakra-ui/react'; 9 | import { BoardCard } from './components/BoardCard'; 10 | import { UsersStoreProvider } from 'store'; 11 | 12 | interface BoardsProps { 13 | boards: Collection; 14 | } 15 | 16 | const Dashboard = ({ boards }: BoardsProps) => { 17 | const showEmpty = !boards?.docs.length && !boards?.isLoading; 18 | 19 | if (showEmpty) { 20 | return ( 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | {boards.docs.map((board) => ( 33 | 34 | ))} 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default observer(Dashboard); 41 | -------------------------------------------------------------------------------- /src/modules/Auth/components/FirebaseAuth.tsx: -------------------------------------------------------------------------------- 1 | /* globals window */ 2 | import { useEffect, useState } from 'react'; 3 | import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth'; 4 | import firebase from 'firebase/app'; 5 | import 'firebase/auth'; 6 | import cookie from 'js-cookie'; 7 | 8 | import { normalizeCookieUser } from 'modules/Auth/components/SessionProvider'; 9 | 10 | const firebaseAuthConfig = { 11 | signInFlow: 'popup', 12 | signInOptions: [ 13 | { 14 | provider: firebase.auth.GoogleAuthProvider.PROVIDER_ID, 15 | }, 16 | ], 17 | callbacks: { 18 | signInSuccessWithAuthResult: ({ user }) => { 19 | cookie.set('auth', normalizeCookieUser(user)); 20 | 21 | return false; 22 | }, 23 | }, 24 | }; 25 | 26 | const FirebaseAuth = () => { 27 | const [renderAuth, setRenderAuth] = useState(false); 28 | 29 | useEffect(() => { 30 | if (typeof window !== 'undefined') { 31 | setRenderAuth(true); 32 | } 33 | // eslint-disable-next-line react-hooks/exhaustive-deps 34 | }, []); 35 | 36 | return ( 37 |
    38 | {renderAuth ? ( 39 | 43 | ) : null} 44 |
    45 | ); 46 | }; 47 | 48 | export default FirebaseAuth; 49 | -------------------------------------------------------------------------------- /src/components/shared/Empty/CreateContentEmpty.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import BoardDocument from 'modules/Board/data/board.doc'; 5 | import AddNewListModal from 'modules/Board/components/BoardHeader/AddNewListModal'; 6 | import { CreateContent } from 'components/shared/images/CreateContent'; 7 | import Button from 'components/shared/Button'; 8 | import { Empty } from './Empty'; 9 | 10 | interface AddNewListButtonProps { 11 | board: BoardDocument; 12 | } 13 | 14 | const AddNewListButton = observer(({ board }: AddNewListButtonProps) => { 15 | const [isOpen, setIsOpen] = useState(false); 16 | 17 | const toggle = () => setIsOpen(!isOpen); 18 | 19 | return ( 20 | <> 21 | 24 | 25 | 26 | 27 | ); 28 | }); 29 | 30 | interface CreateContentEmptyProps { 31 | board: BoardDocument; 32 | } 33 | 34 | export const CreateContentEmpty = observer( 35 | ({ board }: CreateContentEmptyProps) => ( 36 | } 38 | message={`Let's start a new board! Add a new list to continue.`} 39 | button={} 40 | /> 41 | ) 42 | ); 43 | -------------------------------------------------------------------------------- /src/hooks/use-rect.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useCallback, useState } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | function getRect(element) { 5 | if (element && element.getBoundingClientRect) { 6 | return element.getBoundingClientRect(); 7 | } 8 | 9 | return { 10 | bottom: 0, 11 | height: 0, 12 | left: 0, 13 | right: 0, 14 | top: 0, 15 | width: 0, 16 | }; 17 | } 18 | 19 | export const useRect = (ref): [ClientRect, () => void] => { 20 | const [rect, setRect] = useState( 21 | getRect(ref && ref.current ? ref.current : undefined) 22 | ); 23 | 24 | const refreshRect = useCallback(() => { 25 | if (!(ref && ref.current)) { 26 | return; 27 | } 28 | 29 | // Update client rect 30 | setRect(getRect(ref.current)); 31 | }, [ref]); 32 | 33 | useLayoutEffect(() => { 34 | if (!ref || !ref.current) { 35 | return; 36 | } 37 | 38 | refreshRect(); 39 | 40 | let resizeObserver = new ResizeObserver(() => refreshRect()); 41 | resizeObserver.observe(ref.current); 42 | 43 | return () => { 44 | if (!resizeObserver) { 45 | return; 46 | } 47 | 48 | resizeObserver.disconnect(); 49 | resizeObserver = null; 50 | }; 51 | }, [ref, refreshRect]); 52 | 53 | return [rect, refreshRect]; 54 | }; 55 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardModal/CardOptions.scss: -------------------------------------------------------------------------------- 1 | .modal-icon { 2 | flex-shrink: 0; 3 | margin-bottom: 3px; 4 | margin-right: 0.75rem; 5 | } 6 | 7 | .calendar-modal { 8 | position: absolute; 9 | left: 50%; 10 | top: 50%; 11 | } 12 | 13 | .calendar-underlay { 14 | position: fixed; 15 | top: 0; 16 | left: 0; 17 | right: 0; 18 | bottom: 0; 19 | z-index: 3; 20 | } 21 | 22 | .calendar { 23 | display: flex; 24 | flex-direction: column; 25 | border-radius: 3px; 26 | } 27 | 28 | .calendar-buttons { 29 | button { 30 | width: 120px; 31 | height: 30px; 32 | border: 0; 33 | border-radius: 3px; 34 | font-size: 14px; 35 | font-weight: 700; 36 | transition: background 0.2s; 37 | cursor: pointer; 38 | } 39 | } 40 | 41 | .modal-color-picker { 42 | position: absolute; 43 | top: 100%; 44 | left: 5%; 45 | display: flex; 46 | flex-wrap: wrap; 47 | width: 90%; 48 | padding: 5px; 49 | border-radius: 3px; 50 | color: black; 51 | background: #718096; 52 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 53 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 54 | font-weight: 700; 55 | text-align: center; 56 | z-index: 1; 57 | } 58 | 59 | .color-picker-color { 60 | width: 36px; 61 | height: 36px; 62 | margin: 2px; 63 | border: 1px #999 solid; 64 | border-radius: 3px; 65 | } 66 | 67 | .color-picker-color:hover { 68 | border: 1px #fff solid; 69 | } 70 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardModal/CardMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaTrash, FaEllipsisH } from 'react-icons/fa'; 3 | 4 | import Menu, { MenuItem, Button } from 'components/shared/Menu'; 5 | 6 | interface CardMenuProps { 7 | title: string; 8 | onRemove: (title: string) => void; 9 | } 10 | 11 | const CardMenu: React.FC = props => { 12 | const { title, onRemove } = props; 13 | 14 | const handleSelection = (value: string) => { 15 | switch (value) { 16 | case 'delete': 17 | onRemove(title); 18 | break; 19 | 20 | default: 21 | break; 22 | } 23 | }; 24 | 25 | return ( 26 | <> 27 | 35 | 36 | 37 | } 38 | items={ 39 | <> 40 | {/* */} 41 | 42 | 43 | Delete this card 44 | 45 | 46 | } 47 | /> 48 | 49 | ); 50 | }; 51 | 52 | export default CardMenu; 53 | -------------------------------------------------------------------------------- /src/modules/Card/components/CardMarkdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import MarkdownText from 'components/shared/MarkdownText'; 5 | import { useMarkdownCheckbox } from 'hooks/use-markdown-checkbox'; 6 | import CardBasic, { CardBasicProps } from './CardBasic'; 7 | 8 | interface CardMarkdownProps extends CardBasicProps { 9 | text: string; 10 | onChangeCheckbox: (text: string) => void; 11 | } 12 | 13 | const isLink = (tagName: string) => tagName.toLowerCase() === 'a'; 14 | const isInput = (tagName: string) => tagName.toLowerCase() === 'input'; 15 | 16 | const CardMarkdown: FC = ({ 17 | text, 18 | onChangeCheckbox, 19 | ...props 20 | }) => { 21 | const toggleCheckbox = useMarkdownCheckbox(text); 22 | 23 | const changeCheckbox = (checked: boolean, index: number) => { 24 | const newText = toggleCheckbox({ 25 | checked, 26 | index, 27 | }); 28 | 29 | onChangeCheckbox(newText); 30 | }; 31 | 32 | const handleClick = e => { 33 | const { tagName, checked, id } = e.target; 34 | 35 | if (isInput(tagName)) { 36 | changeCheckbox(checked, parseInt(id)); 37 | } else if (!isLink(tagName)) { 38 | props.onClick && props.onClick(e); 39 | } 40 | }; 41 | 42 | return ( 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default observer(CardMarkdown); 50 | -------------------------------------------------------------------------------- /src/components/providers/InterfaceProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, PropsWithChildren } from 'react'; 2 | 3 | interface InterfaceContextProps { 4 | isMenuOpen: boolean; 5 | setMenu: (value: boolean) => void; 6 | toggleMenu: () => void; 7 | previewMode: boolean; 8 | togglePreviewMode: () => void; 9 | setPreviewMode: (value: boolean) => void; 10 | hasOpenedModal: boolean; 11 | setOpenedModal: (value: boolean) => void; 12 | } 13 | 14 | export const InterfaceContext = createContext(null); 15 | 16 | export const InterfaceProvider = (props: PropsWithChildren<{}>) => { 17 | const [isMenuOpen, setMenu] = useState(); 18 | const [previewMode, setPreviewMode] = useState(); 19 | const [hasOpenedModal, setOpenedModal] = useState(false); 20 | 21 | const togglePreviewMode = () => setPreviewMode(s => !s); 22 | const toggleMenu = () => setMenu(s => !s); 23 | 24 | return ( 25 | 38 | ); 39 | }; 40 | 41 | export const useInterface = () => { 42 | const context = useContext(InterfaceContext); 43 | 44 | if (context === undefined) { 45 | throw new Error('useInterface must be used within a InterfaceProvider'); 46 | } 47 | return context; 48 | }; 49 | -------------------------------------------------------------------------------- /src/services/firebase.service.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | import { initFirestorter } from 'firestorter'; 4 | 5 | import { Activity } from 'modules/Activity/domain/activity'; 6 | 7 | const config = { 8 | apiKey: process.env.REACT_APP_API_KEY, 9 | authDomain: process.env.REACT_APP_AUTH_DOMAIN, 10 | databaseURL: process.env.REACT_APP_DATABASE_URL, 11 | projectId: process.env.REACT_APP_PROJECT_ID, 12 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET, 13 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID, 14 | appId: process.env.REACT_APP_ID, 15 | }; 16 | 17 | if (!firebase.apps.length) { 18 | firebase.initializeApp(config); 19 | 20 | // @ts-ignore 21 | initFirestorter({ firebase }); 22 | } 23 | class FirebaseService { 24 | app: firebase.app.App; 25 | db: firebase.firestore.Firestore; 26 | 27 | constructor() { 28 | /* Firebase APIs */ 29 | this.db = firebase.firestore(); 30 | } 31 | 32 | getUsers = () => this.db.collection('users'); 33 | 34 | getBoards = () => this.db.collection('boards'); 35 | 36 | getBoardInvites = () => this.db.collection('board_invites'); 37 | 38 | getUser = (email: string) => this.getUsers().doc(email); 39 | 40 | getBoard = (id: string) => this.getBoards().doc(id); 41 | 42 | getBoardInvite = (id: string) => this.getBoardInvites().doc(id); 43 | 44 | createActivity = (activity: Activity) => { 45 | return this.db.collection('actions').add(activity); 46 | }; 47 | } 48 | 49 | export default new FirebaseService(); 50 | -------------------------------------------------------------------------------- /src/components/shared/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import { CloseButton, Flex, Text } from '@chakra-ui/react'; 2 | import Button from '../Button'; 3 | 4 | interface ToastProps { 5 | title: string; 6 | id: string | number; 7 | isClosable?: boolean; 8 | undo?: (id: string | number) => void; 9 | closeToast?: () => void; 10 | onCloseComplete?: () => void; 11 | } 12 | 13 | const Toast = ({ 14 | title, 15 | id, 16 | isClosable = true, 17 | undo, 18 | closeToast, 19 | onCloseComplete, 20 | }: ToastProps) => { 21 | const handleUndo = () => { 22 | undo(id); 23 | closeToast(); 24 | }; 25 | 26 | const handleClose = () => { 27 | closeToast(); 28 | onCloseComplete?.(); 29 | }; 30 | 31 | return ( 32 | 33 | 40 | 41 | {title}{' '} 42 | 43 | 44 | {undo && ( 45 | 53 | )} 54 | 55 | {isClosable && ( 56 | 61 | )} 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default Toast; 68 | -------------------------------------------------------------------------------- /src/hooks/use-click-outside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { usePrevious } from './use-previous'; 3 | 4 | function isInDOM(obj) { 5 | return Boolean(obj.closest('body')); 6 | } 7 | 8 | function hasParent(element, root) { 9 | return root.contains(element) && isInDOM(element); 10 | } 11 | 12 | export const useClickOutside = ({ element, active, onClick }) => { 13 | const previousActive = usePrevious(active); 14 | 15 | useEffect(() => { 16 | if (!element) return; 17 | 18 | const handleClick = event => { 19 | const { current } = element; 20 | 21 | if (!current) return; 22 | 23 | if (!hasParent(event.target, current)) { 24 | if (typeof onClick === 'function') { 25 | onClick(event); 26 | } 27 | } 28 | }; 29 | 30 | if (active) { 31 | document.addEventListener('mousedown', handleClick); 32 | document.addEventListener('touchstart', handleClick); 33 | } 34 | 35 | if (!previousActive && active) { 36 | document.addEventListener('mousedown', handleClick); 37 | document.addEventListener('touchstart', handleClick); 38 | } 39 | 40 | if (previousActive && !active) { 41 | document.removeEventListener('mousedown', handleClick); 42 | document.removeEventListener('touchstart', handleClick); 43 | } 44 | 45 | return () => { 46 | if (active) { 47 | document.removeEventListener('mousedown', handleClick); 48 | document.removeEventListener('touchstart', handleClick); 49 | } 50 | }; 51 | }, [active, element, onClick, previousActive]); 52 | }; 53 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { HeadProvider, Link } from 'react-head'; 4 | import Router from 'next/router'; 5 | import { ToastContainer } from 'react-toastify'; 6 | import { ChakraProvider } from '@chakra-ui/react'; 7 | import 'react-toastify/dist/ReactToastify.min.css'; 8 | 9 | import Header from 'components/Header'; 10 | import { initGA, logPageView } from 'libs/analytics'; 11 | import { InterfaceProvider } from 'components/providers/InterfaceProvider'; 12 | import { SessionProvider } from 'modules/Auth/components/SessionProvider'; 13 | import { customTheme } from 'ui/theme'; 14 | 15 | import 'services/firebase.service'; 16 | import 'styles/style.css'; 17 | 18 | function MyApp({ Component, pageProps }) { 19 | useEffect(() => { 20 | initGA(); 21 | Router.router.events.on('routeChangeComplete', (url) => logPageView(url)); 22 | }, []); 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 |
    31 | 32 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default observer(MyApp); 45 | -------------------------------------------------------------------------------- /src/libs/emitter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventEmitter as Emitter, 3 | EventSubscription as Susbcription, 4 | } from 'fbemitter'; 5 | 6 | export type EventEmitter = Emitter; 7 | export type EventSubscription = Susbcription; 8 | 9 | export interface EmitterTypes { 10 | ASSIGNEE_UPDATED: { cardId: string }; 11 | TEAM_MEMBER_UPDATED: {}; 12 | REMOVE_CARD: { 13 | title: string; 14 | text: string; 15 | listId: string; 16 | }; 17 | COMPLETE_CARD: { 18 | title: string; 19 | listId: string; 20 | completed: boolean; 21 | cardId: string; 22 | }; 23 | ASSIGNE_CARD: { 24 | title: string; 25 | listId: string; 26 | assigneId: string; 27 | cardId: string; 28 | }; 29 | ADD_CARD: { 30 | title: string; 31 | listId: string; 32 | cardId: string; 33 | }; 34 | EDIT_CARD: { 35 | oldText: string; 36 | newText: string; 37 | oldTitle: string; 38 | newTitle: string; 39 | listId: string; 40 | cardId: string; 41 | }; 42 | MOVE_CARD: { 43 | listBeforeId: string; 44 | listAfterId: string; 45 | cardId: string; 46 | title: string; 47 | }; 48 | } 49 | 50 | const _emitter = new Emitter(); 51 | 52 | export const emitter = { 53 | addListener( 54 | key: K, 55 | listener: (payload: EmitterTypes[K]) => void 56 | ) { 57 | return _emitter.addListener(key, listener); 58 | }, 59 | emit(key: K, payload: EmitterTypes[K]) { 60 | if (__DEV__) console.debug('[EMITTER]', key, payload); // tslint:disable-line no-console 61 | _emitter.emit(key, payload); 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/shared/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { DetailedHTMLProps, ButtonHTMLAttributes } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | type Size = 'small' | 'medium'; 5 | type Variant = 'solid' | 'secondary' | 'ghost' | 'ghost-danger'; 6 | 7 | const sizes = { 8 | small: 'px-2 py-2 text-sm', 9 | medium: 'px-4 md:px-5 xl:px-4 py-3 md:py-4 xl:py-3 md:text-lg', 10 | }; 11 | 12 | const variants = { 13 | solid: 'bg-pink-500 hover:bg-pink-600 shadow-md', 14 | secondary: 15 | 'bg-transparent rounded border border-transparent text-white hover:border-white', 16 | ghost: 17 | 'bg-transparent rounded text-pink-500 hover:bg-pink-500 hover:text-white', 18 | 'ghost-danger': 19 | 'bg-transparent rounded text-red-500 hover:bg-red-500 hover:text-white', 20 | disabled: 'bg-pink-400 cursor-not-allowed text-gray-200', 21 | }; 22 | 23 | interface ButtonProps 24 | extends DetailedHTMLProps< 25 | ButtonHTMLAttributes, 26 | HTMLButtonElement 27 | > { 28 | size?: Size; 29 | variant?: Variant; 30 | block?: boolean; 31 | } 32 | 33 | const Button: React.FC = ({ 34 | className, 35 | size = 'medium', 36 | variant = 'solid', 37 | block, 38 | ...props 39 | }) => ( 40 | 56 | )} 57 | 58 |
  • 59 | ); 60 | }; 61 | 62 | export default CardCounter; 63 | -------------------------------------------------------------------------------- /src/components/shared/SideMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Menu from 'react-burger-menu/lib/menus/slide'; 3 | import { MdClose } from 'react-icons/md'; 4 | 5 | import { useLockBodyScroll } from 'hooks/use-lock-body-scroll'; 6 | 7 | interface SideMenuProps { 8 | isOpen: boolean; 9 | title: string; 10 | right?: boolean; 11 | onClose: () => void; 12 | onStateChange: (state: boolean) => void; 13 | } 14 | 15 | const styles = { 16 | bmMenuWrap: { 17 | top: 0, 18 | position: 'absolute', 19 | }, 20 | bmMenu: { 21 | overflow: 'hidden', 22 | }, 23 | }; 24 | 25 | interface ModalHeaderProps { 26 | title: string; 27 | onClose: () => void; 28 | } 29 | 30 | const ModalHeader = ({ title, onClose }: ModalHeaderProps) => ( 31 |
    32 |

    {title}

    33 | 40 |
    41 | ); 42 | 43 | const SideMenu: React.FC = ({ 44 | isOpen, 45 | title, 46 | right, 47 | onClose, 48 | onStateChange, 49 | children, 50 | }) => { 51 | useLockBodyScroll(isOpen); 52 | 53 | return ( 54 | onStateChange(state.isOpen)} 63 | > 64 |
    65 | 66 | 67 |
    {children}
    68 |
    69 |
    70 | ); 71 | }; 72 | 73 | export default SideMenu; 74 | -------------------------------------------------------------------------------- /src/modules/LandingPage/Article.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | 3 | import AnimateSlideUpinView from 'components/shared/Animated/AnimateSlideUpinView'; 4 | import styles from './Article.module.css'; 5 | 6 | type Size = 'small' | 'large'; 7 | 8 | interface ArticleProps { 9 | reverse?: boolean; 10 | srcImg: string; 11 | altImg?: string; 12 | sizeImg?: Size; 13 | title: string; 14 | description?: string; 15 | subtitle?: string; 16 | } 17 | 18 | const Article = ({ 19 | reverse, 20 | srcImg, 21 | altImg, 22 | sizeImg, 23 | title, 24 | description, 25 | subtitle, 26 | }: ArticleProps) => { 27 | const marginBetween = reverse 28 | ? 'sm:mr-0 md:mr-10 lg:mr-24' 29 | : 'sm:ml-0 md:ml-10 lg:ml-24'; 30 | 31 | const imageSize = sizeImg === 'small' ? 'md:w-1/2' : 'md:w-3/5'; 32 | const contentSize = sizeImg === 'small' ? 'md:w-1/2' : 'md:w-2/5'; // small img, bigger content 33 | 34 | return ( 35 |
    41 | 42 | {altImg} 50 | 51 | 56 | <> 57 |

    {subtitle}

    58 |

    {title}

    59 |

    {description}

    60 | 61 |
    {' '} 62 |
    63 | ); 64 | }; 65 | 66 | export default Article; 67 | -------------------------------------------------------------------------------- /src/modules/Dashboard/components/BoardAdder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, useOutsideClick } from '@chakra-ui/react'; 3 | 4 | import { useSession } from 'hooks/use-session'; 5 | import BoardDocument from 'modules/Board/data/board.doc'; 6 | import NewBoardForm from 'components/shared/NewBoardForm'; 7 | import { AnimatedOpacity } from 'components/shared/Animated/AnimatedOpacity'; 8 | 9 | export function BoardAdder() { 10 | const { userDoc } = useSession(); 11 | const [isOpen, setIsOpen] = useState(false); 12 | const ref = React.useRef(); 13 | 14 | const toggleOpen = () => setIsOpen(!isOpen); 15 | 16 | useOutsideClick({ 17 | ref: ref, 18 | handler: () => setIsOpen(false), 19 | }); 20 | 21 | const handleSubmit = async props => { 22 | const { title, code, index } = props; 23 | 24 | await BoardDocument.create({ 25 | owner: userDoc.ref, 26 | users: [userDoc.ref], 27 | title, 28 | code, 29 | index, 30 | }); 31 | 32 | setIsOpen(false); 33 | }; 34 | 35 | const handleKeyDown = (event: React.KeyboardEvent) => { 36 | if (event.keyCode === 27) { 37 | setIsOpen(false); 38 | } 39 | }; 40 | 41 | return isOpen ? ( 42 | 52 | 53 | 54 | ) : ( 55 | 62 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/shared/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Button, 4 | Wrapper, 5 | Menu as AriaMenu, 6 | MenuItem as AriaMenuItem, 7 | } from 'react-aria-menubutton'; 8 | import classNames from 'classnames'; 9 | import styled from 'styled-components'; 10 | 11 | import Divider from './Divider'; 12 | 13 | interface MenuProps { 14 | trigger: React.ReactChild; 15 | header?: React.ReactChild; 16 | items: React.ReactChild; 17 | className?: string; 18 | onSelection?: (value: any, event: Event) => void; 19 | } 20 | 21 | const Menu = ({ 22 | trigger, 23 | header, 24 | items, 25 | className, 26 | onSelection, 27 | }: MenuProps) => { 28 | return ( 29 | 30 | {trigger} 31 | 37 | {header && ( 38 | <> 39 |
    {header}
    40 | 41 | 42 | )} 43 | 44 | {items} 45 |
    46 |
    47 | 48 | ); 49 | }; 50 | 51 | const MenuItem = props => ( 52 | 56 | ); 57 | 58 | export default Menu; 59 | export { MenuItem, Button, Divider }; 60 | 61 | const StyledMenu = styled(AriaMenu)` 62 | & ~ .popover-arrow { 63 | position: absolute; 64 | left: 50%; 65 | margin-left: -7px; 66 | top: 99%; 67 | clip: rect(0 18px 14px -4px); 68 | } 69 | 70 | & ~ .popover-arrow:after { 71 | content: ''; 72 | display: block; 73 | width: 14px; 74 | height: 14px; 75 | background: #4a5568; 76 | transform: rotate(45deg) translate(6px, 6px); 77 | box-shadow: -1px -1px 1px -1px rgba(0, 0, 0, 0.54); 78 | } 79 | `; 80 | -------------------------------------------------------------------------------- /src/hooks/use-due-date.ts: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format'; 2 | import { useMemo } from 'react'; 3 | import { differenceInCalendarDays } from 'date-fns'; 4 | 5 | export const useDueDate = ( 6 | date: any, 7 | completed: boolean 8 | ): { dueDateString: string; dueDateColor: string; dueTitle: string } => { 9 | const dueDateFromToday = useMemo( 10 | () => date && differenceInCalendarDays(new Date(date.toDate()), new Date()), 11 | [date] 12 | ); 13 | 14 | const dueDateString = useMemo(() => { 15 | if (!date) return; 16 | 17 | switch (dueDateFromToday) { 18 | case -1: 19 | return 'Yesterday'; 20 | case 0: 21 | return 'Today'; 22 | case 1: 23 | return 'Tomorrow'; 24 | default: 25 | if (dueDateFromToday < -1) { 26 | return `${Math.abs(dueDateFromToday)} days ago`; 27 | } else { 28 | return format(new Date(date.toDate()), 'd MMM'); 29 | } 30 | } 31 | }, [dueDateFromToday, date]); 32 | 33 | const dueDateColor = useMemo(() => { 34 | if (!date) return; 35 | if (completed) return 'bg-green-500'; 36 | 37 | switch (dueDateFromToday) { 38 | case 0: 39 | return 'bg-orange-500'; 40 | 41 | default: 42 | if (dueDateFromToday < 0) { 43 | return 'bg-red-500'; 44 | } else { 45 | return 'bg-gray-500'; 46 | } 47 | } 48 | }, [dueDateFromToday, completed, date]); 49 | 50 | const dueTitle = useMemo(() => { 51 | if (!date) return; 52 | if (completed) return 'This card is completed'; 53 | 54 | switch (dueDateFromToday) { 55 | case 0: 56 | return 'This card is due today'; 57 | case 1: 58 | return 'This card is due tomorrow'; 59 | default: 60 | if (dueDateFromToday < 0) { 61 | return 'This card is past due.'; 62 | } else { 63 | return 'This card is due later.'; 64 | } 65 | } 66 | }, [dueDateFromToday, completed, date]); 67 | 68 | return { dueTitle, dueDateColor, dueDateString }; 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/shared/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { 4 | DetailedHTMLProps, 5 | InputHTMLAttributes, 6 | forwardRef, 7 | RefObject, 8 | } from 'react'; 9 | 10 | export interface CheckboxProps 11 | extends DetailedHTMLProps< 12 | InputHTMLAttributes, 13 | HTMLInputElement 14 | > {} 15 | 16 | const StyledInput = styled.input` 17 | position: relative; 18 | flex-shrink: 0; 19 | width: 12px; 20 | height: 12px; 21 | appearance: none; 22 | -webkit-appearance: none; 23 | -moz-appearance: none; 24 | background-color: #fff; 25 | cursor: pointer; 26 | border-radius: 3px; 27 | overflow: hidden; 28 | 29 | &::before { 30 | content: ' '; 31 | position: absolute; 32 | top: 50%; 33 | right: 50%; 34 | bottom: 50%; 35 | left: 50%; 36 | transition: all 0.1s; 37 | background: #ed64a6; 38 | } 39 | 40 | &:checked { 41 | display: inline-block; 42 | background: #fff; 43 | 44 | &::before { 45 | transform: rotate(45deg); 46 | content: ''; 47 | position: absolute; 48 | width: 1px; 49 | height: 6px; 50 | background-color: #ed64a6; 51 | left: 7px; 52 | top: 3px; 53 | } 54 | 55 | &::after { 56 | transform: rotate(45deg); 57 | content: ''; 58 | position: absolute; 59 | width: 3px; 60 | height: 1px; 61 | background-color: #ed64a6; 62 | left: 3px; 63 | top: 6px; 64 | } 65 | } 66 | 67 | &:disabled { 68 | border-color: hsla(320, 4.2%, 13.9%, 0.2); 69 | cursor: default; 70 | background-color: hsla(320, 4.2%, 13.9%, 0.2); 71 | 72 | &::before { 73 | background-color: hsla(320, 4.2%, 13.9%, 0.2); 74 | } 75 | 76 | + label { 77 | color: hsla(320, 4.2%, 13.9%, 0.2); 78 | cursor: default; 79 | } 80 | } 81 | `; 82 | 83 | const Checkbox = forwardRef( 84 | (props: CheckboxProps, ref: RefObject) => ( 85 | 86 | ) 87 | ); 88 | 89 | export default Checkbox; 90 | -------------------------------------------------------------------------------- /src/components/shared/Feedback/emoji.module.css: -------------------------------------------------------------------------------- 1 | .emojiSelector { 2 | display: flex; 3 | width: 210px; 4 | pointer-events: none; 5 | } 6 | .emojiSelector.loading { 7 | filter: grayscale(100%); 8 | -webkit-filter: grayscale(100%); 9 | cursor: default; 10 | pointer-events: none; 11 | } 12 | .emojiSelector > button { 13 | background: transparent; 14 | border: 0; 15 | padding: 0; 16 | margin: 0; 17 | } 18 | .emojiSelector > button, 19 | .emojiSelector > button .inner { 20 | display: inline-flex; 21 | } 22 | .emojiSelector > button { 23 | cursor: pointer; 24 | text-align: center; 25 | } 26 | .emojiSelector > button:not(:last-child) { 27 | padding-right: 2px; 28 | } 29 | .emojiSelector > button:not(:first-child) { 30 | padding-left: 2px; 31 | } 32 | .emojiSelector.loading > button { 33 | cursor: default; 34 | } 35 | .emojiSelector > button:first-child { 36 | outline: none; 37 | pointer-events: all; 38 | } 39 | .emojiSelector.loading > button:first-child { 40 | outline: none; 41 | pointer-events: none; 42 | } 43 | .emojiSelector > button .inner { 44 | height: 24px; 45 | width: 24px; 46 | border-radius: 4px; 47 | border: 1px solid #2d3748; 48 | justify-content: center; 49 | align-items: center; 50 | padding: 3px; 51 | } 52 | .emojiSelector > button .inner.icon { 53 | padding: 3px 3px 4px 2px; 54 | } 55 | .emojiSelector.dark { 56 | background: transparent; 57 | } 58 | .emojiSelector.dark > button .inner { 59 | border-color: #000000; 60 | background-color: #000000; 61 | } 62 | .emojiSelector.dark.loading > button .inner { 63 | border-color: #666666; 64 | background-color: #666666; 65 | } 66 | .emojiSelector > button.active .inner, 67 | .emojiSelector > button:hover .inner { 68 | border-color: #ed64a6; 69 | } 70 | .emojiSelector > button.option { 71 | opacity: 0; 72 | transform: translate3d(-10px, 0px, 0px); 73 | transition: all ease 100ms; 74 | pointer-events: none; 75 | } 76 | .emojiSelector.shown > button.option { 77 | pointer-events: all; 78 | } 79 | .emojiSelector.shown > button.option { 80 | opacity: 1; 81 | transform: translate3d(0, 0px, 0px); 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/Board/components/BoardHeader/BoardButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, CSSProperties } from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | interface BoardButtonProps { 5 | icon: React.ReactChild; 6 | text?: string; 7 | renderModal?: ({ isOpen, originRef, toggleIsOpen }) => React.ReactChild; 8 | onClick?: () => void; 9 | style?: CSSProperties; 10 | disabled?: boolean; 11 | } 12 | 13 | const BoardButton = ({ 14 | icon, 15 | text, 16 | renderModal, 17 | onClick, 18 | style, 19 | disabled, 20 | }: BoardButtonProps) => { 21 | const buttonRef = useRef(null); 22 | const [isOpen, setIsOpen] = useState(false); 23 | 24 | return ( 25 | <> 26 | { 29 | if (onClick) { 30 | onClick(); 31 | } 32 | setIsOpen(true); 33 | }} 34 | style={style} 35 | disabled={disabled} 36 | > 37 |
    {icon}
    38 | {text &&
     {text}
    } 39 |
    40 | {renderModal && 41 | renderModal({ 42 | isOpen, 43 | originRef: buttonRef, 44 | toggleIsOpen: () => setIsOpen(!isOpen), 45 | })} 46 | 47 | ); 48 | }; 49 | 50 | export default BoardButton; 51 | 52 | const StyledButton = styled.button<{ disabled: boolean }>` 53 | display: flex; 54 | justify-content: space-around; 55 | align-items: center; 56 | padding: 8px 10px 8px 10px; 57 | border-radius: 0.25rem; 58 | color: #fff; 59 | transition: background 0.1s; 60 | cursor: pointer; 61 | margin-left: 5px; 62 | min-height: 40px; 63 | 64 | ${({ disabled }) => 65 | disabled 66 | ? css` 67 | cursor: default; 68 | ` 69 | : css` 70 | cursor: pointer; 71 | 72 | &:hover, 73 | &:focus { 74 | background: rgba(0, 0, 0, 0.2); 75 | } 76 | `}; 77 | 78 | & > .btn-icon { 79 | flex-shrink: 0; 80 | 81 | margin-bottom: 3px; 82 | } 83 | `; 84 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | Html, 3 | Head, 4 | Main, 5 | NextScript, 6 | DocumentProps, 7 | DocumentContext, 8 | } from 'next/document'; 9 | import { ServerStyleSheet } from 'styled-components'; 10 | 11 | class MyDocument extends Document { 12 | static async getInitialProps(ctx: DocumentContext) { 13 | const sheet = new ServerStyleSheet(); 14 | const originalRenderPage = ctx.renderPage; 15 | 16 | try { 17 | ctx.renderPage = () => 18 | originalRenderPage({ 19 | enhanceApp: App => props => sheet.collectStyles(), 20 | }); 21 | 22 | const initialProps = await Document.getInitialProps(ctx); 23 | return { 24 | ...initialProps, 25 | styles: ( 26 | <> 27 | {initialProps.styles} 28 | {sheet.getStyleElement()} 29 | 30 | ), 31 | }; 32 | } finally { 33 | sheet.seal(); 34 | } 35 | } 36 | 37 | render() { 38 | return ( 39 | 40 | 41 | 47 | 48 | {/* */} 49 | 53 | 59 | 60 | 61 |
    62 | 63 | 64 | 65 | ); 66 | } 67 | } 68 | 69 | export default MyDocument; 70 | -------------------------------------------------------------------------------- /src/modules/Board/pages/Board.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import { useSession } from 'hooks/use-session'; 5 | import { useBoard } from 'modules/Board/hooks/use-board'; 6 | import Board from 'modules/Board'; 7 | import AuthenticatedPage from 'modules/Auth/components/AuthenticatedPage'; 8 | import { useInterface } from 'components/providers/InterfaceProvider'; 9 | import Loader from 'components/shared/Loader'; 10 | import { BoardsStoreProvider, UsersStoreProvider } from 'store'; 11 | import AppTrackEvents from 'components/shared/AppTrackEvents'; 12 | import { CardModalProvider } from 'modules/Board/components/CardModalProvider'; 13 | 14 | interface BoardPageProps { 15 | query: { uid: string; previewmode: boolean }; 16 | children: React.ReactChildren; 17 | } 18 | 19 | const error404 = { 20 | statusCode: 404, 21 | title: 'Board not found', 22 | }; 23 | 24 | const BoardPage = ({ query }: BoardPageProps) => { 25 | const { user } = useSession(); 26 | const [board] = useBoard(query.uid); 27 | const { setPreviewMode } = useInterface(); 28 | 29 | const isAnonymous = !user; 30 | const isABoardMember = !isAnonymous && board && board.hasMember(user.email); 31 | 32 | const previewMode = !!query.previewmode || isAnonymous || !isABoardMember; 33 | 34 | useEffect(() => { 35 | setPreviewMode(previewMode); 36 | }, [previewMode, setPreviewMode]); 37 | 38 | if (!board || board.isLoading) return ; 39 | 40 | return ( 41 | 45 | 46 | 47 | 48 | 49 |
    50 | 51 |
    52 |
    53 |
    54 |
    55 |
    56 | ); 57 | }; 58 | 59 | BoardPage.getInitialProps = ({ query }) => ({ query }); 60 | 61 | export default observer(BoardPage); 62 | -------------------------------------------------------------------------------- /src/modules/Dashboard/components/BoardCard.tsx: -------------------------------------------------------------------------------- 1 | import { Text, VStack } from '@chakra-ui/react'; 2 | import { chakra } from '@chakra-ui/system'; 3 | import { motion } from 'framer-motion'; 4 | import { observer } from 'mobx-react-lite'; 5 | import BoardDocument from 'modules/Board/data/board.doc'; 6 | import { useBoardTeam } from 'modules/Board/hooks/use-board-team'; 7 | import { MembersAtavar } from './UserPopupProfile'; 8 | import Link from 'next/link'; 9 | import { forwardRef, ReactNode } from 'react'; 10 | 11 | const MotionDiv = chakra(motion.div); 12 | 13 | const StyledContainer = forwardRef( 14 | ({ children }, ref) => { 15 | return ( 16 | 34 | {children} 35 | 36 | ); 37 | } 38 | ); 39 | 40 | type BoardCardProps = { 41 | board: BoardDocument; 42 | }; 43 | 44 | export const BoardCard = observer(function BoardCard({ 45 | board, 46 | }: BoardCardProps) { 47 | const { assignees } = useBoardTeam(board); 48 | 49 | return ( 50 | 51 | 52 | 53 | event.stopPropagation()} 55 | spacing={4} 56 | alignItems='stretch' 57 | justifyContent='space-between' 58 | h='full' 59 | borderRadius={10} 60 | > 61 | 62 | {board.data.title} 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | }); 72 | -------------------------------------------------------------------------------- /src/components/shared/AutosuggestInput/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledAutosuggestInput = styled.div` 4 | .react-autosuggest__container { 5 | position: relative; 6 | margin: 0 3px; 7 | } 8 | 9 | .react-autosuggest__input { 10 | width: 100%; 11 | padding: 10px; 12 | font-family: inherit; 13 | font-size: 1rem; 14 | line-height: 1.25; 15 | border: 1px solid rgba(255, 255, 255, 0.08); 16 | border-radius: 0.25rem; 17 | transition: all 0.2s; 18 | outline: none; 19 | align-items: center; 20 | position: relative; 21 | display: flex; 22 | 23 | height: 2.5rem; 24 | background-color: #718096; 25 | color: #fff; 26 | padding-top: 0.5rem; 27 | padding-bottom: 0.5rem; 28 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 29 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 30 | } 31 | .react-autosuggest__input::placeholder { 32 | color: #1a202c; 33 | } 34 | .react-autosuggest__input:focus { 35 | z-index: 1; 36 | border-color: #ed64a6; 37 | box-shadow: 0 0px 1px 0px #ed64a6; 38 | } 39 | 40 | .react-autosuggest__input--open { 41 | border-bottom-left-radius: 0; 42 | border-bottom-right-radius: 0; 43 | } 44 | 45 | .react-autosuggest__suggestions-container { 46 | display: none; 47 | } 48 | 49 | .react-autosuggest__suggestions-container--open { 50 | display: block; 51 | color: #fff; 52 | position: absolute; 53 | top: 100%; 54 | width: 100%; 55 | margin-left: 0px; 56 | border: 0; 57 | background-color: #718096; 58 | font-family: inherit; 59 | font-size: 16px; 60 | border-bottom-left-radius: 3px; 61 | border-bottom-right-radius: 3px; 62 | max-height: 148px; 63 | overflow-y: auto; 64 | z-index: 2; 65 | } 66 | 67 | .react-autosuggest__suggestions-list { 68 | margin: 0; 69 | padding: 0px 0px 8px 0px; 70 | list-style-type: none; 71 | margin-top: 8px; 72 | } 73 | 74 | .react-autosuggest__suggestion { 75 | cursor: pointer; 76 | padding: 10px; 77 | } 78 | 79 | .react-autosuggest__suggestion--highlighted { 80 | color: #fff; 81 | background-color: #2d3748; 82 | } 83 | `; 84 | -------------------------------------------------------------------------------- /src/modules/Board/components/BoardHeader/AddNewListModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import 'firebase/firestore'; 4 | 5 | import { useKeySubmit } from 'hooks/use-key-submit'; 6 | import BoardDocument from 'modules/Board/data/board.doc'; 7 | import Dialog from 'components/shared/Dialog'; 8 | import { Input } from 'components/shared'; 9 | import { useAppToast } from 'hooks/use-app-toast'; 10 | 11 | interface AddNewListModalProps { 12 | board: BoardDocument; 13 | isOpen?: boolean; 14 | toggleIsOpen: () => void; 15 | } 16 | 17 | const AddNewListModal = ({ 18 | board, 19 | toggleIsOpen, 20 | isOpen, 21 | }: AddNewListModalProps) => { 22 | const toast = useAppToast(); 23 | const [value, setValue] = useState(''); 24 | const [isSubmit, setIsSubmit] = useState(false); 25 | 26 | const handleChange = (event: React.ChangeEvent) => { 27 | setValue(event.target.value); 28 | }; 29 | 30 | const save = async (newValue: string) => { 31 | if (!newValue) return; 32 | 33 | const index = board.lists.docs.length; 34 | 35 | setIsSubmit(true); 36 | 37 | return board.lists 38 | .add({ 39 | title: newValue, 40 | index, 41 | }) 42 | .then(() => { 43 | toast({ id: index, title: `A new list was created!` }); 44 | setIsSubmit(false); 45 | }); 46 | }; 47 | 48 | const handleSubmit = async () => { 49 | await save(value); 50 | setValue(''); 51 | toggleIsOpen(); 52 | }; 53 | 54 | const handleKeyDown = useKeySubmit(handleSubmit, () => { 55 | setValue(''); 56 | toggleIsOpen(); 57 | }); 58 | 59 | return ( 60 | 61 |
    62 | 72 |
    73 |
    74 | ); 75 | }; 76 | 77 | export default observer(AddNewListModal); 78 | -------------------------------------------------------------------------------- /src/components/shared/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactChild } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Modal from 'react-modal'; 4 | 5 | import { MdClose } from 'react-icons/md'; 6 | 7 | if (typeof window !== 'undefined') { 8 | Modal.setAppElement('#__next'); 9 | } 10 | 11 | interface DialogProps { 12 | isOpen?: boolean; 13 | onClose?: () => void; 14 | title: string; 15 | children: ReactChild; 16 | top?: number; 17 | width?: number; 18 | } 19 | 20 | const Dialog = ({ 21 | top, 22 | width, 23 | onClose, 24 | isOpen, 25 | title, 26 | children, 27 | }: DialogProps) => { 28 | const getStyles = (_width = 400, _top = 20) => ({ 29 | overlay: { 30 | backgroundColor: 'rgba(0, 0, 0, 0.75)', 31 | overflowY: 'auto', 32 | zIndex: 30, 33 | transform: 'translate3d(0, 0, 0)', 34 | }, 35 | content: { 36 | position: 'relative', 37 | overflow: 'hidden', 38 | padding: 0, 39 | width: _width, 40 | maxWidth: '98%', 41 | top: `${_top}vh`, 42 | bottom: 40, 43 | left: 0, 44 | right: 0, 45 | margin: `0 auto ${_top}vh`, 46 | border: 'none', 47 | borderRadius: '0.5rem', 48 | backgroundColor: 'transparent', 49 | }, 50 | }); 51 | 52 | return ( 53 | 61 |
    62 |
    63 |
    64 |

    {title}

    65 | 72 |
    73 | 74 | {children} 75 |
    76 |
    77 |
    78 | ); 79 | }; 80 | 81 | export default observer(Dialog); 82 | -------------------------------------------------------------------------------- /src/modules/LandingPage/Pricing.tsx: -------------------------------------------------------------------------------- 1 | import PricingCard from './PricingCard'; 2 | import { useInView } from 'react-intersection-observer'; 3 | 4 | import './Pricing.css'; 5 | import { AnimatedSlideUp } from 'components/shared/Animated/AnimatedSlideUp'; 6 | 7 | const Pricing = () => { 8 | const [ref, inView] = useInView({ 9 | threshold: 0.5, 10 | triggerOnce: true, 11 | }); 12 | 13 | return ( 14 |
    15 | 20 |

    21 | Simple and transparent pricing 22 |

    23 |
    24 | 25 |
    26 |
    27 | console.log('clicked')} 37 | colored 38 | /> 39 | 40 | console.log('clicked')} 49 | disabled 50 | /> 51 | 52 | console.log('clicked')} 63 | disabled 64 | /> 65 |
    66 |
    67 |
    68 | ); 69 | }; 70 | export default Pricing; 71 | -------------------------------------------------------------------------------- /src/modules/Board/components/CardModalProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useContext, 4 | useState, 5 | PropsWithChildren, 6 | useCallback, 7 | useMemo, 8 | } from 'react'; 9 | import ReactModal from 'react-modal'; 10 | 11 | import CardModalFull, { 12 | getStyle, 13 | CardModalProps, 14 | } from 'modules/Card/components/CardModal/CardModalFull'; 15 | import { useThinDisplay } from 'hooks/use-thin-display'; 16 | 17 | interface CardModalContextProps { 18 | showModal: (props: CardModalProps) => void; 19 | hideModal: () => void; 20 | isOpen: boolean; 21 | } 22 | 23 | export const CardModalContext = createContext({ 24 | showModal: () => {}, 25 | hideModal: () => {}, 26 | isOpen: false, 27 | }); 28 | 29 | const useModal = () => { 30 | const [isOpen, setIsShow] = useState(false); 31 | const [modalProps, setModalProps] = useState(null); 32 | 33 | const hideModal = useCallback(() => setIsShow(false), []); 34 | 35 | const showModal = useCallback((props: CardModalProps) => { 36 | setIsShow(true); 37 | setModalProps(props); 38 | }, []); 39 | 40 | return { isOpen, showModal, hideModal, modalProps }; 41 | }; 42 | 43 | export const CardModalProvider = ({ children }: PropsWithChildren<{}>) => { 44 | const isThinDisplay = useThinDisplay(); 45 | const customStyle = useMemo(() => getStyle(isThinDisplay), [isThinDisplay]); 46 | 47 | const { isOpen, showModal, hideModal, modalProps } = useModal(); 48 | 49 | return ( 50 | 51 | <> 52 | {children} 53 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | export const useCardFullModal = () => { 67 | const context = useContext(CardModalContext); 68 | 69 | if (context === undefined) { 70 | throw new Error('useInterface must be used within a CardModalProvider'); 71 | } 72 | return context; 73 | }; 74 | -------------------------------------------------------------------------------- /src/modules/Card/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import { findCheckboxes } from 'helpers/find-check-boxes'; 5 | import CardBadges from 'modules/Card/components/CardBadges'; 6 | import { Card as CardModel } from 'modules/Card/data/card.doc'; 7 | import CardMarkdown from 'modules/Card/components/CardMarkdown'; 8 | import { User } from 'store/users'; 9 | 10 | interface CardProps { 11 | card: CardModel; 12 | previewMode: boolean; 13 | assignees: User[]; 14 | isHidden: boolean; 15 | onUpdate: (data: Partial) => void; 16 | onComplete?: (state: boolean) => void; 17 | onTagClick?: (tag: string) => void; 18 | onClick?: (cardId: string) => void; 19 | onKeyDown?: (event: React.KeyboardEvent) => void; 20 | } 21 | 22 | const Card = ({ 23 | card, 24 | previewMode, 25 | assignees, 26 | isHidden, 27 | onUpdate, 28 | onComplete, 29 | onTagClick, 30 | onClick, 31 | onKeyDown, 32 | }: CardProps) => { 33 | const checkboxes = useMemo(() => findCheckboxes(card.text), [card.text]); 34 | 35 | const showBadges = 36 | card.assignee || card.date || card.tags || checkboxes.total > 0; 37 | 38 | const handleClick = (e: React.MouseEvent) => { 39 | e.preventDefault(); 40 | onClick && onClick(card.id); 41 | }; 42 | 43 | const cardProps = previewMode ? {} : { onClick: handleClick, onKeyDown }; 44 | 45 | return ( 46 | onUpdate({ text })} 48 | text={card.text} 49 | isHidden={isHidden} 50 | previewMode={previewMode} 51 | bgColor={card.color} 52 | renderBadges={() => 53 | showBadges && ( 54 | 65 | ) 66 | } 67 | {...cardProps} 68 | /> 69 | ); 70 | }; 71 | 72 | export default observer(Card); 73 | -------------------------------------------------------------------------------- /src/components/shared/HoverCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Popover, 3 | PopoverTrigger, 4 | PopoverContent, 5 | PopoverArrow, 6 | PopoverBody, 7 | PopoverFooter, 8 | PopoverBodyProps, 9 | Portal, 10 | } from '@chakra-ui/react'; 11 | import React, { createContext, useContext } from 'react'; 12 | 13 | type HoverCardContextValue = { 14 | onClose: () => void; 15 | onOpen: () => void; 16 | }; 17 | 18 | const HoverCardContext = createContext( 19 | undefined 20 | ); 21 | 22 | type HoverCardProps = { 23 | isOpen: boolean; 24 | onClose: () => void; 25 | onOpen: () => void; 26 | }; 27 | 28 | export function HoverCard({ 29 | isOpen, 30 | onClose, 31 | onOpen, 32 | children, 33 | }: WithChildren) { 34 | return ( 35 | 36 | 44 | {children} 45 | 46 | 47 | ); 48 | } 49 | 50 | function Trigger(props: WithChildren) { 51 | return ; 52 | } 53 | 54 | function Content({ children }: WithChildren) { 55 | const { onOpen, onClose } = useContext(HoverCardContext); 56 | 57 | return ( 58 | 59 | 65 | 69 | {children} 70 | 71 | 72 | ); 73 | } 74 | 75 | function Body(props: WithChildren) { 76 | return ; 77 | } 78 | 79 | function Footer({ children }: WithChildren) { 80 | return ( 81 | 82 | {children} 83 | 84 | ); 85 | } 86 | 87 | HoverCard.Trigger = Trigger; 88 | HoverCard.Content = Content; 89 | HoverCard.Body = Body; 90 | HoverCard.Footer = Footer; 91 | -------------------------------------------------------------------------------- /public/static/images/kanban-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/shared/Assignee.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { useTransition, animated, useSpring, config } from 'react-spring'; 4 | import styled from 'styled-components'; 5 | 6 | import { useFirstRender } from 'hooks/use-first-render'; 7 | import Avatar from 'components/shared/Avatar'; 8 | import { User } from 'store/users'; 9 | 10 | interface AssigneeProps { 11 | assignees: User[]; 12 | avatarColor: string; 13 | } 14 | 15 | const Assignee = ({ assignees, avatarColor }: AssigneeProps) => { 16 | const transitions = useTransition(assignees, item => item.id, { 17 | from: { transform: 'translate3d(40px, 0, 0)', opacity: 0 }, 18 | enter: { transform: 'translate3d(0, 0px, 0)', opacity: 1 }, 19 | leave: { transform: 'translate3d(40px, 0, 0)', opacity: 0 }, 20 | }); 21 | 22 | const [spring, setSpring] = useSpring(() => ({ 23 | marginLeft: '0px', 24 | immediate: true, 25 | config: config.stiff, 26 | })); 27 | 28 | const initAnimation = () => 29 | setTimeout(() => { 30 | setSpring({ marginLeft: '-8px' }); 31 | }, 1000); 32 | 33 | useFirstRender(initAnimation); 34 | 35 | return ( 36 | 37 | {transitions.map( 38 | ({ item, key, props: tprops }, index) => 39 | item && ( 40 | 48 | 55 | 56 | ) 57 | )} 58 | 59 | ); 60 | }; 61 | 62 | export default observer(Assignee); 63 | 64 | const StyledAssignes = styled.div` 65 | display: flex; 66 | 67 | & .assignee { 68 | will-change: margin; 69 | transition: margin 0.3s; 70 | } 71 | 72 | &:hover .assignee:not(:first-child) { 73 | margin-left: 0 !important; 74 | } 75 | `; 76 | -------------------------------------------------------------------------------- /src/modules/Card/hooks/use-assign.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import firebase from 'firebase/app'; 3 | import 'firebase/firestore'; 4 | 5 | import { useSession } from 'hooks/use-session'; 6 | import CardDocument from 'modules/Card/data/card.doc'; 7 | import { emitter } from 'libs/emitter'; 8 | 9 | export const useAssign = (card: CardDocument, listId: string): (() => void) => { 10 | const { userDoc } = useSession(); 11 | 12 | const userRef = userDoc?.ref; 13 | 14 | const emitEvent = useCallback( 15 | assigneId => 16 | emitter.emit('ASSIGNE_CARD', { 17 | title: card.data.title || '', 18 | cardId: card.id, 19 | listId, 20 | assigneId, 21 | }), 22 | [card, listId] 23 | ); 24 | 25 | const toggleAssignment = async () => { 26 | if (!card.data.assignee || !card.data.assignee.length) { 27 | await card 28 | .update({ 29 | assignee: firebase.firestore.FieldValue.arrayUnion(userRef), 30 | }) 31 | .then(() => emitEvent(userDoc.id)); 32 | } else { 33 | const ids = card.data.assignee.map(a => a.id); 34 | 35 | if (ids.includes(userRef.id)) { 36 | // Toggle if the user is already an assignee 37 | await card 38 | .update({ 39 | assignee: firebase.firestore.FieldValue.arrayRemove(userRef), 40 | }) 41 | .then(() => emitEvent(null)); 42 | } else { 43 | // If is not an array, we need to keep the old assigne to save with the new one, 44 | // otherwise, the old assigne will be cleanned. (backwards compatibility) 45 | if (!Array.isArray(card.data.assignee)) { 46 | const oldAssignee = card.data.assignee; 47 | await card 48 | .update({ 49 | assignee: firebase.firestore.FieldValue.arrayUnion( 50 | ...[oldAssignee, userRef] 51 | ), 52 | }) 53 | .then(() => emitEvent(userDoc.id)); 54 | } else { 55 | await card 56 | .update({ 57 | assignee: firebase.firestore.FieldValue.arrayUnion(userRef), 58 | }) 59 | .then(() => emitEvent(userDoc.id)); 60 | } 61 | } 62 | } 63 | }; 64 | 65 | return toggleAssignment; 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/shared/AutosuggestInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Autosuggest from 'react-autosuggest'; 4 | import { useState, useCallback } from 'react'; 5 | import { StyledAutosuggestInput } from './styles'; 6 | 7 | const escapeRegexCharacters = str => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 8 | 9 | export interface AutosuggestInputProps { 10 | suggestions: string[]; 11 | onSelect: (tag: string) => void; 12 | } 13 | 14 | const AutosuggestInput = (props: AutosuggestInputProps) => { 15 | const { suggestions = [], onSelect } = props; 16 | 17 | const [value, setValue] = useState(''); 18 | const [_suggestions, setSuggestions] = useState([]); 19 | 20 | const onChange = (_, { newValue }) => setValue(newValue); 21 | 22 | const getSuggestions = useCallback( 23 | (value: string) => { 24 | const escapedValue = escapeRegexCharacters(value.trim()); 25 | 26 | if (escapedValue === '') { 27 | return []; 28 | } 29 | 30 | const regex = new RegExp(`.*${escapedValue}.*`, 'i'); 31 | 32 | return suggestions.filter(tag => regex.test(tag)); 33 | }, 34 | [suggestions] 35 | ); 36 | 37 | const getSuggestionValue = useCallback(suggestion => suggestion, []); 38 | const renderSuggestion = useCallback(suggestion => suggestion, []); 39 | 40 | const onSuggestionsFetchRequested = ({ value }) => 41 | setSuggestions(getSuggestions(value)); 42 | 43 | const onSuggestionsClearRequested = () => setSuggestions([]); 44 | const onSuggestionSelected = (_, { suggestion }) => onSelect(suggestion); 45 | 46 | const inputProps = { 47 | placeholder: 'Type', 48 | value, 49 | onChange, 50 | }; 51 | 52 | return ( 53 | 54 | 63 | 64 | ); 65 | }; 66 | export default observer(AutosuggestInput); 67 | -------------------------------------------------------------------------------- /src/components/shared/Editable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ReactChild, ReactElement } from 'react'; 2 | 3 | import { useKeySubmit } from '../../hooks/use-key-submit'; 4 | import { Input } from '../shared'; 5 | import { InputProps } from './Input'; 6 | 7 | interface EditableProps { 8 | value: string; 9 | editable?: boolean; 10 | inputProps?: InputProps; 11 | onSubmit: (value: string) => void; 12 | onRenderInput?: ReactElement; 13 | children: ({ 14 | value, 15 | onClick, 16 | }: { 17 | value: string; 18 | onClick: () => void; 19 | }) => ReactChild; 20 | } 21 | 22 | const Editable = ({ 23 | value, 24 | editable = true, 25 | onSubmit, 26 | onRenderInput, 27 | children, 28 | inputProps, 29 | }: EditableProps) => { 30 | const [isOpen, setIsOpen] = useState(false); 31 | const [newValue, setNewValue] = useState(value); 32 | 33 | const handleClick = () => { 34 | setIsOpen(true); 35 | setNewValue(value); 36 | }; 37 | 38 | const handleChange = event => setNewValue(event.target.value); 39 | 40 | const submitTitle = () => { 41 | if (newValue === '') return; 42 | 43 | if (value !== newValue) { 44 | onSubmit(newValue); 45 | } 46 | setIsOpen(false); 47 | }; 48 | 49 | const revertTitle = () => { 50 | setIsOpen(false); 51 | setNewValue(value); 52 | }; 53 | 54 | const handleKeyDown = useKeySubmit(submitTitle, revertTitle); 55 | 56 | return ( 57 |
    58 | {isOpen && editable ? ( 59 | onRenderInput ? ( 60 | React.cloneElement(onRenderInput, { 61 | value: newValue, 62 | onKeyDown: handleKeyDown, 63 | onChange: handleChange, 64 | onBlur: revertTitle, 65 | autoFocus: true, 66 | ...inputProps, 67 | }) 68 | ) : ( 69 | 79 | ) 80 | ) : ( 81 | children({ value, onClick: handleClick }) 82 | )} 83 |
    84 | ); 85 | }; 86 | 87 | export default Editable; 88 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | import { useSession } from 'hooks/use-session'; 6 | 7 | import { Avatar } from 'components/shared'; 8 | import UserMenu from 'components/shared/UserMenu'; 9 | import { GiveFeedback } from 'components/shared/Feedback/GiveFeedback'; 10 | import { useInterface } from 'components/providers/InterfaceProvider'; 11 | 12 | const Header = observer(() => { 13 | const { user, userDoc } = useSession(); 14 | const { previewMode } = useInterface(); 15 | 16 | if (previewMode || !user) return null; 17 | 18 | return ( 19 |
    20 |
    55 | 56 | 57 | ); 58 | }); 59 | 60 | export default Header; 61 | -------------------------------------------------------------------------------- /src/components/shared/BoardList/ListHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import cn from 'classnames'; 4 | 5 | import ListDocument, { List as ListModel } from 'documents/list.doc'; 6 | import Editable from 'components/shared/Editable'; 7 | import CardCounter from 'components/shared/CardCounter'; 8 | import ListMenu from './ListMenu'; 9 | 10 | interface ListHeaderProps { 11 | listTitle: string; 12 | list: ListDocument; 13 | isDragging: boolean; 14 | previewMode?: boolean; 15 | onRemove: (title: string) => Promise; 16 | onUpdate: (data: Partial) => Promise; 17 | } 18 | 19 | const ListHeader = ({ 20 | listTitle, 21 | list, 22 | isDragging, 23 | previewMode, 24 | onRemove, 25 | onUpdate, 26 | }: ListHeaderProps) => { 27 | const isEditable = !previewMode; 28 | 29 | const handleSubmit = (value: string) => { 30 | if (value === '') return; 31 | 32 | if (value !== listTitle) { 33 | onUpdate({ title: value }); 34 | } 35 | }; 36 | 37 | const handleCounterSubmit = (value: number) => 38 | onUpdate({ cardsLimit: value }); 39 | 40 | return ( 41 |
    46 | 52 | {({ value, onClick }) => ( 53 |
    54 |
    60 | 64 | {value} 65 | 66 |
    67 |
    68 | )} 69 |
    70 | 71 | 77 | 78 | {isEditable && } 79 |
    80 | ); 81 | }; 82 | 83 | export default observer(ListHeader); 84 | -------------------------------------------------------------------------------- /src/components/shared/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, CSSProperties } from 'react'; 2 | import DayPicker, { DayModifiers } from 'react-day-picker'; 3 | import { MdClose } from 'react-icons/md'; 4 | import cn from 'classnames'; 5 | 6 | import Button from 'components/shared/Button'; 7 | 8 | interface CalendarProps { 9 | initialDate: Date; 10 | toggleCalendar: () => void; 11 | className?: string; 12 | style?: CSSProperties; 13 | onSave?: (date: Date) => void; 14 | onRemove?: () => void; 15 | } 16 | 17 | const Calendar: React.FC = props => { 18 | const { 19 | initialDate, 20 | toggleCalendar, 21 | className, 22 | style, 23 | onSave = () => {}, 24 | onRemove = () => {}, 25 | } = props; 26 | 27 | const [selectedDay, setSelectedDay] = useState(initialDate); 28 | 29 | const handleDayClick = ( 30 | selectedDay: Date, 31 | { selected, disabled }: DayModifiers 32 | ) => { 33 | if (disabled) { 34 | return; 35 | } 36 | if (selected) { 37 | // Unselect the day if already selected 38 | setSelectedDay(undefined); 39 | return; 40 | } 41 | setSelectedDay(selectedDay); 42 | }; 43 | 44 | const handleSave = ( 45 | event: React.MouseEvent 46 | ) => { 47 | event.preventDefault(); 48 | const newDate = selectedDay || initialDate; 49 | onSave(newDate); 50 | 51 | toggleCalendar(); 52 | }; 53 | 54 | const handleRemove = ( 55 | event: React.MouseEvent 56 | ) => { 57 | event.preventDefault(); 58 | onRemove(); 59 | 60 | toggleCalendar(); 61 | }; 62 | 63 | return ( 64 |
    68 |
    69 | 72 |
    73 | 78 |
    79 | 82 | 85 |
    86 |
    87 | ); 88 | }; 89 | 90 | export default Calendar; 91 | -------------------------------------------------------------------------------- /src/components/shared/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | 3 | import CardOptionButton from 'modules/Card/components/CardModal/CardOptionButton'; 4 | import ClickOutside from './ClickOutside'; 5 | 6 | interface PickerModalProps { 7 | isOpen: boolean; 8 | buttonRef: React.RefObject; 9 | onChange(color: string): void; 10 | onClose(): void; 11 | } 12 | 13 | const PickerModal = ({ 14 | isOpen, 15 | buttonRef, 16 | onChange, 17 | onClose, 18 | }: PickerModalProps) => { 19 | const focusButtonAndCloseModal = () => { 20 | buttonRef.current.focus(); 21 | onClose && onClose(); 22 | }; 23 | const handleClickOutside = () => { 24 | focusButtonAndCloseModal(); 25 | }; 26 | 27 | const handleKeyDown = event => { 28 | if (event.keyCode === 27) { 29 | focusButtonAndCloseModal(); 30 | } 31 | }; 32 | 33 | const changeColor = color => { 34 | onChange(color); 35 | focusButtonAndCloseModal(); 36 | }; 37 | 38 | return ( 39 | isOpen && ( 40 | 41 |
    42 | {['white', '#6df', '#6f6', '#ff6', '#fa4', '#f66'].map(color => ( 43 |
    51 |
    52 | ) 53 | ); 54 | }; 55 | 56 | interface ColorPickerProps { 57 | onChange: (color: string) => void; 58 | } 59 | 60 | const ColorPicker = ({ onChange }: ColorPickerProps) => { 61 | const buttonRef = useRef(null); 62 | const [isOpen, setIsOpen] = useState(false); 63 | 64 | const closeModal = () => setIsOpen(false); 65 | 66 | return ( 67 |
    68 | setIsOpen(!isOpen)} 70 | ref={buttonRef} 71 | aria-haspopup 72 | aria-expanded={isOpen} 73 | > 74 | colorwheel 79 |  Color 80 | 81 | 82 | 88 |
    89 | ); 90 | }; 91 | 92 | export default ColorPicker; 93 | -------------------------------------------------------------------------------- /src/components/shared/MarkdownText.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import ReactMarkdown, { ReactMarkdownProps } from 'react-markdown'; 3 | import { listItem as defaultListItem } from 'react-markdown/lib/renderers'; 4 | 5 | import Checkbox from '../shared/Checkbox'; 6 | 7 | const renderListItem = props => { 8 | if (props.checked !== null && props.checked !== undefined) { 9 | return ( 10 |
  • 11 | 12 | 13 |
  • 14 | ); 15 | } 16 | return defaultListItem(props); 17 | }; 18 | 19 | const MarkdownText = ({ renderers, ...props }: ReactMarkdownProps) => { 20 | return ( 21 | 22 | 26 | 27 | ); 28 | }; 29 | 30 | export default MarkdownText; 31 | 32 | const StyledCardHtml = styled.div` 33 | h1 { 34 | font-size: 1.3rem !important; 35 | } 36 | 37 | h2 { 38 | font-size: 1.2rem !important; 39 | } 40 | 41 | h3 { 42 | font-size: 1.1rem !important; 43 | } 44 | 45 | h1, 46 | h2, 47 | h3, 48 | h4, 49 | h5, 50 | h6 { 51 | margin-bottom: 8px; 52 | font-weight: bold !important; 53 | } 54 | 55 | img { 56 | max-width: 100%; 57 | } 58 | 59 | p { 60 | margin: 4px 0; 61 | } 62 | 63 | code, 64 | pre { 65 | white-space: pre-wrap; 66 | } 67 | pre { 68 | margin: 4px 0; 69 | padding: 4px 2px; 70 | background: rgba(100, 100, 100, 0.08); 71 | } 72 | 73 | ul { 74 | list-style: disc; 75 | 76 | p { 77 | display: inline; 78 | } 79 | } 80 | 81 | & > ul { 82 | margin-left: 1rem; 83 | } 84 | 85 | & li { 86 | margin-left: 1rem; 87 | } 88 | 89 | & li > input { 90 | margin-right: 0.3rem; 91 | } 92 | 93 | a { 94 | text-decoration: underline; 95 | } 96 | 97 | table { 98 | width: 100%; 99 | } 100 | 101 | thead { 102 | border-bottom: 1px solid #939393; 103 | } 104 | 105 | tbody > tr > td:first-child { 106 | border-left: 1px solid #939393; 107 | } 108 | 109 | tbody > tr:last-child { 110 | border-bottom: 1px solid #939393; 111 | } 112 | 113 | tbody > tr > td { 114 | border-right: 1px solid #939393; 115 | } 116 | 117 | tbody > tr:hover { 118 | background-color: #dedede; 119 | } 120 | `; 121 | -------------------------------------------------------------------------------- /src/modules/LandingPage/PricingCard.tsx: -------------------------------------------------------------------------------- 1 | import { FaCheck } from 'react-icons/fa'; 2 | import { useInView } from 'react-intersection-observer'; 3 | 4 | import { AnimatedSlideUp } from 'components/shared/Animated/AnimatedSlideUp'; 5 | import './PricingCard.css'; 6 | 7 | interface PricingCardProps { 8 | title: string; 9 | price: string; 10 | description?: string; 11 | colored?: boolean; 12 | options: Array<{ title: string }>; 13 | className?: string; 14 | disabled?: boolean; 15 | onClick: () => void; 16 | } 17 | 18 | const PricingCard = ({ 19 | title, 20 | price, 21 | description = '', 22 | options, 23 | onClick, 24 | colored, 25 | className = '', 26 | disabled, 27 | }: PricingCardProps) => { 28 | const [ref, inView] = useInView({ 29 | threshold: 0.5, 30 | triggerOnce: true, 31 | }); 32 | 33 | return ( 34 | 43 | <> 44 |
    45 |

    {title}

    46 |

    47 | $ 48 | {price} 49 | / month 50 |

    51 |

    {description}

    52 |
    53 |
      54 | {options.map((o, i) => ( 55 |
    • 56 | 57 | {o.title} 58 |
    • 59 | ))} 60 |
    61 |
    62 | {disabled ? ( 63 |
    64 | Coming Soon 65 |
    66 | ) : ( 67 | 77 | )} 78 |
    79 | 80 |
    81 | ); 82 | }; 83 | 84 | export default PricingCard; 85 | -------------------------------------------------------------------------------- /src/modules/Board/components/BoardHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MdTimeline, MdViewColumn } from 'react-icons/md'; 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | import BoardDocument from 'modules/Board/data/board.doc'; 6 | import { useInterface } from 'components/providers/InterfaceProvider'; 7 | import { SafariButtonWarning } from 'components/shared'; 8 | import AddNewListModal from './AddNewListModal'; 9 | import TeamListModal from './TeamListModal'; 10 | import BoardButton from './BoardButton'; 11 | import BoardMenu from './BoardMenu'; 12 | import BoardTitle from './BoardTitle'; 13 | import Team from './Team'; 14 | import { useSession } from 'hooks/use-session'; 15 | 16 | interface BoardHeaderProps { 17 | board: BoardDocument; 18 | onRemove: () => void; 19 | previewMode?: boolean; 20 | } 21 | 22 | const BoardHeader = ({ board, onRemove, previewMode }: BoardHeaderProps) => { 23 | const { isMenuOpen, setMenu } = useInterface(); 24 | const { user } = useSession(); 25 | const toggleMenu = () => setMenu(!isMenuOpen); 26 | const isOwner = board.isOwner(user && user.email); 27 | 28 | return ( 29 |
    30 | 35 |
    36 | } 40 | renderModal={props => } 41 | /> 42 | {!previewMode && ( 43 | <> 44 | } 46 | text='Add column' 47 | renderModal={props => ( 48 | 49 | )} 50 | /> 51 | } 53 | text='Activity' 54 | onClick={toggleMenu} 55 | /> 56 | 57 | 63 | 64 | )} 65 | 66 | {previewMode && !user && ( 67 |
    68 | 69 |
    70 | )} 71 |
    72 |
    73 | ); 74 | }; 75 | 76 | export default observer(BoardHeader); 77 | -------------------------------------------------------------------------------- /src/modules/Board/components/BoardHeader/BoardMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { FaArchive, FaHashtag } from 'react-icons/fa'; 3 | import styled from 'styled-components'; 4 | import { MdSettings } from 'react-icons/md'; 5 | 6 | import BoardDocument from 'modules/Board/data/board.doc'; 7 | import Menu, { MenuItem, Button, Divider } from 'components/shared/Menu'; 8 | import AddTagsModal from './AddTagsModal'; 9 | 10 | interface BoardMenuProps { 11 | board: BoardDocument; 12 | isOwner: boolean; 13 | className: string; 14 | onRemove: () => void; 15 | } 16 | 17 | const BoardMenu = ({ board, isOwner, className, onRemove }: BoardMenuProps) => { 18 | const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); 19 | 20 | const toggleTagsModal = () => setIsTagsModalOpen(o => !o); 21 | 22 | const handleSelection = async (value: string) => { 23 | switch (value) { 24 | case 'tags': 25 | toggleTagsModal(); 26 | break; 27 | 28 | case 'deleteBoard': 29 | onRemove(); 30 | break; 31 | 32 | default: 33 | break; 34 | } 35 | }; 36 | 37 | return ( 38 | <> 39 | 44 | 45 |
     Menu
    46 | 47 | } 48 | items={ 49 | <> 50 | 51 | 52 | Tags 53 | 54 | 55 | 56 | 57 | 58 | {isOwner ? 'Archive board' : 'Leave board'} 59 | 60 | 61 | 62 | } 63 | /> 64 | 65 | 70 | 71 | ); 72 | }; 73 | 74 | export default BoardMenu; 75 | 76 | const StyledButton = styled(Button)` 77 | display: flex; 78 | justify-content: space-around; 79 | align-items: center; 80 | padding: 8px 10px 8px 10px; 81 | border-radius: 0.25rem; 82 | color: #fff; 83 | transition: background 0.1s; 84 | cursor: pointer; 85 | margin-left: 5px; 86 | min-height: 40px; 87 | 88 | &:hover, 89 | &:focus { 90 | background: rgba(0, 0, 0, 0.2); 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /src/components/shared/Color.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { CirclePicker } from 'react-color'; 4 | import styled from 'styled-components'; 5 | import usePortal from 'react-cool-portal'; 6 | import { useSpring, animated } from 'react-spring'; 7 | 8 | import { useRect } from '../../hooks/use-rect'; 9 | import { useBoardsStore } from '../../store'; 10 | 11 | const TOP_PADDING = 35; 12 | const LEFT_PADDING = 9; 13 | 14 | interface ColorProps { 15 | color: string; 16 | onChange: (color: string) => void; 17 | } 18 | 19 | const Color: React.FC = props => { 20 | const { color, onChange } = props; 21 | 22 | const { colors } = useBoardsStore(); 23 | 24 | const buttonRef = useRef(); 25 | const [buttonRect] = useRect(buttonRef); 26 | 27 | const { Portal, toggle, isShow } = usePortal({ defaultShow: false }); 28 | const style = useSpring({ opacity: isShow ? 1 : 0 }); 29 | 30 | const handleChange = ({ hex }: { hex: string }) => onChange(hex); 31 | 32 | return ( 33 |
    34 |
    35 | 41 |
    42 | 43 | 44 | 52 | 53 | c.data.code)} 57 | onChangeComplete={handleChange} 58 | /> 59 |
    60 | 61 | 62 | 63 |
    64 | ); 65 | }; 66 | 67 | export default observer(Color); 68 | 69 | const StyledContainer = styled.div` 70 | & .popover-arrow { 71 | position: absolute; 72 | left: 5%; 73 | margin-left: -7px; 74 | top: -14px; 75 | clip: rect(0 18px 14px -4px); 76 | } 77 | 78 | & .popover-arrow:after { 79 | content: ''; 80 | display: block; 81 | width: 14px; 82 | height: 14px; 83 | background: #4a5568; 84 | transform: rotate(45deg) translate(6px, 6px); 85 | box-shadow: -1px -1px 1px -1px rgba(0, 0, 0, 0.54); 86 | } 87 | `; 88 | -------------------------------------------------------------------------------- /src/modules/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import CardDocument from 'modules/Card/data/card.doc'; 5 | import { Card as CardModel } from 'modules/Card/data/card.doc'; 6 | import { useCardAssignees } from 'modules/Card/hooks/use-card-assignees'; 7 | import { useUndo } from 'hooks/use-undo'; 8 | import { CardModalProps } from 'modules/Card/components/CardModal/CardModalFull'; 9 | import Card from 'modules/Card/components/Card'; 10 | 11 | interface CardContainerProps { 12 | card: CardDocument; 13 | listId: string; 14 | previewMode: boolean; 15 | onUpdate: (card: CardDocument, data: Partial) => void; 16 | onRemove: (card: CardDocument) => void; 17 | onHideModal: () => void; 18 | onShowModal: (props: CardModalProps) => void; 19 | } 20 | 21 | const isLink = (tagName: string) => tagName.toLowerCase() === 'a'; 22 | const isTextArea = (tagName: string) => tagName.toLowerCase() === 'textarea'; 23 | 24 | const CardContainer = ({ 25 | card, 26 | listId, 27 | previewMode, 28 | onUpdate, 29 | onRemove, 30 | onHideModal, 31 | onShowModal, 32 | }: CardContainerProps) => { 33 | const { assignees } = useCardAssignees(card); 34 | 35 | const onClose = useCallback(async () => { 36 | onRemove(card); 37 | }, [card, onRemove]); 38 | 39 | const updateCard = useCallback( 40 | (data: Partial) => { 41 | onUpdate(card, data); 42 | }, 43 | [card, onUpdate] 44 | ); 45 | 46 | const { action, isHidden } = useUndo({ 47 | onCloseComplete: onClose, 48 | onAction: onHideModal, 49 | toastId: card.id, 50 | toastTitle: 'Card removed', 51 | }); 52 | 53 | const handleClick = useCallback( 54 | () => 55 | onShowModal({ 56 | card, 57 | listId, 58 | onUpdate: updateCard, 59 | onRemove: action, 60 | onClose: onHideModal, 61 | }), 62 | [card, listId, updateCard, action, onHideModal, onShowModal] 63 | ); 64 | 65 | const handleKeyDown = (event: React.KeyboardEvent) => { 66 | const { tagName } = event.target as HTMLElement; 67 | // Only open card on enter since spacebar is used by react-beautiful-dnd for keyboard dragging 68 | if (event.keyCode === 13 && !isLink(tagName) && !isTextArea(tagName)) { 69 | event.preventDefault(); 70 | handleClick(); 71 | } 72 | }; 73 | 74 | return ( 75 | <> 76 | 85 | 86 | ); 87 | }; 88 | 89 | export default observer(CardContainer); 90 | -------------------------------------------------------------------------------- /src/modules/Board/store/boards.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, PropsWithChildren, useContext } from 'react'; 2 | import { observable, action, makeObservable } from 'mobx'; 3 | import { useLocalStore } from 'mobx-react-lite'; 4 | import { Collection, Mode } from 'firestorter'; 5 | 6 | import BoardDocument from 'modules/Board/data/board.doc'; 7 | import ListDocument from 'documents/list.doc'; 8 | import ColorDocument from 'documents/color.doc'; 9 | 10 | class BoardsStore { 11 | currentBoard: BoardDocument = null; 12 | lists: ListDocument[] = []; 13 | colors: ColorDocument[] = []; 14 | 15 | private setCurrentBoard = (board: BoardDocument) => { 16 | this.currentBoard = board; 17 | this.fetchColors(); 18 | }; 19 | 20 | private setListsFromCurrentBoard = (lists: ListDocument[]) => { 21 | this.lists = lists; 22 | }; 23 | 24 | private setColors = (colors: ColorDocument[]) => { 25 | this.colors = colors; 26 | }; 27 | 28 | private fetchColors = () => { 29 | if (this.colors.length) return; 30 | 31 | const colors = new Collection(() => 'colors', { 32 | mode: Mode.Off, 33 | createDocument: (src, opts) => 34 | new ColorDocument(src, { 35 | ...opts, 36 | debug: __DEV__, 37 | debugName: 'Color document', 38 | }), 39 | debug: __DEV__, 40 | debugName: 'Colors collection', 41 | }); 42 | 43 | colors.fetch().then(data => this.setColors(data.docs)); 44 | }; 45 | 46 | setBoard = (board: BoardDocument) => { 47 | this.setCurrentBoard(board); 48 | this.setListsFromCurrentBoard(board.lists.docs); 49 | }; 50 | 51 | getList = (id: string) => { 52 | return this.lists.find(list => list.id === id); 53 | }; 54 | 55 | getColor = (id: string) => { 56 | return this.colors.find(color => color.id === id); 57 | }; 58 | 59 | constructor() { 60 | makeObservable(this, { 61 | currentBoard: observable, 62 | lists: observable, 63 | colors: observable, 64 | setCurrentBoard: action, 65 | setListsFromCurrentBoard: action, 66 | setColors: action 67 | }); 68 | } 69 | } 70 | 71 | const createStore = () => new BoardsStore(); 72 | 73 | type TStore = ReturnType; 74 | 75 | const BoardsContext = createContext(null); 76 | 77 | export const BoardsStoreProvider = ({ children }: PropsWithChildren<{}>) => { 78 | const store = useLocalStore(createStore); 79 | 80 | return ( 81 | {children} 82 | ); 83 | }; 84 | 85 | export const useBoardsStore = () => { 86 | const store = useContext(BoardsContext); 87 | 88 | if (!store) { 89 | throw new Error( 90 | 'useBoardsStore must be used within a BoardsStoreProvider.' 91 | ); 92 | } 93 | 94 | return store; 95 | }; 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 |
    3 | EasyFlow: Project Management Tool
    4 | 5 |

    6 | easyflow.live 7 |

    8 |

    9 | 10 |
    11 | 12 | Easy Flow is a real time collaborative project manager based on Kanban methodology. We make everything easier so you and your team can focus on complete tasks and ship great products. 13 | 14 | ### Tech stack 15 | 16 | - [Typescript](https://github.com/microsoft/TypeScript) 17 | - [React](https://github.com/facebook/react) 18 | - [Next.js](https://github.com/zeit/next.js/) 19 | - [TailwindCSS](https://github.com/tailwindcss/tailwindcss) 20 | - [MobX](https://github.com/mobxjs/mobx) 21 | - [Google Firebase](firebase.google.com/) 22 | 23 | ### Development 24 | 25 | #### Simplified setup 26 | 27 | ```shell 28 | # Clone this reposittory 29 | 30 | cd easyflow 31 | 32 | npm install 33 | 34 | npm run dev 35 | ``` 36 | 37 | The app will run on http://127.0.0.1:3000 38 | 39 | You need to create a file with the name `.env` in the root directory with the following variables: 40 | 41 | ``` 42 | REACT_APP_API_KEY = 43 | REACT_APP_AUTH_DOMAIN = 44 | REACT_APP_DATABASE_URL = 45 | REACT_APP_PROJECT_ID = 46 | REACT_APP_STORAGE_BUCKET = 47 | REACT_APP_MESSAGING_SENDER_ID = 48 | REACT_APP_ID = 49 | PORT = 3000 50 | ``` 51 | 52 | ## Contributors ✨ 53 | 54 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 55 | 56 | 57 | 58 | 59 | 60 | 71 | 80 | 81 |
    61 | 62 | Fellipe Pinheiro 63 |
    64 | Fellipe Pinheiro 65 |
    66 |
    67 | 💻 68 | 🎨 69 | 👀 70 |
    72 | 73 | Erick Almeida 74 |
    Erick Almeida 75 |
    76 |
    77 | 💻 78 | 🤔 79 |
    82 | 83 | 84 | 85 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 86 | -------------------------------------------------------------------------------- /src/components/shared/CardAdder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { Collection } from 'firestorter'; 4 | 5 | import CardDocument from 'modules/Card/data/card.doc'; 6 | import ListDocument from 'documents/list.doc'; 7 | import Input from 'components/shared/Input'; 8 | import { useKeySubmit } from 'hooks/use-key-submit'; 9 | import ClickOutside from './ClickOutside'; 10 | import { emitter } from 'libs/emitter'; 11 | import { useAppToast } from 'hooks/use-app-toast'; 12 | 13 | interface CardAdderProps { 14 | cards: Collection; 15 | limit: number; 16 | list: ListDocument; 17 | } 18 | 19 | const CardAdder = ({ cards, limit, list }: CardAdderProps) => { 20 | const toast = useAppToast(); 21 | const [newText, setNewText] = useState(''); 22 | const [isOpen, setIsOpen] = useState(false); 23 | 24 | const toggleCardComposer = () => setIsOpen(!isOpen); 25 | 26 | const handleChange = (event: React.ChangeEvent) => 27 | setNewText(event.target.value); 28 | 29 | const createCard = async () => { 30 | if (newText === '') return; 31 | 32 | //current size plus 1 33 | const amount = cards.docs.length + 1; 34 | const hasLimit = limit !== 0; 35 | const greaterThanLimit = amount > limit; 36 | 37 | if (hasLimit && greaterThanLimit) { 38 | toast({ title: 'Cards limit reached!', id: list.id }); 39 | } 40 | 41 | const index = (await cards.ref.get()).size; 42 | const createdCard = await cards.add({ 43 | text: newText, 44 | color: '#a0aec0', 45 | date: '', 46 | index, 47 | createdAt: Date.now(), 48 | listBefore: list.ref, 49 | title: newText, 50 | }); 51 | 52 | toggleCardComposer(); 53 | setNewText(''); 54 | 55 | emitter.emit('ADD_CARD', { 56 | title: newText, 57 | listId: list.id, 58 | cardId: createdCard.id, 59 | }); 60 | }; 61 | 62 | const handleSubmit = async (event: React.FormEvent) => { 63 | event.preventDefault(); 64 | createCard(); 65 | }; 66 | 67 | const handleKeyDown = useKeySubmit(createCard, toggleCardComposer); 68 | 69 | return isOpen ? ( 70 | 71 |
    72 | 81 |
    82 |
    83 | ) : ( 84 | 90 | ); 91 | }; 92 | 93 | export default observer(CardAdder); 94 | -------------------------------------------------------------------------------- /src/components/shared/Cards.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Droppable } from 'react-beautiful-dnd'; 3 | import { observer } from 'mobx-react-lite'; 4 | import { Collection } from 'firestorter'; 5 | import styled from 'styled-components'; 6 | 7 | import CardDocument, { Card as CardModel } from 'modules/Card/data/card.doc'; 8 | import CardPlaceholder from 'components/shared/CardPlaceholder'; 9 | import Card from 'modules/Card'; 10 | import { emitter } from 'libs/emitter'; 11 | import DraggableElement from './DraggableElement'; 12 | import { useCardFullModal } from 'modules/Board/components/CardModalProvider'; 13 | 14 | interface CardProps { 15 | cards: Collection; 16 | listId: string; 17 | previewMode?: boolean; 18 | } 19 | 20 | const Cards = ({ cards, listId, previewMode }: CardProps) => { 21 | const { isLoading } = cards; 22 | 23 | const { isOpen, hideModal, showModal } = useCardFullModal(); 24 | 25 | const removeCard = (card: CardDocument) => { 26 | card.ref.delete().then(() => 27 | emitter.emit('REMOVE_CARD', { 28 | text: card.data.text, 29 | title: card.data.title || '', 30 | listId, 31 | }) 32 | ); 33 | }; 34 | 35 | const updateCard = (card: CardDocument, data: Partial) => { 36 | const oldData = { ...card.data }; 37 | 38 | card.ref.update(data).then(() => 39 | emitter.emit('EDIT_CARD', { 40 | cardId: card.id, 41 | newText: data.text || oldData.text, 42 | oldText: oldData.text, 43 | newTitle: data.title || oldData.title, 44 | oldTitle: oldData.title, 45 | listId, 46 | }) 47 | ); 48 | }; 49 | 50 | return ( 51 | 52 | {provided => ( 53 | 54 | {isLoading ? ( 55 | 56 | ) : ( 57 | cards.docs.map((card, index) => ( 58 | 64 | 74 | 75 | )) 76 | )} 77 | 78 | {provided.placeholder} 79 | 80 | )} 81 | 82 | ); 83 | }; 84 | 85 | export default observer(Cards); 86 | 87 | const StyledCards = styled.div` 88 | min-height: 1px; 89 | 90 | & > .card:first-child { 91 | margin-top: 0; 92 | } 93 | & > .card:last-child { 94 | margin-bottom: 0; 95 | } 96 | `; 97 | -------------------------------------------------------------------------------- /src/components/shared/NewBoardForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState, KeyboardEvent, ChangeEvent } from 'react'; 2 | 3 | import { useSession } from 'hooks/use-session'; 4 | import Button from 'components/shared/Button'; 5 | import Input from 'components/shared/Input'; 6 | import { isAlphanumeric } from 'helpers/is-alphanumeric'; 7 | 8 | const MIN_CHAR = 2; 9 | const MAX_CHAR = 6; 10 | 11 | const isBoardCodeValid = (code: string) => 12 | code !== '' && 13 | code.length >= MIN_CHAR && 14 | code.length <= MAX_CHAR && 15 | isAlphanumeric(code); 16 | 17 | const isBoardTitleValid = (title: string) => title !== ''; 18 | 19 | interface NewBoardFormProps { 20 | onKeyDown?: (event: KeyboardEvent) => void; 21 | onSubmit: ({ 22 | title, 23 | code, 24 | index, 25 | }: { 26 | title: string; 27 | code: string; 28 | index: number; 29 | }) => void; 30 | } 31 | 32 | const NewBoardForm = ({ 33 | onKeyDown = () => {}, 34 | onSubmit, 35 | }: NewBoardFormProps) => { 36 | const { userDoc } = useSession(); 37 | const [title, setTitle] = useState(''); 38 | const [code, setCode] = useState(''); 39 | 40 | const handleTitleChange = (e: ChangeEvent) => 41 | setTitle(e.target.value); 42 | const handleCodeChange = (e: ChangeEvent) => 43 | setCode(e.target.value.toUpperCase()); 44 | 45 | const handleSubmit = async (event: React.FormEvent) => { 46 | event.preventDefault(); 47 | if (title === '' || code === '') return; 48 | 49 | const index = userDoc.boards.docs.length; 50 | 51 | onSubmit({ 52 | title, 53 | code, 54 | index, 55 | }); 56 | 57 | setTitle(''); 58 | setCode(''); 59 | }; 60 | 61 | return ( 62 |
    63 |
    64 | New board 65 | 66 | 75 | 76 | 77 | 78 | Must be between {MIN_CHAR} and {MAX_CHAR} letters or numbers 79 | 80 | 90 | 91 | 99 |
    100 |
    101 | ); 102 | }; 103 | export default NewBoardForm; 104 | -------------------------------------------------------------------------------- /src/pages/invitation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Title } from 'react-head'; 3 | import Router from 'next/router'; 4 | import { observer } from 'mobx-react-lite'; 5 | import Link from 'next/link'; 6 | import { NextPage } from 'next'; 7 | 8 | import firebase from 'services/firebase.service'; 9 | import { useSession } from 'hooks/use-session'; 10 | import Header from 'modules/LandingPage/Header'; 11 | import { SafariButtonWarning } from 'components/shared'; 12 | import { InviteStatus } from 'modules/Board/data/board-invite.doc'; 13 | import Loader from 'components/shared/Loader'; 14 | 15 | const Shell: React.FC = ({ children }) => ( 16 |
    17 |
    18 |
    19 | {children} 20 |
    21 |
    22 |
    23 | ); 24 | 25 | const Invitation: NextPage<{ token?: string }> = ({ token }) => { 26 | const { isLogged, user } = useSession(); 27 | const [error, setError] = useState(null); 28 | 29 | useEffect(() => { 30 | async function fetchInvite() { 31 | const i = await firebase.getBoardInvite(token as string).get(); 32 | 33 | if (!i.exists) { 34 | setError('Invalid token'); 35 | return null; 36 | } 37 | 38 | const invite = i.data(); 39 | 40 | if (invite.user.id !== user.email) { 41 | setError('This invite is not for you'); 42 | return null; 43 | } 44 | 45 | if (invite.status !== InviteStatus.ACCEPTED) { 46 | await i.ref.update({ status: InviteStatus.ACCEPTED }); 47 | } 48 | 49 | return invite; 50 | } 51 | 52 | if (user) { 53 | fetchInvite().then(invite => { 54 | if (invite) { 55 | Router.replace(`/b/${invite.board.id}`); 56 | } 57 | }); 58 | } 59 | }, [user, token]); 60 | 61 | if (error) 62 | return ( 63 | 64 |

    65 | {error} 66 |

    67 | 68 | Go back to home 69 | 70 |
    71 | ); 72 | 73 | return isLogged ? ( 74 | 75 | ) : ( 76 |
    77 | Easy Flow 78 | 79 |
    80 | 81 | 82 |

    83 | Log in to EasyFlow 84 |

    85 | 86 |
    87 |
    88 | ); 89 | }; 90 | 91 | Invitation.getInitialProps = ctx => { 92 | if (ctx.query.token) { 93 | return { token: ctx.query.token as string }; 94 | } 95 | 96 | if (ctx.res) { 97 | ctx.res.writeHead(302, { Location: '/' }); 98 | ctx.res.end(); 99 | } 100 | return {}; 101 | }; 102 | 103 | export default observer(Invitation); 104 | -------------------------------------------------------------------------------- /src/components/shared/Assignees.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { MdClose } from 'react-icons/md'; 4 | import styled from 'styled-components'; 5 | import { useTransition, useSpring, animated } from 'react-spring'; 6 | 7 | import { useCardAssignees } from 'modules/Card/hooks/use-card-assignees'; 8 | import { useSession } from 'hooks/use-session'; 9 | import { useAssign } from 'modules/Card/hooks/use-assign'; 10 | import CardDocument from 'modules/Card/data/card.doc'; 11 | import Avatar from 'components/shared/Avatar'; 12 | 13 | const Container = styled.div` 14 | & button { 15 | transition: transform 0.3s, opacity 0.3s; 16 | transform: translateX(-100%); 17 | opacity: 0; 18 | } 19 | 20 | &:hover button { 21 | transform: translateX(0); 22 | opacity: 1; 23 | } 24 | `; 25 | 26 | interface AssigneesProps { 27 | card: CardDocument; 28 | listId: string; 29 | } 30 | 31 | export const Assignees: React.FC = observer(props => { 32 | const { card, listId } = props; 33 | const { assignees } = useCardAssignees(card); 34 | const { userDoc } = useSession(); 35 | const toogleAssigment = useAssign(card, listId); 36 | 37 | const assigneesId = assignees.map(a => a.id); 38 | const hasMySelfAsAssignee = assigneesId.includes(userDoc.id); 39 | 40 | const transitions = useTransition(assignees, item => item.id, { 41 | initial: { transform: 'translate3d(0px, 0, 0)', opacity: 0 }, 42 | from: { transform: 'translate3d(40px, 0, 0)', opacity: 0 }, 43 | enter: { transform: 'translate3d(0, 0px, 0)', opacity: 1 }, 44 | leave: { transform: 'translate3d(40px, 0, 0)', opacity: 0 }, 45 | }); 46 | 47 | const animate = useSpring({ 48 | delay: 600, 49 | opacity: !hasMySelfAsAssignee ? 1 : 0, 50 | }); 51 | 52 | return ( 53 |
    54 | {!hasMySelfAsAssignee && ( 55 | 56 |
    57 | 63 |
    64 |
    65 | )} 66 | 67 | {transitions.map(({ item: assignee, key, props }) => ( 68 | 69 | 70 | 76 | {assignee.username} 77 | 78 | {userDoc.id === assignee.email && ( 79 | 86 | )} 87 | 88 | 89 | ))} 90 |
    91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /src/components/shared/DueCalendar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import usePortal from 'react-cool-portal'; 4 | import { useSpring, animated } from 'react-spring'; 5 | import styled from 'styled-components'; 6 | 7 | import { useThinDisplay } from 'hooks/use-thin-display'; 8 | import { useRect } from 'hooks/use-rect'; 9 | import { Card } from 'modules/Card/data/card.doc'; 10 | import { Checkbox } from 'components/shared'; 11 | import Calendar from 'components/shared/Calendar'; 12 | import DueDate from 'components/shared/DueCalendar/DueDate'; 13 | 14 | const TOP_PADDING = 10; 15 | 16 | const Container = styled.div` 17 | & span.done { 18 | transition: transform 0.3s, opacity 0.3s; 19 | transform: translateX(-50%); 20 | opacity: 0; 21 | } 22 | 23 | &:hover span.done { 24 | transform: translateX(0); 25 | opacity: 1; 26 | } 27 | `; 28 | 29 | interface DueCalendarProps { 30 | date: any; 31 | completed: boolean; 32 | onUpdate: (data: Partial) => void; 33 | } 34 | 35 | const getStyle = (isThinDisplay: boolean, style: ClientRect) => ({ 36 | top: isThinDisplay ? '50%' : style.top + TOP_PADDING, 37 | left: isThinDisplay ? '50%' : style.left, 38 | transform: isThinDisplay ? 'translate(-50%, -50%)' : '', 39 | }); 40 | 41 | export const DueCalendar: React.FC = observer(props => { 42 | const { date, completed, onUpdate } = props; 43 | 44 | const isThinDisplay = useThinDisplay(); 45 | 46 | const calendaButtonRef = useRef(); 47 | const [buttonRect] = useRect(calendaButtonRef); 48 | 49 | const saveCardCalendar = (date: Date) => onUpdate({ date }); 50 | const removeCardCalendar = () => onUpdate({ date: '' }); 51 | 52 | const toggleDueStatus = (e: React.ChangeEvent) => 53 | onUpdate({ completed: e.target.checked }); 54 | 55 | const { Portal, toggle, isShow } = usePortal({ defaultShow: false }); 56 | const style = useSpring({ opacity: isShow ? 1 : 0 }); 57 | 58 | const toDate = date ? new Date(date.toDate()) : new Date(); 59 | 60 | const customStyle = getStyle(isThinDisplay, buttonRect); 61 | 62 | return ( 63 | 64 |
    65 | 72 |
    73 | 74 | 75 | 76 | Done? 77 | 78 | 79 | 80 | 87 | 93 | 94 | 95 |
    96 | ); 97 | }); 98 | -------------------------------------------------------------------------------- /src/store/users.tsx: -------------------------------------------------------------------------------- 1 | import { observable, runInAction, action, makeObservable } from 'mobx'; 2 | import { createContext, PropsWithChildren, useContext } from 'react'; 3 | import { useLocalStore } from 'mobx-react-lite'; 4 | import firebase from 'firebase/app'; 5 | 6 | import UserDocument from '../documents/user.doc'; 7 | 8 | interface IUser { 9 | id: string; 10 | username: string; 11 | photo: string; 12 | email: string; 13 | } 14 | 15 | export class User implements IUser { 16 | id: string; 17 | username: string; 18 | photo: string; 19 | email: string; 20 | 21 | constructor(private store: UsersStore) { 22 | makeObservable(this, { 23 | updateFromJson: action, 24 | }); 25 | } 26 | 27 | delete() { 28 | this.store.remove(this); 29 | } 30 | 31 | updateFromJson = (json: IUser) => { 32 | this.id = json.id; 33 | this.username = json.username; 34 | this.photo = json.photo; 35 | this.email = json.email; 36 | }; 37 | } 38 | 39 | class UsersStore { 40 | users: User[] = []; 41 | isLoading = false; 42 | currentUser: UserDocument = null; 43 | 44 | constructor() { 45 | makeObservable(this, { 46 | users: observable, 47 | isLoading: observable, 48 | currentUser: observable, 49 | createUser: action, 50 | remove: action, 51 | loadUsers: action, 52 | }); 53 | } 54 | 55 | createUser = (data: IUser) => { 56 | const { id, username, photo, email } = data; 57 | 58 | const u = this.getUser(id); 59 | if (u) return u; 60 | 61 | const user = new User(this); 62 | user.updateFromJson({ id, username, photo, email }); 63 | this.users.push(user); 64 | return user; 65 | }; 66 | 67 | remove = (user: User) => { 68 | this.users.splice(this.users.indexOf(user), 1); 69 | }; 70 | 71 | getUser = (id: string) => { 72 | return this.users.find(u => u.id === id); 73 | }; 74 | 75 | loadUsers = async (usersRef: firebase.firestore.DocumentReference[] = []) => { 76 | let loadedUsers = []; 77 | this.isLoading = true; 78 | 79 | loadedUsers = await Promise.all( 80 | usersRef.map(async user => { 81 | const cachedUser = this.users.find(u => u.id === user.id); 82 | 83 | if (cachedUser) return cachedUser; 84 | 85 | const data = (await user.get()).data(); 86 | 87 | // @ts-ignore 88 | return this.createUser({ ...data, id: user.id }); 89 | }) 90 | ); 91 | 92 | runInAction(() => { 93 | this.isLoading = false; 94 | this.users = [...new Set([...this.users, ...loadedUsers])]; 95 | }); 96 | }; 97 | } 98 | 99 | const createStore = () => new UsersStore(); 100 | 101 | type TStore = ReturnType; 102 | 103 | const UsersContext = createContext(null); 104 | 105 | export const UsersStoreProvider = ({ children }: PropsWithChildren<{}>) => { 106 | const store = useLocalStore(createStore); 107 | 108 | return ( 109 | {children} 110 | ); 111 | }; 112 | 113 | export const useUsersStore = () => { 114 | const store = useContext(UsersContext); 115 | 116 | if (!store) { 117 | throw new Error('useUsersStore must be used within a UsersStoreProvider.'); 118 | } 119 | 120 | return store; 121 | }; 122 | --------------------------------------------------------------------------------