Repository: Pong420/google-tasks-desktop Branch: master Commit: 100dedb61e77 Files: 185 Total size: 266.3 KB Directory structure: gitextract_58hd8j73/ ├── .eslintcache ├── .eslintignore ├── .gitignore ├── .prettierrc ├── LICENSE ├── Procfile ├── README.md ├── common.d.ts ├── config-overrides.js ├── electron/ │ ├── electron.d.ts │ ├── main.ts │ ├── menu.ts │ ├── preload/ │ │ ├── index.ts │ │ └── theme.ts │ ├── storage.ts │ └── tsconfig.json ├── mock-fs.js ├── package.json ├── public/ │ ├── icon/ │ │ └── icon.icns │ ├── index.html │ └── manifest.json ├── scripts/ │ ├── component.js │ ├── electron-wait-react.js │ ├── redux.js │ ├── template/ │ │ ├── store/ │ │ │ ├── actions.tmpl │ │ │ ├── epics.tmpl │ │ │ ├── reducers.tmpl │ │ │ └── store.tmpl │ │ └── useActions.tmpl │ └── type.js ├── src/ │ ├── App.tsx │ ├── components/ │ │ ├── AppRegion/ │ │ │ ├── AppRegion.scss │ │ │ ├── AppRegion.tsx │ │ │ ├── WindowsTitleBar.tsx │ │ │ └── index.ts │ │ ├── KeyboardShortcuts/ │ │ │ ├── KeyboardShortcuts.scss │ │ │ ├── KeyboardShortcuts.tsx │ │ │ ├── index.ts │ │ │ └── shortcuts.json │ │ ├── Mui/ │ │ │ ├── DeleteIcon.tsx │ │ │ ├── Dialog/ │ │ │ │ ├── ConfirmDialog.tsx │ │ │ │ ├── Dialog.scss │ │ │ │ ├── FormDialog.tsx │ │ │ │ ├── FullScreenDialog.tsx │ │ │ │ └── index.ts │ │ │ ├── Dropdown/ │ │ │ │ ├── Dropdown.scss │ │ │ │ ├── Dropdown.tsx │ │ │ │ └── index.ts │ │ │ ├── EditIcon.tsx │ │ │ ├── IconButton/ │ │ │ │ ├── IconButton.scss │ │ │ │ ├── IconButton.tsx │ │ │ │ └── index.ts │ │ │ ├── Input/ │ │ │ │ ├── Input.scss │ │ │ │ ├── Input.tsx │ │ │ │ └── index.ts │ │ │ ├── Menu/ │ │ │ │ ├── Menu.scss │ │ │ │ ├── Menu.tsx │ │ │ │ ├── MenuItem.tsx │ │ │ │ ├── index.ts │ │ │ │ └── useMuiMenu.ts │ │ │ ├── Tooltip.tsx │ │ │ └── index.ts │ │ ├── Preferences/ │ │ │ ├── AccentColor.tsx │ │ │ ├── Preferences.scss │ │ │ ├── Preferences.tsx │ │ │ ├── Storage.tsx │ │ │ ├── ThemeSelector.tsx │ │ │ ├── TitleBarSelector.tsx │ │ │ └── index.ts │ │ ├── PrivateRoute.tsx │ │ └── Switch/ │ │ ├── Switch.scss │ │ ├── Switch.tsx │ │ └── index.ts │ ├── constants/ │ │ ├── index.ts │ │ └── paths.json │ ├── date.d.ts │ ├── hooks/ │ │ ├── crud-reducer/ │ │ │ ├── bindDispatch.ts │ │ │ ├── crudAction.ts │ │ │ ├── crudReducer.ts │ │ │ ├── crudSelector.ts │ │ │ ├── index.ts │ │ │ ├── useActions.ts │ │ │ └── useCRUDReducer.ts │ │ ├── useActions.ts │ │ ├── useBoolean.ts │ │ └── useMouseTrap.ts │ ├── index.scss │ ├── index.tsx │ ├── pages/ │ │ ├── Auth/ │ │ │ ├── Auth.scss │ │ │ ├── Auth.tsx │ │ │ ├── FileUpload.tsx │ │ │ └── index.ts │ │ └── TaskList/ │ │ ├── CompletedTaskList/ │ │ │ ├── CompletedTaskList.scss │ │ │ ├── CompletedTaskList.tsx │ │ │ └── index.ts │ │ ├── NewTask/ │ │ │ ├── NewTask.scss │ │ │ ├── NewTask.tsx │ │ │ └── index.ts │ │ ├── Task/ │ │ │ ├── CompletedTask.tsx │ │ │ ├── DatePicker/ │ │ │ │ ├── DatePicker.scss │ │ │ │ ├── DatePicker.tsx │ │ │ │ └── index.ts │ │ │ ├── DateTimeDialog/ │ │ │ │ ├── DateTimeDialog.scss │ │ │ │ ├── DateTimeDialog.tsx │ │ │ │ └── index.ts │ │ │ ├── Task.scss │ │ │ ├── Task.tsx │ │ │ ├── TaskInput.tsx │ │ │ ├── TodoTask/ │ │ │ │ ├── TodoTask.scss │ │ │ │ ├── TodoTask.tsx │ │ │ │ ├── TodoTaskMenu.tsx │ │ │ │ └── index.ts │ │ │ ├── TodoTaskDetails/ │ │ │ │ ├── DateTimeButton.tsx │ │ │ │ ├── TodoTaskDetails.scss │ │ │ │ ├── TodoTaskDetails.tsx │ │ │ │ └── index.ts │ │ │ ├── ToggleCompleted.tsx │ │ │ └── index.ts │ │ ├── TaskList.scss │ │ ├── TaskList.tsx │ │ ├── TaskListDropdown/ │ │ │ ├── TaskListDropdown.scss │ │ │ ├── TaskListDropdown.tsx │ │ │ ├── TaskListDropdownItem.tsx │ │ │ └── index.ts │ │ ├── TaskListHeader/ │ │ │ ├── TaskListHeader.scss │ │ │ ├── TaskListHeader.tsx │ │ │ └── index.ts │ │ ├── TaskListMenu.tsx │ │ ├── TodoTaskList/ │ │ │ ├── TodoTaskList.scss │ │ │ ├── TodoTaskList.tsx │ │ │ ├── TodoTaskListByDate.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── react-app-env.d.ts │ ├── scss/ │ │ ├── _functions.scss │ │ ├── _mixins.scss │ │ ├── _platform.scss │ │ ├── _theme.scss │ │ ├── _variables.scss │ │ ├── index.scss │ │ └── mixins/ │ │ ├── _animation.scss │ │ ├── _background.scss │ │ ├── _border.scss │ │ ├── _electron.scss │ │ ├── _flex.scss │ │ ├── _font.scss │ │ ├── _position.scss │ │ ├── _size.scss │ │ ├── _textHighlight.scss │ │ └── _textOverflow.scss │ ├── service/ │ │ ├── auth.ts │ │ ├── index.ts │ │ ├── task.ts │ │ └── tasksList.ts │ ├── serviceWorker.ts │ ├── store/ │ │ ├── actions/ │ │ │ ├── auth.ts │ │ │ ├── index.ts │ │ │ ├── preferences.ts │ │ │ ├── task.ts │ │ │ └── taskList.ts │ │ ├── epics/ │ │ │ ├── auth.ts │ │ │ ├── index.ts │ │ │ ├── preferences.ts │ │ │ ├── task.ts │ │ │ └── taskList.ts │ │ ├── index.ts │ │ ├── reducers/ │ │ │ ├── auth.ts │ │ │ ├── index.ts │ │ │ ├── preferences.ts │ │ │ ├── task.ts │ │ │ └── taskList.ts │ │ └── selectors/ │ │ ├── index.ts │ │ ├── preferences.ts │ │ ├── task.ts │ │ └── taskList.ts │ ├── theme.ts │ ├── typings/ │ │ └── index.ts │ └── utils/ │ ├── date.ts │ ├── form/ │ │ ├── form.ts │ │ ├── index.ts │ │ ├── typings.ts │ │ └── validators.ts │ ├── nprogress.ts │ └── uuid.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintcache ================================================ [{"/Users/Pong/Desktop/google-tasks-desktop/src/index.tsx":"1","/Users/Pong/Desktop/google-tasks-desktop/src/theme.ts":"2","/Users/Pong/Desktop/google-tasks-desktop/src/serviceWorker.ts":"3","/Users/Pong/Desktop/google-tasks-desktop/src/utils/date.ts":"4","/Users/Pong/Desktop/google-tasks-desktop/src/App.tsx":"5","/Users/Pong/Desktop/google-tasks-desktop/src/store/index.ts":"6","/Users/Pong/Desktop/google-tasks-desktop/src/components/PrivateRoute.tsx":"7","/Users/Pong/Desktop/google-tasks-desktop/src/components/AppRegion/index.ts":"8","/Users/Pong/Desktop/google-tasks-desktop/src/constants/index.ts":"9","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/index.ts":"10","/Users/Pong/Desktop/google-tasks-desktop/src/pages/Auth/index.ts":"11","/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/index.ts":"12","/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/index.ts":"13","/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/index.ts":"14","/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/index.ts":"15","/Users/Pong/Desktop/google-tasks-desktop/src/components/AppRegion/AppRegion.tsx":"16","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskList.tsx":"17","/Users/Pong/Desktop/google-tasks-desktop/src/pages/Auth/Auth.tsx":"18","/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/task.ts":"19","/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/auth.ts":"20","/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/taskList.ts":"21","/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/preferences.ts":"22","/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/task.ts":"23","/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/preferences.ts":"24","/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/taskList.ts":"25","/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/preferences.ts":"26","/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/auth.ts":"27","/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/taskList.ts":"28","/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/task.ts":"29","/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/preferences.ts":"30","/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/taskList.ts":"31","/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/task.ts":"32","/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/auth.ts":"33","/Users/Pong/Desktop/google-tasks-desktop/src/components/AppRegion/WindowsTitleBar.tsx":"34","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTask/TodoTaskMenu.tsx":"35","/Users/Pong/Desktop/google-tasks-desktop/src/utils/nprogress.ts":"36","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/CompletedTaskList/index.ts":"37","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TodoTaskList/index.ts":"38","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/index.ts":"39","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/NewTask/index.ts":"40","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListHeader/index.ts":"41","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTaskDetails/index.ts":"42","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DateTimeDialog/index.ts":"43","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/index.ts":"44","/Users/Pong/Desktop/google-tasks-desktop/src/pages/Auth/FileUpload.tsx":"45","/Users/Pong/Desktop/google-tasks-desktop/src/service/index.ts":"46","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/DeleteIcon.tsx":"47","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DateTimeDialog/DateTimeDialog.tsx":"48","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTaskDetails/TodoTaskDetails.tsx":"49","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Tooltip.tsx":"50","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListHeader/TaskListHeader.tsx":"51","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/NewTask/NewTask.tsx":"52","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/EditIcon.tsx":"53","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TodoTaskList/TodoTaskList.tsx":"54","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/CompletedTaskList/CompletedTaskList.tsx":"55","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Input/index.ts":"56","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/bindDispatch.ts":"57","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/useBoolean.ts":"58","/Users/Pong/Desktop/google-tasks-desktop/src/service/auth.ts":"59","/Users/Pong/Desktop/google-tasks-desktop/src/service/tasksList.ts":"60","/Users/Pong/Desktop/google-tasks-desktop/src/service/task.ts":"61","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/useCRUDReducer.ts":"62","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/useActions.ts":"63","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/crudSelector.ts":"64","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/crudReducer.ts":"65","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/crudAction.ts":"66","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/index.ts":"67","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/IconButton/index.ts":"68","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dropdown/index.ts":"69","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/index.ts":"70","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListMenu.tsx":"71","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTaskDetails/DateTimeButton.tsx":"72","/Users/Pong/Desktop/google-tasks-desktop/src/utils/uuid.ts":"73","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Input/Input.tsx":"74","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TodoTaskList/TodoTaskListByDate.tsx":"75","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/useMuiMenu.ts":"76","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/index.ts":"77","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListDropdown/index.ts":"78","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/MenuItem.tsx":"79","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/FormDialog.tsx":"80","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/FullScreenDialog.tsx":"81","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/ConfirmDialog.tsx":"82","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dropdown/Dropdown.tsx":"83","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/IconButton/IconButton.tsx":"84","/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/Menu.tsx":"85","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DatePicker/index.ts":"86","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/CompletedTask.tsx":"87","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/Task.tsx":"88","/Users/Pong/Desktop/google-tasks-desktop/src/components/KeyboardShortcuts/index.ts":"89","/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/index.ts":"90","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListDropdown/TaskListDropdown.tsx":"91","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DatePicker/DatePicker.tsx":"92","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTask/index.ts":"93","/Users/Pong/Desktop/google-tasks-desktop/src/components/KeyboardShortcuts/KeyboardShortcuts.tsx":"94","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TaskInput.tsx":"95","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/ToggleCompleted.tsx":"96","/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/Preferences.tsx":"97","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListDropdown/TaskListDropdownItem.tsx":"98","/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/Storage.tsx":"99","/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/TitleBarSelector.tsx":"100","/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/AccentColor.tsx":"101","/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/ThemeSelector.tsx":"102","/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTask/TodoTask.tsx":"103","/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/index.ts":"104","/Users/Pong/Desktop/google-tasks-desktop/src/components/Switch/index.ts":"105","/Users/Pong/Desktop/google-tasks-desktop/src/hooks/useMouseTrap.ts":"106","/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/validators.ts":"107","/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/typings.ts":"108","/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/form.ts":"109","/Users/Pong/Desktop/google-tasks-desktop/src/components/Switch/Switch.tsx":"110"},{"size":1110,"mtime":1592723488168,"results":"111","hashOfConfig":"112"},{"size":464,"mtime":1592723488209,"results":"113","hashOfConfig":"112"},{"size":5201,"mtime":1583162203748,"results":"114","hashOfConfig":"112"},{"size":6372,"mtime":1596981268283,"results":"115","hashOfConfig":"112"},{"size":637,"mtime":1602170576947,"results":"116","hashOfConfig":"112"},{"size":1495,"mtime":1592723488204,"results":"117","hashOfConfig":"112"},{"size":411,"mtime":1592723488156,"results":"118","hashOfConfig":"112"},{"size":109,"mtime":1592723488144,"results":"119","hashOfConfig":"112"},{"size":49,"mtime":1583162203742,"results":"120","hashOfConfig":"112"},{"size":105,"mtime":1592723488194,"results":"121","hashOfConfig":"112"},{"size":89,"mtime":1592723488170,"results":"122","hashOfConfig":"112"},{"size":298,"mtime":1592723488203,"results":"123","hashOfConfig":"112"},{"size":591,"mtime":1592723488206,"results":"124","hashOfConfig":"112"},{"size":83,"mtime":1602160477461,"results":"125","hashOfConfig":"112"},{"size":107,"mtime":1602241962356,"results":"126","hashOfConfig":"112"},{"size":944,"mtime":1603281840712,"results":"127","hashOfConfig":"112"},{"size":1803,"mtime":1602253317901,"results":"128","hashOfConfig":"112"},{"size":1882,"mtime":1596635930990,"results":"129","hashOfConfig":"112"},{"size":2086,"mtime":1592723488208,"results":"130","hashOfConfig":"112"},{"size":418,"mtime":1603022608231,"results":"131","hashOfConfig":"112"},{"size":1773,"mtime":1603022632549,"results":"132","hashOfConfig":"112"},{"size":501,"mtime":1603022622282,"results":"133","hashOfConfig":"112"},{"size":3465,"mtime":1603025000505,"results":"134","hashOfConfig":"112"},{"size":280,"mtime":1603198543118,"results":"135","hashOfConfig":"112"},{"size":1035,"mtime":1592723488208,"results":"136","hashOfConfig":"112"},{"size":594,"mtime":1602247007353,"results":"137","hashOfConfig":"112"},{"size":483,"mtime":1596981267967,"results":"138","hashOfConfig":"112"},{"size":2116,"mtime":1603026502618,"results":"139","hashOfConfig":"112"},{"size":6780,"mtime":1603199415382,"results":"140","hashOfConfig":"112"},{"size":2578,"mtime":1603198543117,"results":"141","hashOfConfig":"112"},{"size":3438,"mtime":1603024786959,"results":"142","hashOfConfig":"112"},{"size":9421,"mtime":1603023706197,"results":"143","hashOfConfig":"112"},{"size":917,"mtime":1592723488202,"results":"144","hashOfConfig":"112"},{"size":1437,"mtime":1613657053795,"results":"145","hashOfConfig":"112"},{"size":2267,"mtime":1602163027639,"results":"146","hashOfConfig":"112"},{"size":278,"mtime":1592723488210,"results":"147","hashOfConfig":"112"},{"size":141,"mtime":1592723488174,"results":"148","hashOfConfig":"112"},{"size":121,"mtime":1592723488194,"results":"149","hashOfConfig":"112"},{"size":218,"mtime":1602249256395,"results":"150","hashOfConfig":"112"},{"size":101,"mtime":1592723488175,"results":"151","hashOfConfig":"112"},{"size":129,"mtime":1592723488191,"results":"152","hashOfConfig":"112"},{"size":133,"mtime":1592723488185,"results":"153","hashOfConfig":"112"},{"size":129,"mtime":1592723488178,"results":"154","hashOfConfig":"112"},{"size":189,"mtime":1603021063330,"results":"155","hashOfConfig":"112"},{"size":2226,"mtime":1598365016422,"results":"156","hashOfConfig":"112"},{"size":77,"mtime":1592723488198,"results":"157","hashOfConfig":"112"},{"size":414,"mtime":1592723488146,"results":"158","hashOfConfig":"112"},{"size":1866,"mtime":1592723488178,"results":"159","hashOfConfig":"112"},{"size":4456,"mtime":1603281875394,"results":"160","hashOfConfig":"112"},{"size":1019,"mtime":1602252141332,"results":"161","hashOfConfig":"112"},{"size":1741,"mtime":1592723488191,"results":"162","hashOfConfig":"112"},{"size":924,"mtime":1592723488175,"results":"163","hashOfConfig":"112"},{"size":439,"mtime":1592723488150,"results":"164","hashOfConfig":"112"},{"size":2678,"mtime":1602165733249,"results":"165","hashOfConfig":"112"},{"size":1222,"mtime":1603198551652,"results":"166","hashOfConfig":"112"},{"size":93,"mtime":1592723488152,"results":"167","hashOfConfig":"112"},{"size":529,"mtime":1603021069621,"results":"168","hashOfConfig":"112"},{"size":333,"mtime":1592584359292,"results":"169","hashOfConfig":"112"},{"size":1231,"mtime":1592723488198,"results":"170","hashOfConfig":"112"},{"size":515,"mtime":1603022578891,"results":"171","hashOfConfig":"112"},{"size":1577,"mtime":1603023631481,"results":"172","hashOfConfig":"112"},{"size":1304,"mtime":1603021063330,"results":"173","hashOfConfig":"112"},{"size":389,"mtime":1603021063330,"results":"174","hashOfConfig":"112"},{"size":783,"mtime":1603021063330,"results":"175","hashOfConfig":"112"},{"size":5623,"mtime":1603026628327,"results":"176","hashOfConfig":"112"},{"size":4455,"mtime":1603026549170,"results":"177","hashOfConfig":"112"},{"size":147,"mtime":1592723488154,"results":"178","hashOfConfig":"112"},{"size":113,"mtime":1592723488151,"results":"179","hashOfConfig":"112"},{"size":105,"mtime":1592723488150,"results":"180","hashOfConfig":"112"},{"size":124,"mtime":1592723488149,"results":"181","hashOfConfig":"112"},{"size":4687,"mtime":1603024406906,"results":"182","hashOfConfig":"112"},{"size":782,"mtime":1592723488183,"results":"183","hashOfConfig":"112"},{"size":196,"mtime":1592723488210,"results":"184","hashOfConfig":"112"},{"size":536,"mtime":1602248414255,"results":"185","hashOfConfig":"112"},{"size":1442,"mtime":1592723488193,"results":"186","hashOfConfig":"112"},{"size":1736,"mtime":1592723488154,"results":"187","hashOfConfig":"112"},{"size":150,"mtime":1592723488186,"results":"188","hashOfConfig":"112"},{"size":137,"mtime":1592723488190,"results":"189","hashOfConfig":"112"},{"size":1270,"mtime":1592723488153,"results":"190","hashOfConfig":"112"},{"size":1797,"mtime":1592723488148,"results":"191","hashOfConfig":"112"},{"size":1991,"mtime":1606554789272,"results":"192","hashOfConfig":"112"},{"size":1528,"mtime":1603281840713,"results":"193","hashOfConfig":"112"},{"size":1618,"mtime":1592723488150,"results":"194","hashOfConfig":"112"},{"size":1054,"mtime":1592723488151,"results":"195","hashOfConfig":"112"},{"size":824,"mtime":1592723488153,"results":"196","hashOfConfig":"112"},{"size":113,"mtime":1592723488177,"results":"197","hashOfConfig":"112"},{"size":747,"mtime":1592723488176,"results":"198","hashOfConfig":"112"},{"size":1354,"mtime":1603198978400,"results":"199","hashOfConfig":"112"},{"size":141,"mtime":1592723488145,"results":"200","hashOfConfig":"112"},{"size":117,"mtime":1592723488155,"results":"201","hashOfConfig":"112"},{"size":2306,"mtime":1600613861713,"results":"202","hashOfConfig":"112"},{"size":3926,"mtime":1592723488176,"results":"203","hashOfConfig":"112"},{"size":105,"mtime":1592723488182,"results":"204","hashOfConfig":"112"},{"size":1158,"mtime":1606554168813,"results":"205","hashOfConfig":"112"},{"size":1292,"mtime":1592723488179,"results":"206","hashOfConfig":"112"},{"size":1347,"mtime":1603198986682,"results":"207","hashOfConfig":"112"},{"size":6039,"mtime":1606554168828,"results":"208","hashOfConfig":"112"},{"size":680,"mtime":1600613251466,"results":"209","hashOfConfig":"112"},{"size":445,"mtime":1602247724151,"results":"210","hashOfConfig":"112"},{"size":2056,"mtime":1603281840714,"results":"211","hashOfConfig":"112"},{"size":696,"mtime":1603198543110,"results":"212","hashOfConfig":"112"},{"size":685,"mtime":1603198543112,"results":"213","hashOfConfig":"112"},{"size":6951,"mtime":1603198952197,"results":"214","hashOfConfig":"112"},{"size":119,"mtime":1602247007354,"results":"215","hashOfConfig":"112"},{"size":97,"mtime":1592723488157,"results":"216","hashOfConfig":"112"},{"size":591,"mtime":1592723488167,"results":"217","hashOfConfig":"112"},{"size":3097,"mtime":1602247007355,"results":"218","hashOfConfig":"112"},{"size":1869,"mtime":1602247007355,"results":"219","hashOfConfig":"112"},{"size":8074,"mtime":1602249855641,"results":"220","hashOfConfig":"112"},{"size":697,"mtime":1592723488157,"results":"221","hashOfConfig":"112"},{"filePath":"222","messages":"223","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"b11wxz",{"filePath":"224","messages":"225","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"226","messages":"227","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"228","messages":"229","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"230","messages":"231","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"232","messages":"233","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"234","messages":"235","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"236","messages":"237","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"238","messages":"239","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"240","messages":"241","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"242","messages":"243","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"244","messages":"245","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"246","messages":"247","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"248","messages":"249","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"250","messages":"251","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"252","messages":"253","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"254","messages":"255","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"256","messages":"257","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"258","messages":"259","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"260","messages":"261","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"262","messages":"263","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"264","messages":"265","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"266","messages":"267","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"268","messages":"269","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"270","messages":"271","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"272","messages":"273","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"274","messages":"275","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"276","messages":"277","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"278","messages":"279","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"280","messages":"281","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"282","messages":"283","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"284","messages":"285","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"286","messages":"287","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"288","messages":"289","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"290","messages":"291","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"292","messages":"293","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"294","messages":"295","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"296","messages":"297","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"298","messages":"299","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"300","messages":"301","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"302","messages":"303","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"304","messages":"305","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"306","messages":"307","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"308","messages":"309","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"310","messages":"311","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"312","messages":"313","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"314","messages":"315","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"316","messages":"317","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"318","messages":"319","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"320","messages":"321","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"322","messages":"323","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"324","messages":"325","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"326","messages":"327","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"328","messages":"329","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"330","messages":"331","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"332","messages":"333","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"334","messages":"335","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"336","messages":"337","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"338","messages":"339","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"340","messages":"341","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"342","messages":"343","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"344","messages":"345","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"346","messages":"347","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"348","messages":"349","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"350","messages":"351","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"352","messages":"353","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"354","messages":"355","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"356","messages":"357","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"358","messages":"359","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"360","messages":"361","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"362","messages":"363","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"364","messages":"365","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"366","messages":"367","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"368","messages":"369","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"370","messages":"371","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"372","messages":"373","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"374","messages":"375","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"376","messages":"377","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"378","messages":"379","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"380","messages":"381","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"382","messages":"383","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"384","messages":"385","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"386","messages":"387","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"388","messages":"389","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"390","messages":"391","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"392","messages":"393","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"394","messages":"395","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"396","messages":"397","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"398","messages":"399","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"400","messages":"401","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"402","messages":"403","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"404","messages":"405","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"406","messages":"407","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"408","messages":"409","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"410","messages":"411","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"412","messages":"413","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"414","messages":"415","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"416","messages":"417","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"418","messages":"419","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"420","messages":"421","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"422","messages":"423","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"424","messages":"425","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"426","messages":"427","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"428","messages":"429","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"430","messages":"431","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"432","messages":"433","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"434","messages":"435","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"436","messages":"437","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"438","messages":"439","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"440","messages":"441","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/Pong/Desktop/google-tasks-desktop/src/index.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/theme.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/serviceWorker.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/date.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/App.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/PrivateRoute.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/AppRegion/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/constants/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/Auth/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/AppRegion/AppRegion.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskList.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/Auth/Auth.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/task.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/auth.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/taskList.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/preferences.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/actions/task.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/preferences.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/selectors/taskList.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/preferences.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/auth.ts",["442"],"/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/taskList.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/reducers/task.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/preferences.ts",["443"],"/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/taskList.ts",["444"],"/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/task.ts",["445"],"/Users/Pong/Desktop/google-tasks-desktop/src/store/epics/auth.ts",["446"],"/Users/Pong/Desktop/google-tasks-desktop/src/components/AppRegion/WindowsTitleBar.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTask/TodoTaskMenu.tsx",["447"],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/nprogress.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/CompletedTaskList/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TodoTaskList/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/NewTask/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListHeader/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTaskDetails/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DateTimeDialog/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/Auth/FileUpload.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/service/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/DeleteIcon.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DateTimeDialog/DateTimeDialog.tsx",["448"],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTaskDetails/TodoTaskDetails.tsx",["449"],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Tooltip.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListHeader/TaskListHeader.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/NewTask/NewTask.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/EditIcon.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TodoTaskList/TodoTaskList.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/CompletedTaskList/CompletedTaskList.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Input/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/bindDispatch.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/useBoolean.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/service/auth.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/service/tasksList.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/service/task.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/useCRUDReducer.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/useActions.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/crudSelector.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/crudReducer.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/crud-reducer/crudAction.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/IconButton/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dropdown/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListMenu.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTaskDetails/DateTimeButton.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/uuid.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Input/Input.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TodoTaskList/TodoTaskListByDate.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/useMuiMenu.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListDropdown/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/MenuItem.tsx",["450"],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/FormDialog.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/FullScreenDialog.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dialog/ConfirmDialog.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Dropdown/Dropdown.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/IconButton/IconButton.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Mui/Menu/Menu.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DatePicker/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/CompletedTask.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/Task.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/KeyboardShortcuts/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListDropdown/TaskListDropdown.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/DatePicker/DatePicker.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTask/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/KeyboardShortcuts/KeyboardShortcuts.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TaskInput.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/ToggleCompleted.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/Preferences.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/TaskListDropdown/TaskListDropdownItem.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/Storage.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/TitleBarSelector.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/AccentColor.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Preferences/ThemeSelector.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/pages/TaskList/Task/TodoTask/TodoTask.tsx",[],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Switch/index.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/hooks/useMouseTrap.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/validators.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/typings.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/utils/form/form.ts",[],"/Users/Pong/Desktop/google-tasks-desktop/src/components/Switch/Switch.tsx",[],{"ruleId":"451","severity":1,"message":"452","line":11,"column":1,"nodeType":"453","endLine":28,"endColumn":2},{"ruleId":"451","severity":1,"message":"454","line":83,"column":1,"nodeType":"453","endLine":83,"endColumn":52},{"ruleId":"451","severity":1,"message":"454","line":107,"column":1,"nodeType":"453","endLine":114,"endColumn":3},{"ruleId":"451","severity":1,"message":"454","line":320,"column":1,"nodeType":"453","endLine":329,"endColumn":3},{"ruleId":"451","severity":1,"message":"454","line":32,"column":1,"nodeType":"453","endLine":32,"endColumn":39},{"ruleId":"455","severity":1,"message":"456","line":33,"column":7,"nodeType":"457","messageId":"458","endLine":33,"endColumn":14},{"ruleId":"455","severity":1,"message":"456","line":33,"column":7,"nodeType":"457","messageId":"458","endLine":33,"endColumn":14},{"ruleId":"455","severity":1,"message":"456","line":46,"column":14,"nodeType":"457","messageId":"458","endLine":46,"endColumn":21},{"ruleId":"459","severity":1,"message":"460","line":45,"column":10,"nodeType":"457","endLine":45,"endColumn":21},"import/no-anonymous-default-export","Unexpected default export of anonymous function","ExportDefaultDeclaration","Assign array to a variable before exporting as module default","@typescript-eslint/no-redeclare","'Context' is already defined.","Identifier","redeclared","react-hooks/exhaustive-deps","React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead."] ================================================ FILE: .eslintignore ================================================ # Logs logs *.log # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release .eslintcache # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules # OSX .DS_Store # flow-typed flow-typed/npm/* !flow-typed/npm/module_vx.x.x.js # App packaged release build electron/*.js .idea npm-debug.log.* __snapshots__ # Package.json package.json .travis.yml ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* release electron/**/**/*.js electron/**/**/*.js.map *.tsbuildinfo ================================================ FILE: .prettierrc ================================================ { "overrides": [ { "files": [".prettierrc", ".babelrc", ".eslintrc", ".stylelintrc"], "options": { "parser": "json" } } ], "singleQuote": true, "trailingComma": "none", "arrowParens": "avoid" } ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015-present C. T. Lin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Procfile ================================================ react: npm run app:dev electron: node scripts/electron-wait-react.js && yarn electron:dev ================================================ FILE: README.md ================================================ ## Google Tasks Desktop > Unofficial google tasks desktop application. Using React and google tasks api
#### [Download](https://github.com/Pong420/google-tasks-desktop/releases) #### :warning: You will need to enable your own [Google Tasks API](https://console.developers.google.com/apis/library/tasks.googleapis.com) whether you are user or developer. #### Step to enable Google Tasks API. 1. Follow the instruction in https://support.google.com/cloud/answer/6158849 to setup your `OAuth consent screen` and `Credentials` ( In step 6, you should select `Desktop app` as the application type ) 2. After the OAuth client created, you could download the `oAuth.json` by clicking this button And the `oAuth.json` looks like this ```json { "installed": { "client_id": "...", "project_id": "...", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_secret": "...", "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"] } } ``` 3. Start and drag the `oAuth.json` into the application. 4. Enable [Google Tasks API](https://console.developers.google.com/apis/library/tasks.googleapis.com) 5. Click on the `Get Code` button and will require authentication. Just ignore the `This app isn't verified` warning and continue because you are the app owner. 6. Paste the code into the input filed and click the `Confirm` button. ### Development ``` yarn dev ``` ### Packaging To package apps for the local platform: ``` yarn package ``` First, refer to the [Multi Platform Build docs](https://www.electron.build/multi-platform-build) for dependencies. Then, ``` yarn package-all ``` ### TODO - [x] Support Window & Linux - [x] Keyboard shortcuts - [x] Dark Theme - [x] Add Note - [x] Add Date - [x] Animation - [x] Sync data periodically - [x] Move task to another list - [x] Improve / check performace - [ ] Subtask - [ ] Error handling ### Known issue - Add time / repeat is not supported as API limitation - Tasks sorting type (My order / Date) is not synced to the official platform (Web/App) - The position of the task which marks as complete to incomplete may be different after refresh ================================================ FILE: common.d.ts ================================================ type Theme = 'light' | 'dark'; type AccentColor = 'red' | 'blue' | 'amber' | 'green' | 'purple' | 'grey'; type TitleBar = 'native' | 'frameless'; interface Schema$Storage { get(): T; save(value: NonNullable): void; } interface OAuthKeys { installed: { client_id: string; project_id: string; auth_uri: string; token_uri: string; client_secret: string; redirect_uris: string[]; }; } interface SyncConfig { enabled: boolean; reconnection: boolean; inactiveHours: number; } type Schema$Preferences = { accentColor: AccentColor; maxTasks: number; sync: SyncConfig; theme: Theme; titleBar: TitleBar; }; declare interface Window { __setTheme(theme?: Theme): void; __setAccentColor(color?: AccentColor): void; __setTitleBar(titleBar?: TitleBar, shouldRelaunch?: boolean): void; platform: NodeJS.Platform; openExternal: Electron.Shell['openExternal']; getCurrentWindow(): Electron.BrowserWindow; oAuth2Storage: Schema$Storage; tokenStorage: Schema$Storage; preferencesStorage: Schema$Storage; taskListSortByDateStorage: Schema$Storage; logout: () => void; TOKEN_PATH: string; OAUTH2_KEYS_PATH: string; PREFERENCES_PATH: string; TASKLIST_SORT_BY_DATE_PATH: string; STORAGE_DIRECTORY: string; relaunch: () => void; } ================================================ FILE: config-overrides.js ================================================ const path = require('path'); /** @typedef {import('webpack').Configuration} Configuration */ /** * @param {Configuration} config * @param {*} env * @returns {Configuration} */ module.exports = function (config, env) { config.resolve.alias = { ...config.resolve.alias, fs: path.resolve(__dirname, 'mock-fs.js') }; config.module.rules = config.module.rules.map(r => { if (r.oneOf) { return { ...r, oneOf: r.oneOf.map(r => { if (Array.isArray(r.use)) { return { ...r, use: r.use.map(u => typeof u === 'object' && typeof u.options === 'object' && u.loader.includes('sass-loader') ? { ...u, options: { ...u.options, additionalData: `@import 'scss/index.scss';`, sassOptions: { includePaths: [path.resolve(__dirname, 'src')] } } } : u ) }; } return r; }) }; } return r; }); return config; }; ================================================ FILE: electron/electron.d.ts ================================================ declare module 'electron-devtools-installer'; ================================================ FILE: electron/main.ts ================================================ import path from 'path'; import url from 'url'; import { app, shell, nativeTheme, BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; import { MenuBuilder } from './menu'; import { initStorage } from './storage'; let mainWindow: BrowserWindow | null = null; const isDevelopment = process.env.NODE_ENV === 'development'; const { preferencesStorage } = initStorage(app, nativeTheme); app.allowRendererProcessReuse = true; async function createWindow() { if (isDevelopment) { const { default: installExtension, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } = await import('electron-devtools-installer'); await installExtension(REACT_DEVELOPER_TOOLS); await installExtension(REDUX_DEVTOOLS); } let options: BrowserWindowConstructorOptions = { height: 500, width: 300, show: false, webPreferences: { enableRemoteModule: true, preload: path.join(__dirname, './preload/index.js') } }; const titleBar: TitleBar = preferencesStorage.get().titleBar; if ( titleBar === 'frameless' || process.platform === 'win32' || process.platform === 'darwin' ) { options = { ...options, frame: false, titleBarStyle: titleBar === 'frameless' ? 'hidden' : 'hiddenInset' }; } mainWindow = new BrowserWindow(options); mainWindow.setMenuBarVisibility(false); const startUrl = process.env.ELECTRON_START_URL || url.format({ pathname: path.join(__dirname, '../build/index.html'), protocol: 'file:', slashes: true }); mainWindow.loadURL(startUrl); mainWindow.webContents.on('did-finish-load', () => { mainWindow && mainWindow.show(); }); mainWindow.webContents.on('new-window', (event, url) => { event.preventDefault(); shell.openExternal(url); }); mainWindow.on('closed', () => { mainWindow = null; }); const menuBuilder = new MenuBuilder(mainWindow); menuBuilder.buildMenu(); } app.on('ready', createWindow); app.on('window-all-closed', () => { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { if (mainWindow === null) { createWindow(); } }); ================================================ FILE: electron/menu.ts ================================================ // borrow from // https://github.com/electron-react-boilerplate/examples/blob/master/examples/typescript/app/main.dev.ts import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron'; const help = { label: 'Help', submenu: [ { label: 'Github', click() { shell.openExternal('https://github.com/Pong420/google-tasks-desktop'); } }, { label: 'Search Issues', click() { shell.openExternal( 'https://github.com/Pong420/google-tasks-desktop/issues' ); } } ] }; export class MenuBuilder { mainWindow: BrowserWindow; constructor(mainWindow: BrowserWindow) { this.mainWindow = mainWindow; } buildMenu() { if ( process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' ) { this.setupDevelopmentEnvironment(); } const template = process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate(); const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); return menu; } setupDevelopmentEnvironment() { this.mainWindow.webContents.toggleDevTools(); this.mainWindow.webContents.on('context-menu', (e, props) => { const { x, y } = props; Menu.buildFromTemplate([ { label: 'Inspect element', click: () => { this.mainWindow.webContents.inspectElement(x, y); } } ]).popup({ window: this.mainWindow }); }); } buildDarwinTemplate(): MenuItemConstructorOptions[] { const subMenuAbout = { label: 'Google Tasks', submenu: [ { label: 'About Google Tasks', selector: 'orderFrontStandardAboutPanel:' }, { type: 'separator' }, { label: 'Services', submenu: [] }, { type: 'separator' }, { label: 'Hide Google Tasks', accelerator: 'Command+H', selector: 'hide:' }, { label: 'Hide Others', accelerator: 'Command+Shift+H', selector: 'hideOtherApplications:' }, { label: 'Show All', selector: 'unhideAllApplications:' }, { type: 'separator' }, { label: 'Quit', accelerator: 'Command+Q', click: () => { app.quit(); } } ] }; const subMenuEdit = { label: 'Edit', submenu: [ { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' }, { type: 'separator' }, { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' }, { label: 'Select All', accelerator: 'Command+A', selector: 'selectAll:' } ] }; const subMenuWindow = { label: 'Window', submenu: [ { label: 'Minimize', accelerator: 'Command+M', selector: 'performMiniaturize:' }, { label: 'Close', accelerator: 'Command+W', selector: 'performClose:' }, { type: 'separator' }, { label: 'Bring All to Front', selector: 'arrangeInFront:' } ] }; const subMenuHelp = help; const subMenuView = { label: 'View', submenu: [ { role: 'Reload', accelerator: 'CmdOrCtrl+R' }, { label: 'Toggle Full Screen', accelerator: 'Ctrl+Command+F', click: () => { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); } }, { type: 'separator' }, { role: 'toggledevtools', accelerator: 'Option+CmdOrCtrl+i' } ] }; return [ subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp ] as MenuItemConstructorOptions[]; } buildDefaultTemplate(): MenuItemConstructorOptions[] { const templateDefault = [ { label: '&File', submenu: [ { label: '&Open', accelerator: 'Ctrl+O' }, { label: '&Close', accelerator: 'Ctrl+W', click: () => { this.mainWindow.close(); } } ] }, { label: '&View', submenu: [ { label: '&Reload', accelerator: 'Ctrl+R', click: () => { this.mainWindow.webContents.reload(); } }, { label: 'Toggle &Full Screen', accelerator: 'F11', click: () => { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); } }, { label: 'Toggle &Developer Tools', accelerator: 'Alt+Ctrl+I', click: () => { this.mainWindow.webContents.toggleDevTools(); } } ] }, help ]; return templateDefault; } } ================================================ FILE: electron/preload/index.ts ================================================ import fs from 'fs'; import { remote } from 'electron'; import { handleOSTheme } from './theme'; import { initStorage } from '../storage'; function relaunch() { remote.app.relaunch(); remote.app.exit(); } const storage = initStorage(remote.app, remote.nativeTheme); const { preferencesStorage } = storage; window.__setAccentColor = (newColor?: AccentColor) => { const preferences = preferencesStorage.get(); const accentColor = newColor || preferences.accentColor; document.documentElement.setAttribute('data-accent-color', accentColor); if (newColor) { preferencesStorage.save({ ...preferences, accentColor }); } }; window.__setTitleBar = (newTitleBar?: TitleBar, shouldRelaunch?: boolean) => { const preferences = preferencesStorage.get(); const titleBar = newTitleBar || preferences.titleBar; document.documentElement.setAttribute('data-title-bar', titleBar); if (titleBar) { preferencesStorage.save({ ...preferences, titleBar }); if (shouldRelaunch) { relaunch(); } } }; Object.assign(window, storage); window.platform = process.platform; window.getCurrentWindow = remote.getCurrentWindow; window.openExternal = remote.shell.openExternal; window.logout = () => fs.unlinkSync(storage.TOKEN_PATH); window.relaunch = relaunch; process.once('loaded', () => { handleOSTheme(); }); ================================================ FILE: electron/preload/theme.ts ================================================ import { remote } from 'electron'; export const setTheme = (newTheme?: Theme) => { const preferences = window.preferencesStorage.get(); let theme = newTheme || preferences.theme; document.documentElement.setAttribute('data-theme', theme); if (newTheme) { window.preferencesStorage.save({ ...preferences, theme }); } }; window.__setTheme = setTheme; export function handleOSTheme() { if (process.platform === 'darwin') { const { systemPreferences } = remote; systemPreferences.subscribeNotification( 'AppleInterfaceThemeChangedNotification', () => setTheme() ); } } ================================================ FILE: electron/storage.ts ================================================ import fs from 'fs'; import path from 'path'; import { App, NativeTheme } from 'electron'; function FileStorage(path: string): Schema$Storage; function FileStorage(path: string, defaultValue: T): Schema$Storage // prettier-ignore // prettier-ignore function FileStorage(path: string, defaultValue?: T): Schema$Storage { return { get() { try { const val = fs.readFileSync(path, 'utf8'); return JSON.parse(val); } catch (error) {} return defaultValue; }, save(value) { fs.writeFileSync(path, JSON.stringify(value, null, 2)); } }; } export function initStorage(app: App, nativeTheme: NativeTheme) { const STORAGE_DIRECTORY = path.join( app.getPath('userData'), 'google-tasks-desktop' ); const TOKEN_PATH = path.join(STORAGE_DIRECTORY, 'token.json'); const OAUTH2_KEYS_PATH = path.join(STORAGE_DIRECTORY, 'oauth2.json'); const PREFERENCES_PATH = path.join(STORAGE_DIRECTORY, 'preferences.json'); const TASKLIST_SORT_BY_DATE_PATH = path.join( STORAGE_DIRECTORY, 'tasklist-order.json' ); if (!fs.existsSync(STORAGE_DIRECTORY)) { fs.mkdirSync(STORAGE_DIRECTORY); } const oAuth2Storage = FileStorage(OAUTH2_KEYS_PATH); const tokenStorage = FileStorage(TOKEN_PATH); const taskListSortByDateStorage = FileStorage( TASKLIST_SORT_BY_DATE_PATH, [] ); const defaultPrefrences: Schema$Preferences = { accentColor: 'blue', maxTasks: 100, theme: nativeTheme.shouldUseDarkColors ? 'dark' : 'light', titleBar: 'native', sync: { enabled: true, reconnection: true, inactiveHours: 12 } }; const preferencesStorage = FileStorage( PREFERENCES_PATH, defaultPrefrences ); // for version <= v3.0.2 let preferences = preferencesStorage.get(); preferencesStorage.save( Object.entries(defaultPrefrences).reduce((results, [key, value]) => { const currentValue: unknown = preferences[key as keyof typeof preferences]; return { ...results, [key]: typeof currentValue === 'undefined' ? value : currentValue }; }, {} as Schema$Preferences) ); return { STORAGE_DIRECTORY, TOKEN_PATH, PREFERENCES_PATH, TASKLIST_SORT_BY_DATE_PATH, oAuth2Storage, tokenStorage, taskListSortByDateStorage, preferencesStorage }; } ================================================ FILE: electron/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "module": "commonjs", "noEmit": false, "rootDir": "./", "incremental": true }, "exclude": ["../node_modules"], "include": ["./*.ts", "./preload/*.ts", "../common.d.ts"] } ================================================ FILE: mock-fs.js ================================================ // https://github.com/googleapis/google-api-nodejs-client/issues/1775#issuecomment-520572247 module.exports = { readFile() {}, readFileSync() {} }; ================================================ FILE: package.json ================================================ { "name": "google-tasks-desktop", "version": "3.1.3", "scripts": { "dev": "nf start", "build": "yarn app:build && yarn electron:compile", "package": "rimraf release && yarn build && electron-builder build --publish never", "package-all": "rimraf release && yarn build && electron-builder build -mwl", "lint": "eslint 'electron/**/*.ts?(x)' && eslint 'src/**/*.ts?(x)'", "app:dev": "cross-env BROWSER=false react-app-rewired start", "app:build": "react-app-rewired build", "electron:compile": "tsc --project electron/tsconfig.json", "electron:dev": "cross-env NODE_ENV=development & electron electron/main.js", "component": "node scripts/component.js", "get": "node scripts/type.js", "redux": "node scripts/redux.js", "test": "react-app-rewired test", "eject": "react-scripts eject" }, "homepage": ".", "main": "./electron/main.js", "build": { "productName": "Google Tasks", "appId": "Google Tasks", "directories": { "buildResources": "public", "output": "release" }, "extraMetadata": { "main": "electron/main.js" }, "files": [ "build/index.html", "build/**/*", "electron/**/*.js", "package.json" ], "extraFiles": [ "credentials" ], "mac": { "target": [ "dmg", "pkg", "zip" ], "darkModeSupport": true, "icon": "public/icon/icon.png", "type": "distribution" }, "dmg": { "contents": [ { "x": 130, "y": 220 }, { "x": 410, "y": 220, "type": "link", "path": "/Applications" } ] }, "pkg": { "license": "LICENSE" }, "win": { "target": [ "nsis", "portable", "zip" ], "icon": "public/icon/icon.ico" }, "nsis": { "installerIcon": "public/icon/icon.ico", "license": "LICENSE", "warningsAsErrors": false }, "linux": { "target": [ "AppImage", "deb", "snap" ], "icon": "./public/icon/512x512.png", "desktop": { "Type": "Application", "Encoding": "UTF-8", "Name": "Google Tasks", "Comment": "Unofficial google tasks desktop application", "Icon": "google-tasks-desktop", "Terminal": "false", "StartupWMClass": "google-tasks-desktop" } }, "snap": { "grade": "stable" } }, "repository": { "type": "git", "url": "https://github.com/Pong420/google-tasks-desktop" }, "author": { "name": "Pong", "email": "samfunghp@gmial.com", "url": "https://pong420.netlify.app" }, "license": "MIT", "bugs": { "url": "https://github.com/Pong420/google-tasks-desktop/issues" }, "eslintConfig": { "extends": "react-app", "rules": { "react/self-closing-comp": "warn", "import/no-anonymous-default-export": 0 } }, "husky": { "hooks": { "pre-co3mit": "lint-staged" } }, "lint-staged": { "*.{ts,tsx}": [ "eslint --max-warnings=0", "prettier --ignore-path .eslintignore --write" ], "{*.json,.{babelrc,eslintrc,prettierrc}}": [ "prettier --ignore-path .eslintignore --parser json --write" ], "*.{css,scss}": [ "prettier --ignore-path .eslintignore --single-quote --write" ], "*.{yml,md}": [ "prettier --ignore-path .eslintignore --single-quote --write" ] }, "devDependencies": { "@types/history": "^4.7.8", "@types/lodash": "^4.14.168", "@types/mousetrap": "^1.6.5", "@types/node": "^14.14.37", "@types/nprogress": "^0.2.0", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", "@types/react-router-dom": "^5.1.7", "cross-env": "^7.0.3", "electron": "11.5.0", "electron-builder": "^22.10.5", "electron-devtools-installer": "^3.1.1", "foreman": "^3.0.1", "husky": "^4.2.3", "lint-staged": "^10.5.4", "react-scripts": "4.0.3", "prettier": "^2.2.1", "react-desktop": "^0.3.9", "rimraf": "^3.0.2", "sass": "^1.32.8", "typescript": "4.2.3" }, "dependencies": { "@material-ui/core": "^4.11.3", "@material-ui/icons": "^4.11.2", "connected-react-router": "^6.9.1", "customize-cra": "^1.0.0", "googleapis": "^47.0.0", "history": "^4.10.1", "lodash": "^4.17.21", "mousetrap": "^1.6.5", "nprogress": "^0.2.0", "rc-field-form": "^1.20.0", "react": "^17.0.2", "react-app-rewired": "^2.1.8", "react-dom": "^17.0.2", "react-redux": "^7.2.3", "react-router-dom": "^5.2.0", "react-sortable-hoc": "^2.0.0", "redux": "^4.0.5", "redux-observable": "^1.2.0", "reselect": "^4.0.0", "rxjs": "^6.6.6", "typeface-nunito-sans": "^1.1.13", "typeface-roboto": "^1.1.13", "use-rx-hooks": "1.6.2" }, "devEngines": { "node": ">=7.x", "npm": ">=4.x", "yarn": ">=0.21.3" }, "browserslist": [ "last 1 chrome version" ] } ================================================ FILE: public/index.html ================================================ Google Tasks
================================================ FILE: public/manifest.json ================================================ { "short_name": "Google Tasks", "name": "Create Google Tasks Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], "start_url": ".", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: scripts/component.js ================================================ const fs = require('fs'); const path = require('path'); const pkg = require('../package.json'); const useTypescript = (pkg['devDependencies'] || {}).typescript || (pkg['dependencies'] || {}).typescript; const componentName = process.argv .slice(2) .find(v => !/-/.test(v)) .replace(/^\w/, function (chr) { return chr.toUpperCase(); }); const componentOnly = process.argv.find(v => v === '-s'); const dir = path.join( __dirname, `../src/components/`, componentOnly ? '' : componentName ); const write = (path, content) => { fs.writeFileSync( path, content.replace(/^ {2}/gm, '').replace(/^ *\n/, ''), 'utf-8' ); }; if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } const index = ` import './${componentName}.scss'; export * from './${componentName}'; export { ${componentName} as default } from './${componentName}'; `; const reactComponent = ` import React from 'react'; export function ${componentName}() { return (
); } `; const scss = ''; if (!componentOnly) { write(`${dir}/index.${useTypescript ? 'ts' : 'js'}`, index); write(`${dir}/${componentName}.scss`, scss); } write( `${dir}/${componentName}.${useTypescript ? 'tsx' : 'jsx'}`, reactComponent ); ================================================ FILE: scripts/electron-wait-react.js ================================================ // @ts-check const exec = require('child_process').exec; const net = require('net'); let client = new net.Socket(); const port = process.env.PORT ? Number(process.env.PORT) - 100 : 3000; process.env.ELECTRON_START_URL = `http://localhost:${port}`; let startedElectron = false; const tryConnection = () => client.connect({ port }, () => { client.end(); if (!startedElectron) { process.off('uncaughtException', uncaughtException); client.destroy(); console.log('starting electron'); startedElectron = true; exec('npm run electron:dev'); } }); exec('npm run electron:compile', tryConnection); function uncaughtException() { client.destroy(); client = new net.Socket(); setTimeout(tryConnection, 1000); } process.on('uncaughtException', uncaughtException); ================================================ FILE: scripts/redux.js ================================================ const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const [, , ...args] = process.argv; const execPromise = command => { return new Promise((resolve, reject) => { const cmd = exec(command, (error, stdout) => { if (error) { reject(error); return; } resolve(); }); cmd.stdout.on('data', data => { console.log(data.trim()); }); }); }; const root = path.join(__dirname, '../src'); const templatePath = path.join(__dirname, 'template'); const store = path.join(root, 'store'); const subdir = ['actions', 'epics', 'reducers'].map(dir => path.join(store, dir) ); if (args[0] === 'init') { const commands = [ `yarn add redux`, `yarn add react-redux && yarn add --dev @types/react-redux`, `yarn add rxjs && yarn add redux-observable` ]; (async () => { for (const cmd of commands) { await execPromise(cmd); } })(); [store, ...subdir].forEach(dir => { const key = dir.split('/').slice(-1)[0]; fs.readFile( path.join(templatePath, 'store', `${key}.tmpl`), (error, content) => { if (!error) { !fs.existsSync(dir) && fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'index.ts'), content); } } ); }); fs.readFile(path.join(templatePath, 'useActions.tmpl'), (error, content) => { if (!error) { fs.writeFileSync(path.join(root, 'hooks', 'useActions.ts'), content); } }); } else if (args[0]) { const filename = args[0]; subdir.map(dir => fs.writeFileSync(path.join(dir, `${filename}.ts`), '')); } ================================================ FILE: scripts/template/store/actions.tmpl ================================================ ================================================ FILE: scripts/template/store/epics.tmpl ================================================ import { combineEpics } from 'redux-observable'; export default combineEpics( ); ================================================ FILE: scripts/template/store/reducers.tmpl ================================================ import { combineReducers } from 'redux'; const rootReducer = () => combineReducers({ }); export type RootState = ReturnType>; export default rootReducer; ================================================ FILE: scripts/template/store/store.tmpl ================================================ import { createStore, applyMiddleware, compose } from 'redux'; import { createEpicMiddleware, Epic } from 'redux-observable'; import { BehaviorSubject } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import rootEpic from './epics'; import createRootReducer from './reducers'; const epic$ = new BehaviorSubject(rootEpic); const hotReloadingEpic: Epic = (...args) => epic$.pipe(switchMap(epic => epic(...args))); export default function configureStore() { const epicMiddleware = createEpicMiddleware(); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const enhancer = composeEnhancers(applyMiddleware(epicMiddleware)); const store = createStore(createRootReducer(), undefined, enhancer); epicMiddleware.run(hotReloadingEpic); if (process.env.NODE_ENV !== 'production') { if (module.hot) { module.hot.accept('./reducers', () => { store.replaceReducer(createRootReducer()); }); module.hot.accept('./epics', () => { const nextRootEpic = require('./epics').default; epic$.next(nextRootEpic); }); } } return store; } export * from './actions'; export * from './reducers'; export * from './epics'; ================================================ FILE: scripts/template/useActions.tmpl ================================================ import { useMemo, useRef, Dispatch as ReactDispatch } from 'react'; import { AnyAction, Dispatch } from 'redux'; import { useDispatch } from 'react-redux'; interface ActionCreators { [k: string]: (...args: any[]) => AnyAction; } type Handler = { [X in keyof A]: (...args: Parameters) => void; }; export function withDispatch( creators: A, dispatch: Dispatch | ReactDispatch ) { const handler = {} as Handler; for (const key in creators) { const creator = creators[key]; handler[key] = (...args: Parameters) => { dispatch(creator(...args)); }; } return handler; } export function useActions(creators: A): Handler { const dispatch = useDispatch(); const creatorsRef = useRef(creators); return useMemo(() => withDispatch(creatorsRef.current, dispatch), [dispatch]); } ================================================ FILE: scripts/type.js ================================================ const { exec } = require('child_process'); const [, , ...args] = process.argv; const install = pkg => { return `yarn add ${pkg} && yarn add --dev @types/${pkg}`; }; const execPromise = command => { return new Promise((resolve, reject) => { const cmd = exec(command, (error, stdout) => { if (error) { reject(error); return; } resolve(); }); cmd.stdout.on('data', data => { console.log(data.trim()); }); }); }; const promises = args.map(pkg => execPromise(install(pkg))); Promise.all(promises).then(() => { process.exit(0); }); ================================================ FILE: src/App.tsx ================================================ import React from 'react'; import { Route, Switch, Redirect, generatePath } from 'react-router-dom'; import { AppRegion } from './components/AppRegion'; import { PrivateRoute } from './components/PrivateRoute'; import { Auth } from './pages/Auth'; import { TaskList } from './pages/TaskList'; import { PATHS } from './constants'; const App = () => { return ( <> ); }; export default App; ================================================ FILE: src/components/AppRegion/AppRegion.scss ================================================ .app-region { @include absolute(0, null, 0); @include dimen(100%, var(--header-height)); .simple-title-bar { @include sq-dimen(100%); @include app-region-drag; padding: 10px 0px 0px 10px; button { @include app-region-no-drag; z-index: $app-region-z-index; } } // drawin .app-region-drag { @include app-region-drag; @include sq-dimen(100%); } } [data-platform^='win32'][data-title-bar^='native'] { .app-region { height: auto; > div { @include relative(); z-index: $app-region-z-index; } } .windows-title { @include flex(center); svg { @include sq-dimen(14px); margin-top: 1px; margin-right: 5px; } } } body { > [role='dialog'], > [role='presentation'] { @include app-region-no-drag; z-index: $app-region-z-index !important; } } ================================================ FILE: src/components/AppRegion/AppRegion.tsx ================================================ import React, { ReactNode } from 'react'; import { useSelector } from 'react-redux'; import { IconButton } from '../Mui'; import { titleBarSelector } from '../../store'; import { WindowsTitleBar } from './WindowsTitleBar'; import Close from '@material-ui/icons/Close'; const { close } = window.getCurrentWindow(); export function AppRegion() { const titleBar = useSelector(titleBarSelector); let content: ReactNode = null; if (window.platform === 'darwin') { content =
; } else if (titleBar === 'frameless') { content = (
{/* should not pass `close` function directly into `onClick` props */} close()} />
); } else { // native if (window.platform === 'win32') { content = ; } } return
{content}
; } ================================================ FILE: src/components/AppRegion/WindowsTitleBar.tsx ================================================ import React, { useState, useEffect, Suspense } from 'react'; import { useSelector } from 'react-redux'; import { themeSelector } from '../../store'; import { ReactComponent as Logo } from '../../assets/logo.svg'; const WindowsTitleBarComp = React.lazy(() => import('react-desktop/windows').then(({ TitleBar }) => ({ default: TitleBar })) ); const { isMaximized, minimize, maximize, unmaximize, close } = window.getCurrentWindow(); function toggleMaximize() { isMaximized() ? unmaximize() : maximize(); } export function WindowsTitleBar() { const [isFullscreen, setIsFullscreen] = useState(isMaximized()); const theme = useSelector(themeSelector); useEffect(() => { function onResize() { setIsFullscreen(isMaximized()); } onResize(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return ( Google Tasks
} controls theme={theme} background="var(--main-color)" isMaximized={isFullscreen} onCloseClick={() => close()} onMinimizeClick={() => minimize()} onMaximizeClick={() => toggleMaximize()} onRestoreDownClick={() => toggleMaximize()} /> ); } ================================================ FILE: src/components/AppRegion/index.ts ================================================ import './AppRegion.scss'; export * from './AppRegion'; export { AppRegion as default } from './AppRegion'; ================================================ FILE: src/components/KeyboardShortcuts/KeyboardShortcuts.scss ================================================ .keyboard-shortcuts { color: var(--text-color); .keyboard-shortcuts-label { @include dimen(100%); } .keyboard-shortcuts-key { min-width: 100px; text-align: right; padding-left: 10px; } } ================================================ FILE: src/components/KeyboardShortcuts/KeyboardShortcuts.tsx ================================================ import React from 'react'; import { FullScreenDialog, FullScreenDialogProps } from '../Mui/Dialog'; import shortcuts from './shortcuts.json'; function normalizeKeyName(str: string) { switch (window.platform) { case 'darwin': return str.replace('Alt', '⌥'); default: return str; } } export function KeyboardShortcuts(props: FullScreenDialogProps) { return (
{Object.entries(shortcuts).map(([type, rows]) => ( {rows.map(({ label, key }, index) => (
{label}
{normalizeKeyName(key)}
))}
))}
); } ================================================ FILE: src/components/KeyboardShortcuts/index.ts ================================================ import './KeyboardShortcuts.scss'; export * from './KeyboardShortcuts'; export { KeyboardShortcuts as default } from './KeyboardShortcuts'; ================================================ FILE: src/components/KeyboardShortcuts/shortcuts.json ================================================ { "Actions": [ { "label": "Add a task", "key": "Enter" }, { "label": "Focus on previous/next task", "key": "Up/Down" }, { "label": "Move task up/down", "key": "Alt + Up/Down" }, { "label": "Enter details view", "key": "Shift + Enter" } ], "Details view": [{ "label": "Exit details view", "key": "Esc" }] } ================================================ FILE: src/components/Mui/DeleteIcon.tsx ================================================ import React from 'react'; import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; export const DeleteIcon = React.forwardRef( (props, ref) => { return ( ); } ); ================================================ FILE: src/components/Mui/Dialog/ConfirmDialog.tsx ================================================ import React, { useMemo, ReactNode } from 'react'; import Button from '@material-ui/core/Button'; import Dialog, { DialogProps } from '@material-ui/core/Dialog'; import mergeWith from 'lodash/mergeWith'; export interface ConfirmDialogProps extends DialogProps { title?: string; confirmLabel?: string; open: boolean; children?: ReactNode; onClose(): void; onConfirm: () => unknown; autoFocusConfirmButon?: boolean; } const backdropProps = { classes: { root: 'mui-menu-backdrop' } }; export function ConfirmDialog({ title, confirmLabel = 'Confirm', children, onClose, onConfirm, autoFocusConfirmButon = true, classes, ...props }: ConfirmDialogProps) { const mergedClasses = useMemo( () => mergeWith( { root: 'mui-dialog', paper: 'mui-dialog-paper' }, classes, (a, b) => a + ' ' + b ), [classes] ); return (
{title}
{children}
); } ================================================ FILE: src/components/Mui/Dialog/Dialog.scss ================================================ .mui-dialog-paper.mui-dialog-paper { @include dimen(100%); @include margin-x(25px); color: var(--text--color); background-color: var(--main-color-diff2); border-radius: 8px; max-width: 280px; padding: 0; [data-theme^='light'] & { box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2); } [data-theme^='dark'] & { box-shadow: 0px 0px 3px 1px var(--shadow-color); } .dialog-scroll-content { max-height: 100%; padding: 15px 20px; } .dialog-title { @include typeface('Nunito Sans', 700); color: var(--text-color); margin-bottom: 10px; } .dialog-actions { @include flex(center, flex-end); flex: 0 0 auto; margin-top: 10px; button { @include typeface('Nunito Sans', 700); background: none; text-transform: initial; + button { color: var(--accent-dark-color); margin-left: 10px; } } } } .form-dialog-error-message { @include typeface('Nunito Sans', 600); color: var(--error-color); font-size: 13px; margin-top: 3px; padding-left: 3px; } .fullscreen-dialog-paper.fullscreen-dialog-paper { background-color: var(--main-color); color: var(--text-color); } .fullscreen-diaglog-header { @include app-region-drag; @include dimen(100%, 70px); @include flex(center, space-between); @include fake-border($borderWidth: 1px, $color: var(--main-color-diff)); @include padding-x(15px 10px); flex: 0 0 auto; .mui-icon-button { @include app-region-no-drag; } h4 { @include margin-y(0 15px); color: var(--text-color); font-size: 18px; font-weight: 500; margin-bottom: 0; } [data-platform^='darwin'] & { height: 70px; padding-bottom: 5px; h4 { margin-bottom: 7.5px; align-self: flex-end; } } } .fullscreen-dialog-content { $padding-x: 15px; @include flex(); @include sq-dimen(100%); overflow: auto; .fullscreen-dialog-inner-content { @include dimen(100%); @include padding-x($padding-x); } .fullscreen-dialog-section { .fullscreen-dialog-section-title { @include margin-x(-$padding-x); @include padding-x($padding-x); @include padding-y(12px); background-color: var(--main-color-diff); border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); font-weight: 500; font-size: 15px; } .fullscreen-dialog-row { @include flex(center, space-between); @include padding-y(12px); min-height: 50px; + .fullscreen-dialog-row { border-top: 1px solid var(--border-color); } } } } ================================================ FILE: src/components/Mui/Dialog/FormDialog.tsx ================================================ import React, { useCallback, useState, useRef, FormEvent } from 'react'; import { ConfirmDialog, ConfirmDialogProps } from './ConfirmDialog'; import { Input } from '../Input'; interface Props extends Omit { defaultValue?: string; errorMsg?: string; onConfirm(payload: string): void; } const INPUT_NAME = 'NAME'; export function FormDialog({ defaultValue = '', errorMsg, onClose, onConfirm, ...props }: Props) { const formRef = useRef(null); const [error, setError] = useState(false); const submitCallback = useCallback( (evt?: FormEvent) => { evt && evt.preventDefault(); const formEl = formRef.current; if (formEl) { const formData = new FormData(formEl); const trimedValue = (formData.get(INPUT_NAME) as string).trim(); if (!trimedValue) { setError(true); return false; } else if (trimedValue !== defaultValue) { onConfirm(trimedValue); onClose(); } } }, [onClose, onConfirm, defaultValue] ); const onExitedCallback = useCallback(() => { setError(false); }, []); return (
{error && errorMsg}
); } ================================================ FILE: src/components/Mui/Dialog/FullScreenDialog.tsx ================================================ import React, { ReactNode } from 'react'; import Dialog, { DialogProps } from '@material-ui/core/Dialog'; import { SlideProps } from '@material-ui/core/Slide'; import { IconButton } from '../IconButton'; import Slide from '@material-ui/core/Slide'; import CloseIcon from '@material-ui/icons/Close'; export interface FullScreenDialogProps extends Omit { title?: string; headerComponents?: ReactNode; onClose(): void; } interface ContainerProps { children?: ReactNode; } export const FULLSCREEN_DIALOG_TRANSITION = 300; const Transition = React.forwardRef((props, ref) => { return ; }); const backdropProps = { open: false }; const paperClasses = { paper: 'fullscreen-dialog-paper' }; export function FullScreenDialog({ children, title, onClose, headerComponents, ...props }: FullScreenDialogProps) { return (

{title}

{headerComponents}
{children}
); } FullScreenDialog.Section = ({ children }: ContainerProps) => { return
{children}
; }; FullScreenDialog.Title = ({ children }: ContainerProps) => { return
{children}
; }; FullScreenDialog.Row = ({ children }: ContainerProps) => { return
{children}
; }; export default FullScreenDialog; ================================================ FILE: src/components/Mui/Dialog/index.ts ================================================ import './Dialog.scss'; export * from './ConfirmDialog'; export * from './FormDialog'; export * from './FullScreenDialog'; ================================================ FILE: src/components/Mui/Dropdown/Dropdown.scss ================================================ .mui-dropdown-button { &.mui-dropdown-button { @include flex(center, space-between); font-size: inherit; text-transform: initial; padding: 0 0 0 10px; > span:nth-child(1) { div { @include text-overflow-ellipsis(); } } } } ================================================ FILE: src/components/Mui/Dropdown/Dropdown.tsx ================================================ import React, { useMemo, ReactNode, MouseEvent, forwardRef } from 'react'; import { Menu, MenuProps } from '../Menu'; import Button, { ButtonProps } from '@material-ui/core/Button'; import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDownRounded'; import mergeWith from 'lodash/mergeWith'; export interface DropdownProps extends Omit { label?: string; onClick(evt: MouseEvent): void; children?: ReactNode; buttonProps?: ButtonProps; } export const Dropdown = forwardRef( ({ buttonProps, children, classes, label, onClick, ...props }, ref) => { const mergedMenuClasses = useMemo( () => mergeWith( { paper: 'dropdown-menu-paper' }, classes, (a, b) => a + ' ' + b ), [classes] ); const { classes: buttonClasses, ...otherButtonProps } = buttonProps || { classes: '' }; const mergedButtonClasses = useMemo( () => mergeWith( { root: 'mui-dropdown-button' }, buttonClasses, (a, b) => a + ' ' + b ), [buttonClasses] ); return ( <> {children} ); } ); ================================================ FILE: src/components/Mui/Dropdown/index.ts ================================================ import './Dropdown.scss'; export * from './Dropdown'; export { Dropdown as default } from './Dropdown'; ================================================ FILE: src/components/Mui/EditIcon.tsx ================================================ import React from 'react'; import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; export const EditIcon = React.forwardRef( (props, ref) => { return ( ); } ); ================================================ FILE: src/components/Mui/IconButton/IconButton.scss ================================================ .mui-icon-button.mui-icon-button { color: var(--text-secondary-color); } ================================================ FILE: src/components/Mui/IconButton/IconButton.tsx ================================================ import React, { ComponentType, ReactElement } from 'react'; import { SvgIconProps } from '@material-ui/core/SvgIcon'; import MuiIconButton, { IconButtonProps } from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; interface Props extends Partial { tooltip?: string; icon?: ComponentType>; iconProps?: SvgIconProps; children?: ReactElement<{}>; } const PopperProps = { popperOptions: { modifiers: { offset: { fn: (data: any) => { data.offsets.reference.top = data.offsets.reference.top + 75; return data; } } } } }; export function IconButton({ className = '', tooltip = '', icon: Icon, iconProps, children, ...props }: Props) { return ( {Icon ? : children ? children :
} ); } ================================================ FILE: src/components/Mui/IconButton/index.ts ================================================ import './IconButton.scss'; export * from './IconButton'; export { IconButton as default } from './IconButton'; ================================================ FILE: src/components/Mui/Input/Input.scss ================================================ .mui-input-base.mui-input-base { font-size: inherit; min-height: 40px; padding: 1px 0px 0px 10px; input, textarea { color: var(--text-color); outline: none; overflow: hidden; &:hover:not(:focus) { cursor: default; } } &.filled { @include textHighlight(); background-color: var(--task-highlight-background-color); } &.bottom-border { border-bottom: 1px solid var(--border-color); } &.error { &:after { border-color: var(--error-color) !important; } } } ================================================ FILE: src/components/Mui/Input/Input.tsx ================================================ import React, { useMemo } from 'react'; import InputBase, { InputBaseProps } from '@material-ui/core/InputBase'; export type InputProps = Omit; export function Input({ className = '', ...props }: InputProps) { const mergedClasses = useMemo( () => ({ root: `mui-input-base ${className}`.trim(), focused: 'focused', multiline: 'multiline', error: 'error' }), [className] ); return ; } ================================================ FILE: src/components/Mui/Input/index.ts ================================================ import './Input.scss'; export * from './Input'; export { Input as default } from './Input'; ================================================ FILE: src/components/Mui/Menu/Menu.scss ================================================ .mui-menu-paper { &.mui-menu-paper { border-radius: 8px; background-color: var(--paper-background-color); // prettier-ignore box-shadow: 0 0 0 1px var(--shadow-color), 0 2px 4px var(--shadow-color), 0 8px 24px var(--shadow-color); color: var(--text-color); } .menu-content { @include padding-y(8px); } } .mui-menu-item { &.mui-menu-item { @include padding-y(8px); color: var(--text-color); font-size: inherit; min-height: 0; &:hover { background-color: var(--menu-item-hover-color); } } > div { @include flex(center, space-between); flex: 1 1 auto; max-width: 100%; .text { @include text-overflow-ellipsis(); flex: 1 1 auto; padding-right: 25px; } } } ================================================ FILE: src/components/Mui/Menu/Menu.tsx ================================================ import React, { useMemo, ReactNode } from 'react'; import Popover, { PopoverProps } from '@material-ui/core/Popover'; import mergeWith from 'lodash/mergeWith'; export interface MenuProps extends PopoverProps { onClose(): void; footer?: ReactNode; } const backdropProps = { classes: { root: 'mui-menu-backdrop' }, invisible: true }; export const Menu = ({ classes, children, footer, ...props }: MenuProps) => { const mergedClasses = useMemo( () => mergeWith({ paper: 'mui-menu-paper' }, classes, (a, b) => a + ' ' + b), [classes] ); return (
{children}
{footer}
); }; ================================================ FILE: src/components/Mui/Menu/MenuItem.tsx ================================================ import React, { forwardRef, useCallback, MouseEvent } from 'react'; import MuiMenuItem from '@material-ui/core/MenuItem'; import TickIcon from '@material-ui/icons/Check'; type DefaultMenuItemProps = Parameters[0]; export interface MenuItemProps extends DefaultMenuItemProps { text?: string; } interface Props { onClose(): void; } const menuItemClasses = { root: 'mui-menu-item' }; export const MenuItem = forwardRef( ({ children, text, onClick, onClose, selected, ...props }, ref) => { const onClickCallback = useCallback( (evt: MouseEvent) => { onClose(); onClick && onClick(evt); }, [onClose, onClick] ); return (
{text}
{selected && }
{children}
); } ); export function useMuiMenuItem({ onClose }: Props) { // eslint-disable-next-line return useCallback( forwardRef((props, ref) => ( )), [onClose] ); } ================================================ FILE: src/components/Mui/Menu/index.ts ================================================ import './Menu.scss'; export * from './Menu'; export * from './MenuItem'; export * from './useMuiMenu'; export { Menu as default } from './Menu'; ================================================ FILE: src/components/Mui/Menu/useMuiMenu.ts ================================================ import { useState, useCallback, useEffect, SyntheticEvent, MouseEvent } from 'react'; import { MenuProps } from '@material-ui/core/Menu'; export type AnchorEl = HTMLElement | null; export type AnchorPosition = MenuProps['anchorPosition']; function instanceOfAnchorEl(object: any): object is AnchorEl { return object === null || object instanceof HTMLElement; } function instanceOfAnchorPosition(object: any): object is AnchorPosition { return typeof object === 'undefined' || (object.top && object.left); } export function useMuiMenu() { const [anchorEl, setAnchorEl] = useState(null); const [anchorPosition, setAnchorPosition] = useState(); const onClose = useCallback(() => { setAnchorEl(null); setAnchorPosition(undefined); }, []); useEffect(() => { setAnchorEl(anchorEl); }, [anchorEl]); useEffect(() => { setAnchorPosition(anchorPosition); }, [anchorPosition]); const setAnchorElCallback = useCallback( (props: SyntheticEvent | AnchorEl) => { if (instanceOfAnchorEl(props)) { setAnchorEl(props); } else { setAnchorEl(props.currentTarget); } }, [] ); const setAnchorPositionCallback = useCallback( (props: MouseEvent | AnchorPosition) => { if (instanceOfAnchorPosition(props)) { setAnchorPosition(props); } else { const evt = props; evt && evt.preventDefault(); setAnchorPosition({ top: evt.pageY, left: evt.pageX }); } }, [] ); return { anchorEl, anchorPosition, setAnchorEl: setAnchorElCallback, setAnchorPosition: setAnchorPositionCallback, onClose }; } ================================================ FILE: src/components/Mui/Tooltip.tsx ================================================ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core/styles'; import MuiTooltip, { TooltipProps as MuiTooltipProps } from '@material-ui/core/Tooltip'; export type TooltipProps = Omit; const fontSize = 14; const PopperProps: TooltipProps['PopperProps'] = { disablePortal: true }; const useStyles = makeStyles((theme: Theme) => ({ tooltip: { fontSize } })); const useErrorStyles = makeStyles((theme: Theme) => ({ arrow: { color: theme.palette.error.main }, tooltip: { fontSize, color: theme.palette.error.contrastText, backgroundColor: theme.palette.error.main } })); export function Tooltip(props: TooltipProps) { const classes = useStyles(); return ( ); } export function ErrorTooltip(props: TooltipProps) { const classes = useErrorStyles(); return ( ); } ================================================ FILE: src/components/Mui/index.ts ================================================ export * from './DeleteIcon'; export * from './Dialog'; export * from './Dropdown'; export * from './EditIcon'; export * from './IconButton'; export * from './Input'; export * from './Menu'; export * from './Tooltip'; ================================================ FILE: src/components/Preferences/AccentColor.tsx ================================================ import React from 'react'; import { FullScreenDialog } from '../Mui/Dialog/FullScreenDialog'; import { Control } from '../../utils/form'; const accentColors: AccentColor[] = [ 'blue', 'purple', 'red', 'amber', 'green', 'grey' ]; export function AccentColor({ onChange }: Control) { return (
Accent color
{accentColors.map((color, index) => (
onChange && onChange(color)} /> ))}
); } ================================================ FILE: src/components/Preferences/Preferences.scss ================================================ .preferences { color: var(--text-color); input[type='number']::-webkit-inner-spin-button, input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .preferences-label { @include flex(center); @include typeface('Nunito Sans', 600); flex: 0 0 auto; padding-right: 10px; svg { margin-left: 3px; } } .preferences-theme-selector { @include flex(center); } .preferences-theme { text-align: center; font-size: 12px; line-height: 1.5em; > div { @include dimen(70px, 45px); @include relative(); border-radius: 5px; border: 2px solid transparent; cursor: pointer; overflow: hidden; &:after { $size: 7px; @include absolute(6px, null, 6px); @include sq-dimen($size); content: ''; background: #ff5f57; border-radius: 50%; box-shadow: $size * 1.5 0 0 #ffbd2e, $size * 3 0 0 #28ca41; } &:before { @include absolute(0, null, 0); @include sq-dimen(100%); border-radius: 3px; content: ''; } } &.light { &:after { content: 'Light'; } > div { [data-theme^='light'] & { border-color: var(--accent-color); } &:before { background-color: #eee; } } } &.dark { margin-left: 20px; &:after { content: 'Dark'; } > div { [data-theme^='dark'] & { border-color: var(--accent-color); } &:before { background-color: #232323; } } } } .preferences-accent-color-selector { @include flex(center); > div { @include sq-dimen(20px); @include relative(); border-radius: 50%; cursor: pointer; overflow: hidden; &:nth-child(n + 2) { margin-left: 10px; } } @each $name, $colors in $accent-colors { .#{'' + $name} { background-color: map-get($colors, primary); box-shadow: 2px 2px 10px -3px map-get($colors, dark); &:after { @include absolute(); @include sq-dimen(100%); content: ''; } [data-accent-color^='#{"" + $name}'] & { &:before { @include absolute(0px, 0, 0px, 0); @include sq-dimen(8px); content: ''; border-radius: 50%; background-color: #fff; margin: auto; } } } } } .preferences-hours, .preferences-max-tasks-selector { .mui-input-base { @include padding-y(0); @include padding-x(10px); margin-right: 5px; max-width: 4em; min-height: 30px; input { text-align: center; } } } .preferences-max-tasks-selector { .mui-input-base { max-width: 6em; } } .preferences-hours { .mui-input-base { max-width: 4em; } } } ================================================ FILE: src/components/Preferences/Preferences.tsx ================================================ import React, { useState } from 'react'; import { useSelector } from 'react-redux'; import { Input, ErrorTooltip, FullScreenDialog, FullScreenDialogProps } from '../Mui'; import { Switch } from '../Switch'; import { ThemeSelector } from './ThemeSelector'; import { AccentColor } from './AccentColor'; import { TitleBarSelector } from './TitleBarSelector'; import { Storage } from './Storage'; import { preferencesSelector, usePreferenceActions } from '../../store'; import { createForm, validators } from '../../utils/form'; const { Form, FormItem, useForm } = createForm(); const normalizeNumber = (value: string) => { const num = Number(value); return value === '' || value === '-0' || isNaN(num) || /^0\d+/.test(value) || /\.(0+)?$/.test(value) ? String(value) : num; }; export function Preferences(props: FullScreenDialogProps) { const preferences = useSelector(preferencesSelector); const [form] = useForm(); const [errors, setErrors] = useState([]); const { updatePreferences } = usePreferenceActions(); return (
{ setTimeout(() => { form .validateFields() .then(() => updatePreferences(changes)) .catch(() => { setErrors( form .getFieldsError(['maxTasks', ['sync', 'inactiveHours']]) .map(payload => payload.errors[0]) ); }); }, 0); }} > {window.platform === 'darwin' ? null : ( )}
Maximum Tasks
Enable synchronization
{({ sync }) => { if (sync.enabled) { return ( <>
Sync on reconnection
Sync after inactive
Hours
); } return
; }} ); } ================================================ FILE: src/components/Preferences/Storage.tsx ================================================ import React from 'react'; import { Input, FullScreenDialog } from '../Mui'; export function Storage() { return (
Path
); } ================================================ FILE: src/components/Preferences/ThemeSelector.tsx ================================================ import React from 'react'; import { FullScreenDialog } from '../Mui/Dialog/FullScreenDialog'; import { Control } from '../../utils/form'; export function ThemeSelector({ value, onChange }: Control) { const handleChange = onChange || (() => {}); return (
Theme
handleChange('light')} />
handleChange('dark')} />
); } ================================================ FILE: src/components/Preferences/TitleBarSelector.tsx ================================================ import React, { useState } from 'react'; import { FullScreenDialog, ConfirmDialog } from '../Mui'; import { Dropdown, MenuItem, useMuiMenu } from '../Mui'; import { Control } from '../../utils/form'; const exlucded: Array = ['darwin', 'win32']; const shouldRelaunch = !exlucded.includes(window.platform); export function TitleBarSelector({ value, onChange }: Control) { const { anchorEl, setAnchorEl, onClose } = useMuiMenu(); const [change, setChange] = useState(null); const [shouldClose, setShoudClose] = useState(false); const handleChange = onChange || (() => {}); const hadnleSelect = shouldRelaunch ? setChange : handleChange; return (
Title Bar
match.toUpperCase())} > hadnleSelect('native')} onClose={onClose} /> hadnleSelect('frameless')} onClose={onClose} /> {shouldRelaunch && ( setChange(null)} onConfirm={() => { if (change && change !== value) { handleChange(change); setShoudClose(true); } }} onExited={() => shouldClose && window.relaunch()} > This will relaunch your application and the changes to take effect after relaunch )}
); } ================================================ FILE: src/components/Preferences/index.ts ================================================ import './Preferences.scss'; export * from './Preferences'; export { Preferences as default } from './Preferences'; ================================================ FILE: src/components/PrivateRoute.tsx ================================================ import React from 'react'; import { Route, Redirect, RouteProps } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { RootState } from '../store'; import { PATHS } from '../constants'; export function PrivateRoute(props: RouteProps) { const loggedIn = useSelector((state: RootState) => state.auth.loggedIn); return loggedIn ? : ; } ================================================ FILE: src/components/Switch/Switch.scss ================================================ $defaultWidth: 47.5px; @mixin shadow($x) { box-shadow: $x 0.5px 3px 0px lighten(#000, 50%); } .switch { @include dimen($defaultWidth); @include relative(); cursor: pointer; user-select: none; -webkit-tap-highlight-color: rgba(255, 255, 255, 0); .switch-bar { @include animate(background-color); @include dimen(100%, 0); @include relative(); background-color: var(--border-color); border-radius: 50px; padding-bottom: 50%; } .switch-icon { @include animate(transform); @include absolute(-0.4px); @include dimen(50%, 100%); padding: round(percentage(2.5 / strip-unit($defaultWidth))); transform: translateX(0); &:before { @include sq-dimen(100%); @include shadow(1px); content: ''; background-color: #fff; border-radius: 50%; display: block; } } &.checked { .switch-bar { background-color: var(--accent-color); } .switch-icon { transform: translateX(100%); &:before { @include shadow(-1px); } } } } ================================================ FILE: src/components/Switch/Switch.tsx ================================================ import React, { useMemo, useCallback, CSSProperties } from 'react'; interface Props { checked?: boolean; onChange?(checked: boolean): void; width?: number; } export const Switch = React.memo( ({ checked = false, width, onChange }) => { const style = useMemo(() => ({ width }), [width]); const onClick = useCallback(() => { onChange && onChange(!checked); }, [onChange, checked]); return (
); } ); ================================================ FILE: src/components/Switch/index.ts ================================================ import './Switch.scss'; export * from './Switch'; export { Switch as default } from './Switch'; ================================================ FILE: src/constants/index.ts ================================================ export { default as PATHS } from './paths.json'; ================================================ FILE: src/constants/paths.json ================================================ { "AUTH": "/auth", "TASKLIST": "/tasklist/:taskListId?" } ================================================ FILE: src/date.d.ts ================================================ type DayCn = '日' | '一' | '二' | '三' | '四' | '五' | '六'; type MonthFullName = | 'January' | 'February' | 'March' | 'April' | 'May' | 'June' | 'July' | 'August' | 'September' | 'October' | 'November' | 'December'; type MonthAbbr = | 'Jan' | 'Feb' | 'Mar' | 'Apr' | 'May' | 'Jun' | 'Jul' | 'Aug' | 'Sep' | 'Oct' | 'Nov' | 'Dec'; type DayFullName = | 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday'; type DayAbbr = 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thur' | 'Fri' | 'Sat'; type DaySuffix = 'th' | 'st' | 'nd' | 'rd'; interface Date { getDayCn(): DayCn; addDays: (n: number) => Date; addMonths: (n: number) => Date; getMonthName(): MonthFullName; getMonthAbbr(): MonthAbbr; getDayFull(): DayFullName; getDayAbbr(): DayAbbr; getDayOfYear(): number; getDaySuffix(): DaySuffix; getWeekOfYear(): number; isLeapYear(): boolean; getMonthDayCount(): number; compare( d: Date ): { sameYear: boolean; sameDate: boolean; sameMonth: boolean; lastMonth: boolean; nextMonth: boolean; }; isToday(): boolean; dayDiff(d?: Date): number; toISODateString(): string; format(dateFormat: string): string; } ================================================ FILE: src/hooks/crud-reducer/bindDispatch.ts ================================================ import { Dispatch } from 'react'; import { ActionCreators } from './crudAction'; export type Dispatched
= { [X in keyof A]: (...args: Parameters) => void; }; export function bindDispatch( creators: A, dispatch: Dispatch ) { const handler = {} as Dispatched; for (const key in creators) { const creator = creators[key]; handler[key] = (...args: Parameters) => { dispatch(creator(...args)); }; } return handler; } ================================================ FILE: src/hooks/crud-reducer/crudAction.ts ================================================ /* eslint-disable @typescript-eslint/ban-types */ // https://medium.com/dailyjs/typescript-create-a-condition-based-subset-types-9d902cea5b8c export type FilterFlags = { [Key in keyof Base]: Base[Key] extends Condition ? Key : never; }; export type AllowedNames = FilterFlags< Base, Condition >[keyof Base]; export type Key = AllowedNames; export interface AnyAction { type: string; [extraProps: string]: any; } export interface ActionCreators { [k: string]: (...args: any[]) => AnyAction; } export type GetCreatorsAction< T extends Record any> > = ReturnType; export type CRUDActionType = | 'LIST' | 'CREATE' | 'UPDATE' | 'DELETE' | 'PAGINATE' | 'PARAMS' | 'RESET'; export type CRUDActionTypes = { [K in CRUDActionType]?: Type; }; export type CustomActionTypes< M extends Partial = CRUDActionTypes > = M & Omit; export type UpdatePayload> = Partial & { [T in K]: string }; export type PaginatePayload = | I[] | { data: I[]; total: number; pageNo: number; pageSize?: number; }; export type List = { type: Type; payload: I[]; }; export type Create = { type: Type; payload: I; }; export interface Update> { type: Type; payload: UpdatePayload; } export interface Delete> { type: Type; payload: { [T in K]: string }; } export interface Paginate { type: Type; payload: PaginatePayload; } export interface Params { type: Type; payload: Record; } export interface Reset { type: Type; } export type CRUDActionCreators< I, K extends Key, M extends CRUDActionTypes = CRUDActionTypes > = { list: (payload: I[]) => List; create: (payload: I) => Create; update: ( payload: Update['payload'] ) => Update; delete: (payload: { [T in K]: string }) => Delete; paginate: (payload: PaginatePayload) => Paginate; params: (payload: Record) => Params; reset: () => Reset; }; export type CRUDActions< I, K extends Key, M extends CRUDActionTypes = CRUDActionTypes > = GetCreatorsAction>; export type ExtractAction< CustomActionTypes extends AnyAction, T2 extends CustomActionTypes['type'] > = CustomActionTypes extends { type: T2 } ? CustomActionTypes : never; export function isAction< I, K extends Key, M extends CRUDActionTypes = CRUDActionTypes, BaseType extends keyof M = keyof M >( actionTypes: M, action: CRUDActions, type: BaseType ): action is ExtractAction, M[BaseType]> { return action.type === actionTypes[type]; } export const DefaultCRUDActionTypes = { LIST: 'LIST', CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE', PAGINATE: 'PAGINATE', PARAMS: 'PARAMS', RESET: 'RESET' } as const; export function getCRUDActionsCreator>() { // prettier-ignore function create(): [CRUDActionCreators, typeof DefaultCRUDActionTypes] // prettier-ignore function create(actionTypes: M): [CRUDActionCreators>, CustomActionTypes] // prettier-ignore function create(actionTypes = DefaultCRUDActionTypes as M) { actionTypes = { ...DefaultCRUDActionTypes, ...actionTypes }; const creators: CRUDActionCreators> = { list: payload => ({ type: actionTypes['LIST'], payload }), create: payload => ({ type: actionTypes['CREATE'], payload }), update: payload => ({ type: actionTypes['UPDATE'], payload }), delete: payload => ({ type: actionTypes['DELETE'], payload }), paginate: payload => ({ type: actionTypes['PAGINATE'], payload }), params: payload => ({ type: actionTypes['PARAMS'], payload }), reset: () => ({ type: actionTypes['RESET'] }) }; return [creators, actionTypes] as const; } return create; } ================================================ FILE: src/hooks/crud-reducer/crudReducer.ts ================================================ /* eslint-disable @typescript-eslint/ban-types */ import { Key, CRUDActions, CRUDActionTypes, PaginatePayload, DefaultCRUDActionTypes, isAction, List } from './crudAction'; export interface CRUDState { ids: string[]; byIds: Record; list: Prefill extends true ? Array : I[]; pageNo: number; pageSize: number; total: number; params: any; } export type CRUDReducer< I, K extends Key, Prefill extends boolean = true, M extends CRUDActionTypes = CRUDActionTypes > = ( state: CRUDState, action: CRUDActions ) => CRUDState; export interface CreateCRUDReducerOptions< M extends CRUDActionTypes = CRUDActionTypes > { prefill?: boolean; actionTypes?: M; keyGenerator?: (index: number) => string; } export const defaultKeyGenerator = (() => { let count = 0; return function () { count++; return `mock-${count}`; }; })(); export function parsePaginatePayload(payload: PaginatePayload) { return Array.isArray(payload) ? { total: payload.length, data: payload, pageNo: 1 } : payload; } export function createCRUDReducer< I, K extends Key, M extends CRUDActionTypes = CRUDActionTypes >( key: K, options: CreateCRUDReducerOptions & { prefill: false } ): [CRUDState, CRUDReducer]; export function createCRUDReducer< I, K extends Key, M extends CRUDActionTypes = CRUDActionTypes >( key: K, options?: CreateCRUDReducerOptions ): [CRUDState, CRUDReducer]; export function createCRUDReducer< I, K extends Key, M extends CRUDActionTypes = CRUDActionTypes >( key: K, options?: CreateCRUDReducerOptions ): [CRUDState, CRUDReducer] { const defaultState: CRUDState = { byIds: {}, ids: [], list: [], pageNo: 1, pageSize: 10, total: 0, params: {} }; const { prefill = true, keyGenerator = defaultKeyGenerator, actionTypes = DefaultCRUDActionTypes as M } = options || {}; const reducer: CRUDReducer = ( state = defaultState, action ) => { if (isAction(actionTypes, action, 'PAGINATE')) { return (() => { const { data, pageNo, total, pageSize = state.pageSize } = parsePaginatePayload(action.payload); if (prefill === false) { return reducer(state, { type: actionTypes['LIST'], payload: data }); } const start = (pageNo - 1) * pageSize; const insert = (arr: T1[], ids: T2[]) => { return [ ...arr.slice(0, start), ...ids, ...arr.slice(start + pageSize) ]; }; const { list, ids, byIds } = reducer(defaultState, { type: actionTypes['LIST'], payload: data }); const length = total - state.ids.length; return { ...state, total, pageNo, pageSize, byIds: { ...state.byIds, ...byIds }, ids: insert( [ ...state.ids, ...Array.from({ length }, (_, index) => keyGenerator(index)) ], ids ), list: insert( [...state.list, ...Array.from({ length }, () => null)], list ) }; })(); } if (isAction(actionTypes, action, 'LIST')) { return (action as List<'', I>).payload.reduce( (state, payload) => reducer(state, { type: actionTypes['CREATE'], payload }), defaultState ); } if (isAction(actionTypes, action, 'CREATE')) { const id: string = action.payload[key] as any; return { ...state, byIds: { ...state.byIds, [id]: action.payload }, list: [...state.list, action.payload], ids: [...state.ids, id] }; } if (isAction(actionTypes, action, 'UPDATE')) { const id = action.payload[key] as string; const updated = { ...state.byIds[id], ...action.payload }; const index = state.ids.indexOf(id); return index === -1 ? state : { ...state, byIds: { ...state.byIds, [id]: updated }, list: [ ...state.list.slice(0, index), updated, ...state.list.slice(index + 1) ] }; } if (isAction(actionTypes, action, 'DELETE')) { const id = action.payload[key]; const index = state.ids.indexOf(id); const { [id]: _deleted, ...byIds } = state.byIds; return { ...state, byIds, ids: removeFromArray(state.ids, index), list: removeFromArray(state.list, index) }; } if (isAction(actionTypes, action, 'PARAMS')) { const { pageNo, pageSize, ...params } = action.payload; const toNum = (value: unknown, num: number) => typeof value === 'undefined' || isNaN(Number(value)) ? num : Number(value); return { ...state, pageNo: toNum(pageNo, state.pageNo), pageSize: toNum(pageSize, state.pageSize), params }; } if (isAction(actionTypes, action, 'RESET')) { return defaultState; } return state; }; return [defaultState, reducer]; } export function removeFromArray(arr: T[], index: number) { return index < 0 ? arr : [...arr.slice(0, index), ...arr.slice(index + 1)]; } ================================================ FILE: src/hooks/crud-reducer/crudSelector.ts ================================================ import { CRUDState } from './crudReducer'; export interface PaginateState> { ids: S['ids']; list: S['list']; pageNo: number; pageSize: number; total: number; params: any; hasData: boolean; } export function paginateSelector>({ list, ids, pageNo, pageSize, params, total }: S): PaginateState { const start = (pageNo - 1) * pageSize; const _list = list.slice(start, start + pageSize); const _ids = ids.slice(start, start + pageSize); let hasData = !!_list.length; for (const item of _list) { if (item === null) { hasData = false; break; } } return { list: _list, ids: _ids, pageNo, pageSize, total, params, hasData }; } ================================================ FILE: src/hooks/crud-reducer/index.ts ================================================ export * from './bindDispatch'; export * from './crudAction'; export * from './crudReducer'; export * from './crudSelector'; export * from './useActions'; export * from './useCRUDReducer'; ================================================ FILE: src/hooks/crud-reducer/useActions.ts ================================================ import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { ActionCreators } from './crudAction'; import { Dispatched, bindDispatch } from './bindDispatch'; export function useActions( creators: A ): Dispatched { const dispatch = useDispatch(); const [actions] = useState(bindDispatch(creators, dispatch)); return actions; } ================================================ FILE: src/hooks/crud-reducer/useCRUDReducer.ts ================================================ /* eslint-disable @typescript-eslint/ban-types */ import { useReducer, useState } from 'react'; import { CRUDState, createCRUDReducer, CreateCRUDReducerOptions } from './crudReducer'; import { Key, getCRUDActionsCreator, CRUDActionCreators } from './crudAction'; import { bindDispatch, Dispatched } from './bindDispatch'; export type UseCRUDReducer< I, K extends Key, Prefill extends boolean = true > = () => [CRUDState, Dispatched>]; export function createUseCRUDReducer>( key: K, options: CreateCRUDReducerOptions & { prefill: false } ): UseCRUDReducer; export function createUseCRUDReducer>( key: K, options?: CreateCRUDReducerOptions ): UseCRUDReducer; export function createUseCRUDReducer>( key: K, options?: CreateCRUDReducerOptions ): UseCRUDReducer { const [intialState, reducer] = createCRUDReducer(key, options); return function useCRUDReducer() { const [state, dispatch] = useReducer(reducer, intialState); const [actions] = useState(() => { const [actions] = getCRUDActionsCreator()(); return { dispatch, ...bindDispatch(actions, dispatch) }; }); return [state, actions]; }; } ================================================ FILE: src/hooks/useActions.ts ================================================ import { useMemo, useRef, Dispatch as ReactDispatch } from 'react'; import { AnyAction, Dispatch } from 'redux'; import { useDispatch } from 'react-redux'; interface ActionCreators { [k: string]: (...args: any[]) => AnyAction; } type Handler = { [X in keyof A]: (...args: Parameters) => void; }; export function withDispatch( creators: A, dispatch: Dispatch | ReactDispatch ) { const handler = {} as Handler; for (const key in creators) { const creator = creators[key]; handler[key] = (...args: Parameters) => { dispatch(creator(...args)); }; } return handler; } export function useActions(creators: A): Handler { const dispatch = useDispatch(); const creatorsRef = useRef(creators); return useMemo(() => withDispatch(creatorsRef.current, dispatch), [dispatch]); } ================================================ FILE: src/hooks/useBoolean.ts ================================================ import { useState, useMemo } from 'react'; export function useBoolean(initialState = false) { const [flag, setFlag] = useState(initialState); const actions = useMemo( () => [ () => setFlag(true), () => setFlag(false), () => setFlag(flag => !flag) ], [] ); return [flag, ...actions] as const; } ================================================ FILE: src/hooks/useMouseTrap.ts ================================================ import { useEffect } from 'react'; import mousetrap, { MousetrapStatic } from 'mousetrap'; type Params = Parameters; export function useMouseTrap( key: Params[0], method: Params[1], preventDefault = true ) { useEffect(() => { if (key !== '') { const instance = mousetrap(document.body); const handler = (...args: Parameters) => { method(...args); return preventDefault ? false : true; }; instance.bind(key, handler); return () => { instance.unbind(key); }; } }, [key, method, preventDefault]); } ================================================ FILE: src/index.scss ================================================ *, *:before, *:after { box-sizing: border-box; } html, body, #root { min-height: 100vh; } html { background-color: var(--main-color); &:not([data-platform^='darwin']) { @include scrollbar; } } body { @include typeface(); color: var(--text-secondary-color); font-size: 14px; margin: 0; overflow: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } img { vertical-align: middle; } a { text-decoration: none; color: inherit; } #nprogress { .bar { background: var(--accent-color); height: 3px; } .peg { box-shadow: 0 0 10px var(--accent-color), 0 0 5px var(--accent-color); } .spinner-icon { border-top-color: var(--accent-color); border-left-color: var(--accent-color); } } ================================================ FILE: src/index.tsx ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { ConnectedRouter } from 'connected-react-router/immutable'; // https://github.com/supasate/connected-react-router/issues/312 import { MuiThemeProvider } from '@material-ui/core/styles'; import { theme } from './theme'; import configureStore, { history } from './store'; import App from './App'; import * as serviceWorker from './serviceWorker'; import 'typeface-roboto'; import 'typeface-nunito-sans'; import './utils/date'; import './index.scss'; const store = configureStore(); function render() { return ReactDOM.render( , document.getElementById('root') ); } render(); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister(); if (module.hot) { module.hot.accept('./App', render); } ================================================ FILE: src/pages/Auth/Auth.scss ================================================ .auth { @include flex($flex-direction: column); @include fixed(0, null, 0); @include sq-dimen(100%); background-color: var(--main-color); color: var(--text-color); padding: $padding-x; text-align: center; z-index: $app-region-z-index - 1; .auth-header { margin-top: 65px; } .auth-content { @include dimen(100%); @include flex(stretch, center, $flex-direction: column); @include relative(); flex: 1 0 auto; } .auth-confirm-button { color: var(--accent-color); } .auth-go-back { color: #999; } form { @include dimen(100%); .mui-input-base { margin: 5px 0 15px; } } .file-upload { @include dimen(100%); @include flex($flex-direction: column); margin-top: 50px; font-weight: bold; flex: 1 0 auto; .file-upload-header { a { color: var(--accent-color); text-decoration: underline; } } .file-upload-content { @include relative(); border: 2px dashed; border-radius: 5px; color: var(--text-secondary-color); cursor: pointer; flex: 1 0 auto; margin: 10px 0; &.dragover { label { opacity: 0.5; } } input[type='file'] { @include absolute(0, null, 0); @include sq-dimen(100%); opacity: 0; outline: none; } label { @include absolute(0, null, 0); @include flex(center, center); @include sq-dimen(100%); line-height: 1.5em; padding: 15px; } } .file-upload-footer { @include flex(center); color: var(--error-color); min-height: 24px; svg { margin-right: 5px; } } } } ================================================ FILE: src/pages/Auth/Auth.tsx ================================================ import React, { useState } from 'react'; import { useRxInput, useRxAsync } from 'use-rx-hooks'; import { Button } from '@material-ui/core'; import { Input } from '../../components/Mui/Input'; import { FileUpload } from './FileUpload'; import { generateAuthUrl, getToken } from '../../service'; import { ReactComponent as LogoSvg } from '../../assets/logo.svg'; import { useAuthActions } from '../../store'; export function Auth() { const [value, inputProps] = useRxInput(); const { authenticated } = useAuthActions(); const [{ loading }, { fetch }] = useRxAsync(getToken, { defer: true, onSuccess: authenticated }); const [installed, setInstanned] = useState(window.oAuth2Storage.get()); return (

Unoffical Google Tasks Client

{installed ? (
Paste the code here:
) : ( )}
); } ================================================ FILE: src/pages/Auth/FileUpload.tsx ================================================ import React, { useState } from 'react'; import { useRxAsync } from 'use-rx-hooks'; import { useBoolean } from '../../hooks/useBoolean'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; function readFile(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = event => { if (event.target) { try { const content = JSON.parse(event.target.result as string); content.installed && resolve(content); } catch (error) {} } reject('Invalid JSON file'); }; reader.readAsText(file); }); } function onSuccess(payload: OAuthKeys) { window.oAuth2Storage.save(payload); window.location.reload(); } export function FileUpload() { const [isDragover, dragover, dragleave] = useBoolean(); const [errorMsg, setErrorMsg] = useState(''); const [, { fetch }] = useRxAsync(readFile, { defer: true, onSuccess, onFailure: setErrorMsg }); return (
{ const { files } = evt.currentTarget; if (files && files.length && files[0].type === 'application/json') { fetch(files[0]); } else { setErrorMsg('Invalid file or format'); } }} />
{errorMsg && } {errorMsg}
); } ================================================ FILE: src/pages/Auth/index.ts ================================================ import './Auth.scss'; export * from './Auth'; export { Auth as default } from './Auth'; ================================================ FILE: src/pages/TaskList/CompletedTaskList/CompletedTaskList.scss ================================================ $completed-task-list-header-height: 54px; .completed-tasks-list { @include dimen(100%, $completed-task-list-header-height); @include relative(); flex: 1 0 auto; z-index: $text-highlight-z-index; .scroll-content { @include dimen(100%); overflow: auto; } } .completed-tasks-list-inner { @include absolute(null, 0, 0); @include animate(transform); @include flex($flex-direction: column); @include dimen(100%, 100vh); background-color: var(--main-color); max-height: calc(100vh - var(--header-height)); padding-bottom: 2px; transform: translateY(calc(100% - #{$completed-task-list-header-height})); z-index: 10; .completed-tasks-list-content { visibility: hidden; } &.expanded { transform: translateY(0); .completed-tasks-list-content { visibility: visible; } } } .completed-tasks-list-header { @include dimen(100%, $completed-task-list-header-height); @include flex(center, space-between); @include fake-border($borderWidth: 1px, $color: var(--main-color-diff)); @include typeface('Nunito Sans', 600); @include padding-x(($padding-x, 10px)); border-top: 1px solid var(--border-color); cursor: pointer; flex: 1 0 auto; } .completed-tasks-list-content { @include flex(); @include sq-dimen(100%); overflow: hidden; padding-bottom: 1px; // avoid scroll shown when there is only one completed task } ================================================ FILE: src/pages/TaskList/CompletedTaskList/CompletedTaskList.tsx ================================================ import React from 'react'; import { useSelector } from 'react-redux'; import { CompletedTask } from '../Task'; import { IconButton } from '../../../components/Mui'; import { useBoolean } from '../../../hooks/useBoolean'; import { completedTaskIdsSelector } from '../../../store'; import ExpandIcon from '@material-ui/icons/ExpandLess'; import CollapseIcon from '@material-ui/icons/ExpandMore'; export function CompletedTaskList() { const tasks = useSelector(completedTaskIdsSelector); const [expanded, , , toggle] = useBoolean(); if (tasks.length === 0) { return null; } return (
Completed ({tasks.length})
{tasks.map(uuid => ( ))}
); } ================================================ FILE: src/pages/TaskList/CompletedTaskList/index.ts ================================================ import './CompletedTaskList.scss'; export * from './CompletedTaskList'; export { CompletedTaskList as default } from './CompletedTaskList'; ================================================ FILE: src/pages/TaskList/NewTask/NewTask.scss ================================================ .new-task { @include dimen(100%, 56px); @include flex(center); @include padding-x(10px); .new-task-button { @include flex(center); border-radius: 50px; cursor: pointer; flex: 1 0 auto; margin-right: 10px; user-select: none; &:hover { background-color: var(--main-color-diff); } .mui-icon-button { background: none; font-weight: 500; margin-right: 5px; } svg { color: var(--accent-color); } } } .task-list-menu-paper { @include dimen(100%); .task-list-menu-title { @include padding-x(16px); color: var(--text-color); font-size: 0.875em !important; height: 30px; line-height: 30px; outline: none; } } ================================================ FILE: src/pages/TaskList/NewTask/NewTask.tsx ================================================ import React from 'react'; import { SvgIconProps } from '@material-ui/core/SvgIcon'; import { IconButton, useMuiMenu } from '../../../components/Mui'; import { TaskListMenu } from '../TaskListMenu'; import { useTaskActions } from '../../../store'; import AddIcon from '@material-ui/icons/Add'; import MoreIcon from '@material-ui/icons/MoreVert'; const iconProps: SvgIconProps = { color: 'secondary' }; export function NewTask() { const { createTask } = useTaskActions(); const { anchorEl, setAnchorEl, onClose } = useMuiMenu(); return (
createTask()}>
Add a task
); } ================================================ FILE: src/pages/TaskList/NewTask/index.ts ================================================ import './NewTask.scss'; export * from './NewTask'; export { NewTask as default } from './NewTask'; ================================================ FILE: src/pages/TaskList/Task/CompletedTask.tsx ================================================ import React from 'react'; import { useSelector } from 'react-redux'; import { Task, TaskProps } from './Task'; import { DeleteIcon, IconButton } from '../../../components/Mui'; import { useTaskActions, completedTaskSelector } from '../../../store'; interface Props extends TaskProps {} export function CompletedTask(props: Props) { const { deleteTask } = useTaskActions(); const { title } = useSelector(completedTaskSelector(props.uuid)) || {}; return ( deleteTask({ uuid: props.uuid })} /> } /> ); } ================================================ FILE: src/pages/TaskList/Task/DatePicker/DatePicker.scss ================================================ .calender-header { @include flex(center, space-between); .month-year { @include typeface('Nunito Sans', 700); color: var(--date-color); } } .calendar-content { @include padding-x(6px); display: grid; grid-template-columns: repeat(7, auto); .grid { @include relative(); text-align: center; &:before { @include dimen(100%, 0); content: ''; display: block; padding-bottom: 100%; } .grid-content { @include absolute(0, null, 0); @include flex(center, center); @include sq-dimen(100%); @include typeface('Nunito Sans', 500); font-size: 12px; } } .day { color: var(--day-color); cursor: default; } .date { color: var(--date-color); cursor: pointer; .grid-content { border-radius: 50%; } @mixin backgorundWidthShadow($color) { background-color: $color; [data-theme^='light'] & { box-shadow: 0px 2px 10px -2px #{$color}; } } &.today { .grid-content { @include backgorundWidthShadow(var(--accent-light-color)); color: #333; } } &.selected { .grid-content { @include backgorundWidthShadow(var(--accent-dark-color)); color: #fff; } } &.lastMonth, &.nextMonth { color: var(--day-color); } } } ================================================ FILE: src/pages/TaskList/Task/DatePicker/DatePicker.tsx ================================================ import React, { useState, useCallback, HTMLAttributes, useMemo } from 'react'; import { IconButton } from '../../../../components/Mui/IconButton'; import LeftArrowIcon from '@material-ui/icons/KeyboardArrowLeftRounded'; import RightArrowIcon from '@material-ui/icons/KeyboardArrowRightRounded'; const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; type GridProps = HTMLAttributes; interface DateProps extends Omit { date: Date; selected?: boolean; onClick(d: Date): void; } interface Props { value?: Date; onChange?(date: Date): void; } const Grid = React.memo(({ children, className, ...props }: GridProps) => (
{children}
)); const DateGrid = React.memo( ({ date, onClick, selected, className, ...props }: DateProps) => { const onClickCallback = useCallback(() => { onClick(date); }, [date, onClick]); return ( ); } ); export function DatePicker({ value, onChange }: Props) { const [{ dates, date }, setDisplay] = useState(getDisplayData(value)); const [currDate, setCurrDate] = useState(date); const [prevMonth, nextMonth] = useMemo(() => { const handler = (step: number) => () => setDisplay(({ date }) => getDisplayData(date.addMonths(step))); return [handler(-1), handler(1)]; }, []); return (
{date.getMonthName()} {date.getFullYear()}
{days.map((day, index) => ( {day} ))} {dates.map((d, index) => ( [...c, v && n], [] as Array ), d.isToday() && 'today' ] .filter(Boolean) .join(' ') .trim()} onClick={(d: Date) => { setCurrDate(d); onChange && onChange(d); }} > {d.getDate()} ))}
); } function getDisplayData(dateObj: Date = new Date()) { const currDate = dateObj.getDate(); // current date const one = dateObj.addDays(-1 * currDate + 1); // first day of current month const index = one.getDay() - 1; const MonthDayCount = one.getMonthDayCount(); const curMonth = []; const lastMonth = []; const nextMonth = []; let dates = []; for (let a = 0; a < MonthDayCount; a++) { curMonth.push(one.addDays(a)); } for (let b = 0; b < index; b++) { lastMonth.push(one.addDays((b + 1) * -1)); } dates = lastMonth.reverse().concat(curMonth); const length = dates.length; for (let c = length; c < 42; c++) { nextMonth.push(curMonth[curMonth.length - 1].addDays(c - length + 1)); } dates = dates.concat(nextMonth); const cIndex = lastMonth.length + currDate - 1; const wIndex = cIndex - dateObj.getDay() + 1; // index of this week first day in days return { dates, week: dates.slice(wIndex, wIndex + 7), date: dateObj }; } ================================================ FILE: src/pages/TaskList/Task/DatePicker/index.ts ================================================ import './DatePicker.scss'; export * from './DatePicker'; export { DatePicker as default } from './DatePicker'; ================================================ FILE: src/pages/TaskList/Task/DateTimeDialog/DateTimeDialog.scss ================================================ .date-time-dialog-paper.date-time-dialog-paper { @include dimen(100%); color: var(--text-secondary-color); max-height: 420px; max-width: 300px; button { color: var(--text-secondary-color); text-transform: inherit; } .dialog-scroll-content { padding: 5px 15px 20px 15px; } .dialog-actions { margin-top: 20px; } } ================================================ FILE: src/pages/TaskList/Task/DateTimeDialog/DateTimeDialog.tsx ================================================ import React, { useState, useEffect, createContext, ReactNode, useContext } from 'react'; import { ConfirmDialog, ConfirmDialogProps } from '../../../../components/Mui/Dialog'; import { DatePicker } from '../DatePicker'; import { useBoolean } from '../../../../hooks/useBoolean'; interface Props { date?: Date; onConfirm(date: Date): void; } type Control = Omit< ConfirmDialogProps, 'onChange' | 'onConfirm' | 'confirmLabel' >; interface DateTimeDialogContext { openDateTimeDialog: (props: Props) => void; } const dialogClasses: ConfirmDialogProps['classes'] = { paper: 'date-time-dialog-paper' }; const Context = createContext({} as DateTimeDialogContext); export function useDateTimeDialog() { return useContext(Context); } export function DateTimeDialogProvider({ children }: { children: ReactNode }) { const [props, setProps] = useState>(); const [isOpen, open, close] = useBoolean(); useEffect(() => { props && open(); }, [props, open]); return ( {children} {props && ( { props.onExited && props.onExited(...args); setProps(undefined); }} /> )} ); } export const DateTimeDialog = ({ date: defaultDate, onConfirm, ...props }: Props & Omit) => { const [date, setDate] = useState(defaultDate || new Date()); return ( { onConfirm(date); }} {...props} > ); }; ================================================ FILE: src/pages/TaskList/Task/DateTimeDialog/index.ts ================================================ import './DateTimeDialog.scss'; export * from './DateTimeDialog'; export { DateTimeDialog as default } from './DateTimeDialog'; ================================================ FILE: src/pages/TaskList/Task/Task.scss ================================================ $border: 1px solid var(--border-color); $min-task-height: 50px; @mixin withTopBottomBorder($color: var(--border-color)) { &:before { @include absolute(0, null, 0); @include dimen(100%, calc(100% + 1px)); border-top: 1px solid $color; border-bottom: 1px solid $color; content: ''; } } .task { @include dimen(100%); @include flex(center); @include padding-x(10px); @include relative(); @include withTopBottomBorder(transparent); background-color: var(--main-color); .task-input-base { @include relative(); @include withTopBottomBorder(); min-height: $min-task-height; min-width: 0; .task-input-base-end-adornment { visibility: hidden; } } &:hover, &.focused, &.dragging { background-color: var(--task-highlight-background-color); .task-input-base::before { border-color: transparent; } } &:hover, &.focused { &:before { border-color: var(--border-color); } &, & + .task:not(:last-child) { .task-input-base::before { border-color: transparent; } } // input endAdornment .task-input-base .mui-icon-button { visibility: visible; } } &:hover { z-index: 1; } &:first-child { .task-input-base:before { border-top-color: transparent; } } .toggle-completed { @include relative(); .mui-icon-button { + .mui-icon-button { @include absolute(); visibility: hidden; } } .mui-tick-icon { color: var(--accent-color); } } .toggle-completed, .task-input-base-end-adornment { @include flex(center); align-self: stretch; max-height: 68px; margin-top: 1px; } .task-input-content { @include dimen(100%); @include flex(flex-start, center, column); @include padding-y(12px); // padding-y for multiple line @include relative(); align-self: center; overflow: hidden; .mui-input-base { $line-height: 20px; @include sq-dimen(auto); font-size: 14px; line-height: $line-height; min-height: 0; padding: 0; width: 100%; textarea { min-height: $line-height; } } } .task-notes { @include dimen(100%); @include multi-line-ellipsis($line-height: 16px); @include margin-y(2px); color: var(--text-secondary-color); font-size: 12px; overflow-wrap: break-word; } .task-due-date-button { @include flex(center, center); align-self: flex-start; background-color: var(--task-highlight-background-color); border: 1px solid var(--main-color-diff); border-radius: 3px; color: var(--text-secondary-color); cursor: pointer; font-size: 12px; line-height: 16px; margin-top: 8px; padding: 6px 7px 5px; svg { color: var(--accent-color); font-size: 16px; margin-right: 6px; } &:after { content: attr(data-date); } &[data-date^='Today'] { &:after { color: var(--accent-color); font-weight: 500; } } &[data-date^='Yesterday'], &[data-date*='ago'] { svg { color: var(--error-color); } } &:hover { background-color: var(--main-color); box-shadow: 0px 5px 10px var(--shadow-color); } } } .completed-task { textarea { text-decoration: line-through; } } ================================================ FILE: src/pages/TaskList/Task/Task.tsx ================================================ import React, { ReactNode } from 'react'; import { useSelector } from 'react-redux'; import { Input, InputProps } from '../../../components/Mui'; import { taskSelector } from '../../../store'; import { ToggleCompleted } from './ToggleCompleted'; import { TaskInput, TaskInputProps } from './TaskInput'; export interface TaskProps extends InputProps, Pick { className?: string; uuid: string; isEmpty?: boolean; endAdornment?: ReactNode; } export const Task = React.forwardRef( ( { className, uuid, isEmpty, endAdornment, onDueDateBtnClick, ...inputProps }, ref ) => { const { due, notes } = useSelector(taskSelector(uuid)) || {}; return (
{endAdornment}
} />
); } ); ================================================ FILE: src/pages/TaskList/Task/TaskInput.tsx ================================================ import React from 'react'; import { Input, InputProps } from '../../../components/Mui'; import { Schema$Task } from '../../../typings'; import EventAvailableIcon from '@material-ui/icons/EventAvailable'; export interface TaskInputProps extends Pick { onDueDateBtnClick?(): void; } type Props = TaskInputProps & InputProps; export function TaskInput({ due, notes, onDueDateBtnClick, ...inputProps }: Props) { return (
{notes &&
{notes}
} {due && (
)}
); } function dateFormat(d: Date) { const now = new Date(); const dayDiff = Math.floor((+now - +d) / 1000 / 60 / 60 / 24); if (dayDiff === 0) { return 'Today'; } if (dayDiff === -1) { return 'Tomorrow'; } if (dayDiff < -1) { return d.format('D, j M'); } if (dayDiff === 1) { return 'Yesterday'; } if (dayDiff < 7) { return `${dayDiff} days ago`; } return `${Math.floor(dayDiff / 7)} weeks ago`; } ================================================ FILE: src/pages/TaskList/Task/TodoTask/TodoTask.scss ================================================ .todo-task { @include textHighlight(); user-select: none; &.dragging { @include padding-x((0px, 20px)); border-radius: 12.5px; border: 1px solid var(--border-color); box-shadow: 0px 7px 10px -5px var(--shadow-color); left: auto !important; right: 5px !important; width: calc(100% - 60px) !important; z-index: 1000; .task-input-base-end-adornment { display: none; } .toggle-completed { margin-left: 0; } // override textHighlight &:before, &:after { visibility: hidden; } } &.highlight-bottom-border { &:after { @include dimen(100%); border-color: var(--accent-color); transition: 0s; } .task-input-base { border-color: transparent; } } &:last-child { border-bottom: 1px solid var(--border-color); } .toggle-completed { &:hover { > button:nth-child(1) { visibility: hidden; } > button:nth-child(2) { visibility: visible; } } } } ================================================ FILE: src/pages/TaskList/Task/TodoTask/TodoTask.tsx ================================================ import React, { useRef, useMemo, useEffect, MouseEvent, KeyboardEvent } from 'react'; import { useSelector } from 'react-redux'; import { Task, TaskProps } from '../Task'; import { useTodoTaskDetails, EditTaskButton } from '../TodoTaskDetails'; import { useDateTimeDialog } from '../DateTimeDialog'; import { useTodoTaskMenu } from './TodoTaskMenu'; import { focusedSelector, useTaskActions, todoTaskSelector, getDateLabel } from '../../../../store'; import { useMouseTrap } from '../../../../hooks/useMouseTrap'; import { Schema$Task } from '../../../../typings'; export interface TodoTaskProps extends TaskProps { index: number; inherit?: (keyof Schema$Task)[]; prevDue?: string | null; sortByDate?: boolean; prevIndex?: number; nextIndex?: number; } export const TodoTask = React.memo( ({ uuid, index, className, inherit, prevDue, sortByDate, prevIndex = index - 1, nextIndex = index + 1, ...props }: TodoTaskProps) => { const ref = useRef(null); const { createTask, deleteTask, update: updateTask, moveTask, setFocus } = useTaskActions(); const focused = useSelector(focusedSelector(uuid)); const { title, due } = useSelector(todoTaskSelector(uuid)) || {}; const { onDelete, moveTaskUp, moveTaskDown, ...handler } = useMemo(() => { return { moveTaskUp: () => moveTask({ uuid, from: index, to: index - 1 }), moveTaskDown: () => moveTask({ uuid, from: index, to: index + 1 }), onDelete: () => deleteTask({ uuid }), // prevent focused task trigger `onBlur` event onMouseDown: (event: MouseEvent) => { !(event.target instanceof HTMLTextAreaElement) && event.preventDefault(); }, onClick: (event: MouseEvent) => { event.currentTarget .querySelector('textarea')! .focus(); }, onBlur: () => { // reduce unnecessary `FOCUS_TASK` action setTimeout(() => { const el = document.activeElement?.parentElement?.parentElement ?.parentElement?.parentElement; (!el || !el.classList.contains('task')) && setFocus(null); }, 0); }, onKeyDown: (event: KeyboardEvent) => { const input = event.currentTarget; if (event.key === 'Enter') { event.preventDefault(); createTask({ prevTask: uuid, inherit: inherit && { uuid, keys: inherit } }); } if (event.key === 'Backspace' && !input.value.trim()) { event.preventDefault(); deleteTask({ uuid, prevTaskIndex: index - 1 }); } if (event.key === 'Escape') { input.blur(); } if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { const { selectionStart, selectionEnd, value } = input; const notHightlighted = selectionStart === selectionEnd; const shouldFocusPrev = event.key === 'ArrowUp' && selectionStart === 0; const shouldFocusNext = event.key === 'ArrowDown' && selectionStart === value.length; const to = event.key === 'ArrowUp' ? prevIndex : nextIndex; if (notHightlighted && (shouldFocusPrev || shouldFocusNext)) { event.preventDefault(); setFocus(to); } } } }; }, [ uuid, index, createTask, deleteTask, moveTask, setFocus, inherit, prevIndex, nextIndex ]); const { updateDue, moveDownByDate, moveUpByDate } = useMemo(() => { // const now = new Date(); const date = due ? new Date(due) : null; const updateDue = (date?: Date) => { date && updateTask({ uuid, due: date.toISODateString() }); }; const moveDownByDate = () => { const now = new Date(); const label = getDateLabel(due, now); let newDate: Date | null = null; if (label === 'Past') newDate = now; else if (label !== 'No date') newDate = date!.addDays(1); newDate && updateDue(newDate); }; const moveUpByDate = () => { const now = new Date(); const label = getDateLabel(due, now); let newDate: Date | null = null; if (label === 'No date') newDate = prevDue ? new Date(prevDue) : now; else if (label !== 'Past' && label !== 'Today') newDate = date!.addDays(-1); newDate && updateDue(newDate); }; return { updateDue, moveUpByDate, moveDownByDate }; }, [uuid, due, prevDue, updateTask]); const { openDateTimeDialog: _openDateTimeDialog } = useDateTimeDialog(); const openDateTimeDialog = () => _openDateTimeDialog({ date: due ? new Date(due) : undefined, onConfirm: updateDue }); const { openTodoTaskDetails: _openTodoTaskDetails } = useTodoTaskDetails(); const openTodoTaskDetails = () => _openTodoTaskDetails({ openDateTimeDialog, uuid }); const { openTodoTaskMenu: _openTodoTaskMenu } = useTodoTaskMenu(); const openTodoTaskMenu = (event: MouseEvent) => _openTodoTaskMenu({ event, uuid, onDelete, openDateTimeDialog, moveToAnotherList: () => _openTodoTaskDetails({ taskListDropdownOpened: true, openDateTimeDialog, uuid }) }); useEffect(() => { const el = ref.current; const input = el && el.querySelector('textarea'); if (input && focused) { const { length } = input.value; input.focus(); // make sure cursor place at end of textarea input.setSelectionRange(length, length); } }, [focused]); useMouseTrap(focused ? 'shift+enter' : '', openTodoTaskDetails); useMouseTrap( focused ? 'option+up' : '', sortByDate ? moveUpByDate : moveTaskUp ); useMouseTrap( focused ? 'option+down' : '', sortByDate ? moveDownByDate : moveTaskDown ); return ( <> !focused && setFocus(uuid)} onChange={event => updateTask({ uuid, title: event.currentTarget.value }) } className={['todo-task', focused ? 'focused' : '', className] .join(' ') .trim()} endAdornment={} /> ); } ); ================================================ FILE: src/pages/TaskList/Task/TodoTask/TodoTaskMenu.tsx ================================================ import React, { createContext, useContext, useState, MouseEvent, ReactNode } from 'react'; import { useSelector } from 'react-redux'; import { useMuiMenuItem, Menu, MenuProps, useMuiMenu } from '../../../../components/Mui'; import { todoTaskSelector } from '../../../../store'; interface Props { uuid: string; onDelete?: () => void; openDateTimeDialog?: () => void; moveToAnotherList?: () => void; firstTask?: boolean; } interface TodoTaskMenuContext { openTodoTaskMenu: (props: Props & { event: MouseEvent }) => void; } type Control = Omit; const classes: MenuProps['classes'] = { paper: 'todo-task-menu-paper' }; const Context = createContext({} as TodoTaskMenuContext); export function useTodoTaskMenu() { return useContext(Context); } export function TodoTaskMenuProvider({ children }: { children: ReactNode }) { const [props, setProps] = useState>(); const { anchorPosition, setAnchorPosition, onClose } = useMuiMenu(); return ( { setProps(props); setAnchorPosition(event); } }} > {children} {props && ( { onClose(); setProps(undefined); }} /> )} ); } export const TodoTaskMenu = ({ uuid, onClose, onDelete, openDateTimeDialog, moveToAnotherList, firstTask, ...props }: Props & Control) => { const MenuItem = useMuiMenuItem({ onClose }); const { due } = useSelector(todoTaskSelector(uuid)) || {}; return ( {!firstTask && } ); }; ================================================ FILE: src/pages/TaskList/Task/TodoTask/index.ts ================================================ import './TodoTask.scss'; export * from './TodoTask'; export { TodoTask as default } from './TodoTask'; ================================================ FILE: src/pages/TaskList/Task/TodoTaskDetails/DateTimeButton.tsx ================================================ import React from 'react'; import { IconButton } from '../../../../components/Mui'; import Button from '@material-ui/core/Button'; import CloseIcon from '@material-ui/icons/Close'; interface Props { date?: Date; onClick?: () => void; onRemove?: () => void; } export const DateTimeButton = ({ date, onClick, onRemove }: Props) => { if (!date) { return ; } return (
{date.format('D, j M')}
); }; ================================================ FILE: src/pages/TaskList/Task/TodoTaskDetails/TodoTaskDetails.scss ================================================ $height: 36px; .todo-task-details { .row, .mui-input-base { + .row, + .mui-input-base { margin-top: 8px; } } .mui-input-base { @include padding-y(0.5em); line-height: 1.42em; &.todo-task-details-title-field { @include typeface('Nunito Sans', 600); font-size: 16px; } &.todo-task-details-notes-field { &, textarea::placeholder { font-size: 14px; } } } .row { @include flex(center, stretch); color: var(--text-secondary-color); min-height: 46px; .mui-dropdown-button { @include typeface('Nunito Sans', 600); background: var(--task-highlight-background-color); color: var(--text-color); min-height: $height; line-height: $height; } > svg { margin-right: 10px; } &.row-date, &.row-subtask { button { @include typeface('Nunito Sans', 700); text-transform: initial; color: inherit; } } } .task-deatails-due-date-button { @include dimen(100%); @include flex(center, space-between); @include padding-x(15px 0); @include relative(); align-self: stretch; border: 1px solid var(--border-color); border-radius: 3px; user-select: none; .date { color: var(--accent-dark-color); font-weight: 500; } button { background: none; } .task-deatails-due-date-clickable { @include absolute(0, null, 0); @include sq-dimen(100%); cursor: pointer; } } } .details-task-list-dropdown-paper { @include dimen(calc(100% - 60px)); .menu-content { @include padding-y(0); } .scroll-content { max-height: $height * 5; } .mui-menu-item { &.mui-menu-item { height: $height; > div { @include relative(); flex-direction: row-reverse; .text { @include padding-x(36px 0); + svg { @include absolute(null, null, 0); } } } } } } ================================================ FILE: src/pages/TaskList/Task/TodoTaskDetails/TodoTaskDetails.tsx ================================================ import React, { KeyboardEvent, useState, createContext, ReactNode, useEffect, useContext } from 'react'; import { useSelector } from 'react-redux'; import { DeleteIcon, EditIcon, FullScreenDialog, FullScreenDialogProps, IconButton, Input } from '../../../../components/Mui'; import { TaskListDropdown } from '../../TaskListDropdown'; import { DateTimeButton } from './DateTimeButton'; import { Schema$Task, Schema$TaskList } from '../../../../typings'; import { useBoolean } from '../../../../hooks/useBoolean'; import { todoTaskSelector, useTaskActions, currentTaskListsSelector } from '../../../../store'; import FormatListBulletedIcon from '@material-ui/icons/FormatListBulleted'; import EventAvailableIcon from '@material-ui/icons/EventAvailable'; interface Props extends Pick { taskListDropdownOpened?: boolean; openDateTimeDialog: () => void; } interface TodoTaskDetailsContext { openTodoTaskDetails: (props: Props) => void; } const preventStartNewLine = (evt: KeyboardEvent) => evt.which === 13 && evt.preventDefault(); const dropdownButtonProps = { fullWidth: true }; export const Context = createContext({} as TodoTaskDetailsContext); export function useTodoTaskDetails() { return useContext(Context); } export function TodoTaskDetailsProvider({ children }: { children: ReactNode }) { const [props, setProps] = useState>(); const [isOpen, open, close] = useBoolean(); useEffect(() => { props && open(); }, [props, open]); return ( {children} {props && ( { props.onExited && props.onExited(...args); setProps(undefined); }} /> )} ); } export const EditTaskButton = ({ onClick }: { onClick(): void }) => { return ( ); }; export function TodoTaskDetails({ uuid, open, onClose, openDateTimeDialog, taskListDropdownOpened, ...props }: Props & FullScreenDialogProps) { const [shouldBeDeleted, deleteOnExited] = useBoolean(); const [moveTo, setMoveTo] = useState(); const { update: updateTask, deleteTask, moveToAnotherList } = useTaskActions(); const { title, notes, due } = useSelector(todoTaskSelector(uuid)) || {}; const currentTaskList = useSelector(currentTaskListsSelector); return ( } onExited={() => { if (shouldBeDeleted) { deleteTask({ uuid }); } else if (moveTo && moveTo.id !== currentTaskList.id) { moveToAnotherList({ tasklistId: moveTo.id, uuid }); } }} > updateTask({ uuid, title: event.currentTarget.value }) } /> updateTask({ uuid, notes: event.currentTarget.value }) } className="filled todo-task-details-notes-field" placeholder="Add details" />
updateTask({ uuid, due: null })} />
); } ================================================ FILE: src/pages/TaskList/Task/TodoTaskDetails/index.ts ================================================ import './TodoTaskDetails.scss'; export * from './TodoTaskDetails'; export { TodoTaskDetails as default } from './TodoTaskDetails'; ================================================ FILE: src/pages/TaskList/Task/ToggleCompleted.tsx ================================================ import React from 'react'; import { useSelector } from 'react-redux'; import { IconButton } from '../../../components/Mui'; import { useTaskActions, taskSelector } from '../../../store'; import { Schema$Task } from '../../../typings'; import CircleIcon from '@material-ui/icons/RadioButtonUnchecked'; import TickIcon from '@material-ui/icons/Check'; interface Props extends Pick { isEmpty: boolean; } const MarkCompleteButton = React.memo(() => ( )); const MarkInCompleteButton = React.memo(() => ( )); export function ToggleCompleted({ uuid, isEmpty }: Props) { const { update: updateTask, deleteTask } = useTaskActions(); const { status, hidden } = useSelector(taskSelector(uuid)) || {}; const isCompleted = hidden || status === 'completed'; return (
isEmpty ? deleteTask({ uuid }) : updateTask({ uuid, hidden: !isCompleted, status: isCompleted ? 'needsAction' : 'completed' }) } > {!isCompleted && }
); } ================================================ FILE: src/pages/TaskList/Task/index.ts ================================================ import './Task.scss'; export * from './Task'; export * from './TodoTask'; export * from './CompletedTask'; export { Task as default } from './Task'; ================================================ FILE: src/pages/TaskList/TaskList.scss ================================================ .task-list { @include dimen(100%, 100vh); @include flex($flex-direction: column); &.disabled { opacity: 0.3; &:after { @include fixed(0, null, 0); @include sq-dimen(100%); content: ''; user-select: none; z-index: 100000; } } .task-list-header, .new-task { flex: 0 0 auto; } } .task-list-content { @include sq-dimen(100%); @include flex($flex-direction: column); max-height: calc(100vh - var(--header-height)); overflow: hidden; z-index: 1; > .scroll-content { @include sq-dimen(100%); flex: 1 1 auto; overflow: auto; } } ================================================ FILE: src/pages/TaskList/TaskList.tsx ================================================ import React, { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { TaskListHeader } from './TaskListHeader'; import { TodoTaskList } from './TodoTaskList'; import { NewTask } from './NewTask'; import { CompletedTaskList } from './CompletedTaskList'; import { TodoTaskDetailsProvider } from './Task/TodoTaskDetails'; import { DateTimeDialogProvider } from './Task/DateTimeDialog'; import { TodoTaskMenuProvider } from './Task/TodoTask/TodoTaskMenu'; import { useTaskListActions, useTaskActions, RootState, currentTaskListsSelector } from '../../store'; export function TaskList() { const taskListActions = useTaskListActions(); const taskActions = useTaskActions(); const currentTasklist = useSelector(currentTaskListsSelector); const taskListId = currentTasklist && currentTasklist.id; const disabled = useSelector( (state: RootState) => state.task.loading || state.taskList.loading ); useEffect(() => { taskListActions.getTaskLists(); }, [taskListActions]); useEffect(() => { if (taskListId) { taskActions.getTasks({ tasklist: taskListId }); } }, [taskActions, taskListId]); return (
); } ================================================ FILE: src/pages/TaskList/TaskListDropdown/TaskListDropdown.scss ================================================ $button-height: 26px; $item-height: 40px; .task-list-header-dropdown-container { @include app-region-no-drag; overflow: hidden; .task-list-header-dropdown-label { color: #80868b; font-size: 10px; letter-spacing: 1.5px; margin-bottom: 2px; text-transform: uppercase; text-align: right; padding-right: 6px; } .mui-dropdown-button { @include dimen(100%, $button-height); @include typeface('Nunito Sans', 600); color: var(--text-color); font-size: 15px; &:not(:hover) { background-color: transparent; } } } .dropdown-menu-paper.task-list-header-dropdown-paper { @include dimen(auto); margin-top: $button-height; .scroll-content { max-height: $item-height * 5; min-width: 180px; overflow: auto; } .mui-menu-item { @include typeface('Nunito Sans', 500); letter-spacing: 0.2px; height: $item-height; } } ================================================ FILE: src/pages/TaskList/TaskListDropdown/TaskListDropdown.tsx ================================================ import React, { ReactNode, useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useMuiMenu, Dropdown, DropdownProps, FULLSCREEN_DIALOG_TRANSITION } from '../../../components/Mui'; import { TaskListDropdownItem } from './TaskListDropdownItem'; import { taskListIdsSelector, currentTaskListsSelector } from '../../../store'; import { Schema$TaskList } from '../../../typings'; export interface TaskListDropdownProps extends Omit, 'onSelect'> { defaultOpen?: boolean; paperClassName?: string; footer?(onClose: () => void): ReactNode; onSelect(taskList: Schema$TaskList): void; taskList?: Schema$TaskList; } export function TaskListDropdown({ children, onSelect, defaultOpen, footer, paperClassName, PaperProps, taskList: controlled, ...props }: TaskListDropdownProps) { const { anchorEl, setAnchorEl, onClose } = useMuiMenu(); const ids = useSelector(taskListIdsSelector); const currentTaskList = useSelector(currentTaskListsSelector); const taskList = controlled || currentTaskList; const dropdownRef = useRef(null); useEffect(() => { const el = dropdownRef.current; if (defaultOpen && el) { setTimeout(() => setAnchorEl(el), FULLSCREEN_DIALOG_TRANSITION / 2); } }, [setAnchorEl, defaultOpen]); return ( { const scroller = el.querySelector('.scroll-content'); const item = el.querySelector('svg')!.parentElement! .offsetParent as HTMLElement; if (scroller && item) { scroller.scrollTop = item.offsetTop - 10 - item.offsetHeight * 2; // 10px padding; } }} footer={footer && footer(onClose)} > {ids.map(id => { return ( ); })} ); } ================================================ FILE: src/pages/TaskList/TaskListDropdown/TaskListDropdownItem.tsx ================================================ import React from 'react'; import { useSelector } from 'react-redux'; import { MenuItem, MenuItemProps } from '../../../components/Mui'; import { Schema$TaskList } from '../../../typings'; import { taskListsSelector } from '../../../store'; interface Props extends Omit { id: string; onClose(): void; onClick(taskList: Schema$TaskList): void; } export function TaskListDropdownItem({ id, onClick, onClose, ...props }: Props) { const taskList = useSelector(taskListsSelector(id))!; return ( onClick(taskList)} /> ); } ================================================ FILE: src/pages/TaskList/TaskListDropdown/index.ts ================================================ import './TaskListDropdown.scss'; export * from './TaskListDropdown'; export { TaskListDropdown as default } from './TaskListDropdown'; ================================================ FILE: src/pages/TaskList/TaskListHeader/TaskListHeader.scss ================================================ .task-list-header { @include dimen(100%, var(--header-height)); @include flex(flex-end, space-between); @include fake-border($borderWidth: 1px, $color: var(--main-color-diff)); @include padding-x($padding-x); @include relative(); padding-bottom: 8px; } ================================================ FILE: src/pages/TaskList/TaskListHeader/TaskListHeader.tsx ================================================ import React from 'react'; import { generatePath } from 'react-router-dom'; import { FormDialog, MenuItem } from '../../../components/Mui'; import { TaskListDropdown } from '../TaskListDropdown'; import { history, currentTaskListsSelector } from '../../../store'; import { PATHS } from '../../../constants'; import { useBoolean } from '../../../hooks/useBoolean'; import { Divider } from '@material-ui/core'; import { useSelector } from 'react-redux'; interface Props { onConfirm: (payload: string) => void; } export function TaskListHeader({ onConfirm }: Props) { const [dialogOpened, openDialog, closeDialog] = useBoolean(); const taskList = useSelector(currentTaskListsSelector); return ( <>
{/* TODO: */}
TASKS
history.push(generatePath(PATHS.TASKLIST, { taskListId: id })) } footer={onClose => ( <> )} />
); } ================================================ FILE: src/pages/TaskList/TaskListHeader/index.ts ================================================ import './TaskListHeader.scss'; export * from './TaskListHeader'; export { TaskListHeader as default } from './TaskListHeader'; ================================================ FILE: src/pages/TaskList/TaskListMenu.tsx ================================================ import React from 'react'; import { useSelector } from 'react-redux'; import { Divider } from '@material-ui/core'; import { Menu, MenuProps, useMuiMenuItem, FormDialog, ConfirmDialog } from '../../components/Mui'; import { Preferences } from '../../components/Preferences'; import { KeyboardShortcuts } from '../../components/KeyboardShortcuts'; import { useTaskActions, isMasterTaskListSelector, RootState, useTaskListActions, currentTaskListsSelector, isSortByDateSelector, useAuthActions } from '../../store'; import { useBoolean } from '../../hooks/useBoolean'; interface Props extends Omit {} const menuClasses = { paper: 'task-list-menu-paper' }; function selector(state: RootState) { const completedTasks = state.task.completed.ids.length; return { isMasterTaskList: isMasterTaskListSelector(state), totalTasks: completedTasks + state.task.todo.ids.length, completedTasks, currentTaskList: currentTaskListsSelector(state) }; } export function TaskListMenu({ onClose, ...props }: Props) { const MenuItem = useMuiMenuItem({ onClose }); const taskListActions = useTaskListActions(); const { deleteAllCompletedTasks } = useTaskActions(); const { isMasterTaskList, totalTasks, completedTasks, currentTaskList } = useSelector(selector); const { title: currentTaskListTitle, id: currentTaskListId } = currentTaskList || {}; const [ deleteCompletedTaskDialogOpend, openDeleteCompletedTaskDialog, closeDeleteCompletedTaskDialog ] = useBoolean(); const [ deleteTaskListDialogOpend, openDeleteTaskListDialog, closeDeleteTaskListDialog ] = useBoolean(); const [ renameTaskDialogOpend, openRenameTaskDialog, closeRenameTaskDialog ] = useBoolean(); const [ keyboardShortcutsOpened, openKeyboardShortcuts, closeKeyboardShortcuts ] = useBoolean(); const [preferencesOpened, openPreferences, closePrefences] = useBoolean(); const isSoryByDate = useSelector( isSortByDateSelector(currentTaskListId || '') ); const sortTaskListBy = (orderType: 'date' | 'order') => { currentTaskListId && taskListActions.sortTaskListBy({ id: currentTaskListId, orderType }); }; const { logout } = useAuthActions(); return ( <>
Sort by
sortTaskListBy('order')} /> sortTaskListBy('date')} />
currentTaskListId && taskListActions.update({ id: currentTaskListId, title }) } /> Deleting this list will also delete {totalTasks} task. {completedTasks} completed task will be permanently removed unless it repeats. ); } ================================================ FILE: src/pages/TaskList/TodoTaskList/TodoTaskList.scss ================================================ .todo-tasks-list-by-date { .todo-task { .task-due-date-button { display: none; } } .date-label { @include dimen(100%, 40px); @include flex(center); @include padding-x(20px); color: var(--text-color); &:after { @include typeface('Nunito Sans', 700); content: attr(data-label); } &[data-label^='Past'] { color: var(--error-color); } &[data-label^='Today'] { color: var(--accent-color); } & + .task .task-input-base:before { border-top-color: transparent; } } } ================================================ FILE: src/pages/TaskList/TodoTaskList/TodoTaskList.tsx ================================================ import React, { useState } from 'react'; import { useSelector } from 'react-redux'; import { SortableContainer, SortableElement } from 'react-sortable-hoc'; import { TodoTask, TodoTaskProps } from '../Task'; import { TodoTaskListByDate } from './TodoTaskListByDate'; import { useTaskActions, todoTaskIdsSelector, isSortByDateSelector } from '../../../store'; import { useBoolean } from '../../../hooks/useBoolean'; interface InsertAfter { insertAfter?: number; } interface SortableListProps extends InsertAfter { dragging?: boolean; todoTasks: Array; } const SortableItem = SortableElement( ({ sortIndex, ...props }: TodoTaskProps & { sortIndex: number }) => ( ) ); const SortableList = SortableContainer( ({ dragging, todoTasks, insertAfter }: SortableListProps) => { return (
{todoTasks.map((uuid, index) => ( ))}
); } ); function TodoTaskListByOrder() { const tasks = useSelector(todoTaskIdsSelector); const [dragging, dragStart, dragEnd] = useBoolean(); const [insertAfter, setInsertAfter] = useState(); const { moveTask } = useTaskActions(); return ( { let insertAfter; const move = newIndex - oldIndex > 0 ? 'down' : 'up'; if (move === 'down') { insertAfter = newIndex > index ? oldIndex + 1 : oldIndex; } else if (move === 'up') { insertAfter = newIndex > index ? newIndex : newIndex - 1; } setInsertAfter(insertAfter); }} onSortEnd={({ newIndex, oldIndex }) => { if (newIndex !== oldIndex) { moveTask({ uuid: tasks[oldIndex], from: oldIndex, to: newIndex }); } dragEnd(); }} /> ); } export function TodoTaskList({ taskListId = '' }: { taskListId?: string }) { const sortByDate = useSelector(isSortByDateSelector(taskListId)); return (
{sortByDate ? : }
); } ================================================ FILE: src/pages/TaskList/TodoTaskList/TodoTaskListByDate.tsx ================================================ import React, { Fragment } from 'react'; import { useSelector } from 'react-redux'; import { TodoTask } from '../Task'; import { todoTasksIdsByDateSelector, todoTaskIdsSelector } from '../../../store'; import { Schema$Task } from '../../../typings'; const inherit: (keyof Schema$Task)[] = ['due']; export function TodoTaskListByDate() { const { order, tasks } = useSelector(todoTasksIdsByDateSelector); const todo = useSelector(todoTaskIdsSelector); let prevDue: string | null | undefined; return (
{tasks.map(([date, ids]) => (
{ids.map(({ uuid, due }) => { const _prevDue = prevDue; const idx = order.indexOf(uuid); const [prev, index, next] = [ order[idx - 1], uuid, order[idx + 1] ].map(uuid => todo.indexOf(uuid)); prevDue = due; return ( ); })} ))}
); } ================================================ FILE: src/pages/TaskList/TodoTaskList/index.ts ================================================ import './TodoTaskList.scss'; export * from './TodoTaskList'; export { TodoTaskList as default } from './TodoTaskList'; ================================================ FILE: src/pages/TaskList/index.ts ================================================ import './TaskList.scss'; export * from './TaskList'; export { TaskList as default } from './TaskList'; ================================================ FILE: src/react-app-env.d.ts ================================================ /// declare module '*.scss'; declare module 'react-desktop/windows'; declare interface Window { __REDUX_DEVTOOLS_EXTENSION__: any; __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any; } declare namespace NodeJS { interface Module { hot?: { accept: (path?: string, callback?: () => void) => void }; } } declare const process: any; declare const require: any; ================================================ FILE: src/scss/_functions.scss ================================================ @function str-replace($string, $search, $replace: '') { $index: str-index($string, $search); @if ($index) { @return str-slice($string, 1, $index - 1) + $replace + str-replace( str-slice($string, $index + str-length($search)), $search, $replace ); } @return $string; } @function to-string($value) { @return inspect($value); } @function list-to-string($list, $separator: ',') { $string: ''; @if (type-of($list) != list) { @error 'Hey please give me a list instead!'; } @each $item in $list { @if (index($list, $item) ==1) { $string: nth($list, 1); } @else { $string: #{$string}#{$separator}#{$item}; } } @return $string; } @function strip-unit($number) { @if type-of($number) == 'number' and not unitless($number) { @return $number / ($number * 0 + 1); } @return $number; } @function getColor($color, $tone: primary) { @return map-get(map-get($accent-colors, $color), $tone); } ================================================ FILE: src/scss/_mixins.scss ================================================ @import './mixins/animation'; @import './mixins/background'; @import './mixins/border'; @import './mixins/electron'; @import './mixins/flex'; @import './mixins/font'; @import './mixins/position'; @import './mixins/size'; @import './mixins/textHighlight'; @import './mixins/textOverflow'; @mixin scrollbar { ::-webkit-scrollbar { width: 0.8rem; } ::-webkit-scrollbar-thumb { background-clip: padding-box; background-color: rgba(128, 128, 128, 0.4); border: 2px solid transparent; border-radius: 0.8rem; box-shadow: inset -1px -1px 0 rgba(0, 0, 0, 0.05), inset 1px 1px 0 rgba(0, 0, 0, 0.05); } } ================================================ FILE: src/scss/_platform.scss ================================================ :root { --header-height: 70px; } [data-title-bar^='native']:not([data-platform^='darwin']) { --header-height: 60px; } [data-platform^='win32'][data-title-bar^='native'] { --header-height: 90px; } ================================================ FILE: src/scss/_theme.scss ================================================ :root { --main-color: #fff; --main-color-diff: #f1f3f4; --main-color-diff2: #fff; --accent-color: #{getColor(blue)}; --accent-light-color: #{getColor(blue, light)}; --accent-dark-color: #{getColor(blue, dark)}; --text-color: #202124; --text-secondary-color: #5f6368; --border-color: #e0e0e0; --task-highlight-background-color: #f8f9fa; --day-color: #9e9e9e; --date-color: #000; --error-color: #da3125; --shadow-color: #e6e6e6; --paper-background-color: #fff; --menu-item-hover-color: #{darken(#fff, 6%)}; } $darkMain: #222; [data-theme^='dark'] { --main-color: #{$darkMain}; --main-color-diff: #{lighten($darkMain, 5%)}; --main-color-diff2: #{lighten($darkMain, 10%)}; --text-color: #dfdedb; --text-secondary-color: #a09c97; --border-color: #313030; --task-highlight-background-color: #333; --day-color: #9e9e9e; --date-color: #fff; --error-color: #f44336; --shadow-color: #161616; --paper-background-color: var(--main-color-diff); --menu-item-hover-color: #444; } @each $name, $colors in $accent-colors { [data-accent-color^='#{"" + $name}'] { --accent-color: #{map-get($colors, primary)}; --accent-light-color: #{map-get($colors, light)}; --accent-dark-color: #{map-get($colors, dark)}; } } ================================================ FILE: src/scss/_variables.scss ================================================ $padding-x: 20px; $mui-menu-z-index: 1300; // default $app-region-z-index: $mui-menu-z-index + 10; $base-colors: ( red: #ef5350, blue: #4285f4, amber: #fb0, green: #66bb6a, purple: #ab47bc, grey: #bdbdbd ); $accent-colors: (); @each $name, $color in $base-colors { $accent-colors: map-merge( $accent-colors, ( $name: ( primary: $color, light: lighten($color, 30%), dark: darken($color, 5%) ) ) ); } ================================================ FILE: src/scss/index.scss ================================================ @import './variables'; @import './functions'; @import './mixins'; @import './theme'; @import './platform'; ================================================ FILE: src/scss/mixins/_animation.scss ================================================ @mixin animate($property) { transition-duration: 0.3s; transition-property: $property; transition-timing-function: ease; will-change: $property; } ================================================ FILE: src/scss/mixins/_background.scss ================================================ @mixin bg-image( $image: null, $size: 100%, $position: center center, $repeat: no-repeat ) { @if ($image) { background-image: $image; } @if ($position) { background-position: $position; } @if ($repeat) { background-repeat: $repeat; } @if ($size) { background-size: $size; } } ================================================ FILE: src/scss/mixins/_border.scss ================================================ @mixin fake-border( $position: bottom, $borderWidth: 1px, $color: #000, $width: 100% ) { background: linear-gradient(to left, $color 0, $color 100%); background-size: $width $borderWidth; background-repeat: no-repeat; background-position: center $position; } ================================================ FILE: src/scss/mixins/_electron.scss ================================================ @mixin app-region-drag { -webkit-app-region: drag; } @mixin app-region-no-drag { -webkit-app-region: no-drag !important; } ================================================ FILE: src/scss/mixins/_flex.scss ================================================ @mixin flex( $align-items: stretch, $justify-content: flex-start, $flex-direction: row, $wrap: nowrap, $is-inline: false ) { align-items: $align-items; display: if($is-inline, inline-flex, flex); flex-direction: $flex-direction; flex-wrap: $wrap; justify-content: $justify-content; } ================================================ FILE: src/scss/mixins/_font.scss ================================================ @mixin typeface($font: 'Roboto', $font-weight: normal) { font-family: $font, Helvetica, Arial, 微軟正黑體, Microsoft JhengHei, sans-serif; font-weight: $font-weight; } ================================================ FILE: src/scss/mixins/_position.scss ================================================ @mixin position( $top: null, $bottom: null, $left: null, $right: null, $position: absolute ) { position: $position; @if ($top) { top: $top; } @if ($bottom) { bottom: $bottom; } @if ($left) { left: $left; } @if ($right) { right: $right; } } @mixin absolute($top: null, $bottom: null, $left: null, $right: null) { @include position($top, $bottom, $left, $right, absolute); } @mixin relative($top: null, $bottom: null, $left: null, $right: null) { @include position($top, $bottom, $left, $right, relative); } @mixin fixed($top: null, $bottom: null, $left: null, $right: null) { @include position($top, $bottom, $left, $right, fixed); } ================================================ FILE: src/scss/mixins/_size.scss ================================================ @mixin dimen($width: null, $height: null) { @if ($width) { width: $width; } @if ($height) { height: $height; } } @mixin sq-dimen($length: null) { @if ($length) { @include dimen($length, $length); } } @mixin margin-x($margin-sizes) { @if (length($margin-sizes) == 1) { margin-left: $margin-sizes; margin-right: $margin-sizes; } @else { margin-left: nth($margin-sizes, 1); margin-right: nth($margin-sizes, 2); } } @mixin margin-y($margin-sizes) { @if (length($margin-sizes) == 1) { margin-top: $margin-sizes; margin-bottom: $margin-sizes; } @else { margin-top: nth($margin-sizes, 1); margin-bottom: nth($margin-sizes, 2); } } @mixin padding-x($padding-sizes) { @if (length($padding-sizes) == 1) { padding-left: $padding-sizes; padding-right: $padding-sizes; } @else { padding-left: nth($padding-sizes, 1); padding-right: nth($padding-sizes, 2); } } @mixin padding-y($padding-sizes) { @if (length($padding-sizes) == 1) { padding-top: $padding-sizes; padding-bottom: $padding-sizes; } @else { padding-top: nth($padding-sizes, 1); padding-bottom: nth($padding-sizes, 2); } } ================================================ FILE: src/scss/mixins/_textHighlight.scss ================================================ $text-highlight-z-index: 11; @mixin textHighlight() { @include relative(); &:after { @include absolute(null, -1px, 0, 0); border-bottom: 2px solid transparent; content: ''; margin: auto; z-index: $text-highlight-z-index; } &.focused { &:after { animation: expand 0.4s ease 1; border-color: var(--accent-color); will-change: width; } } } @keyframes expand { 0% { width: 0; } 100% { width: 100%; } } ================================================ FILE: src/scss/mixins/_textOverflow.scss ================================================ @mixin text-overflow-ellipsis() { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } @mixin multi-line-ellipsis($line-height: 1.2em, $line-clamp: 2) { line-height: $line-height; max-height: calc(#{$line-height * $line-clamp} - 0.1em); display: -webkit-box; overflow: hidden; -webkit-line-clamp: $line-clamp; -webkit-box-orient: vertical; } ================================================ FILE: src/service/auth.ts ================================================ import { google } from 'googleapis'; const { oAuth2Storage, tokenStorage } = window; export let OAuth2Keys = oAuth2Storage.get(); export let oAuth2Client = OAuth2Keys ? new google.auth.OAuth2( OAuth2Keys.installed.client_id, OAuth2Keys.installed.client_secret, OAuth2Keys.installed.redirect_uris[0] ) : undefined; const SCOPES = ['https://www.googleapis.com/auth/tasks']; authenticate(); export const { tasks: tasksAPI, tasklists: taskListAPI } = google.tasks({ version: 'v1', auth: oAuth2Client }); export function generateAuthUrl() { if (oAuth2Client) { const authorizeUrl = oAuth2Client.generateAuthUrl({ access_type: 'offline', scope: SCOPES }); window.openExternal(authorizeUrl); } } export function authenticate() { const token = tokenStorage.get(); if (oAuth2Client && token) { oAuth2Client.setCredentials(token); } } export async function getToken(code: string) { if (oAuth2Client) { try { const { tokens } = await oAuth2Client.getToken(code); oAuth2Client.setCredentials(tokens); tokenStorage.save(tokens); return tokens; } catch (err) { return Promise.reject(err); } } return Promise.reject(); } ================================================ FILE: src/service/index.ts ================================================ export * from './auth'; export * from './task'; export * from './tasksList'; ================================================ FILE: src/service/task.ts ================================================ import { Observable, defer, of } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { google, tasks_v1 } from 'googleapis'; import { oAuth2Client } from './auth'; import { Schema$Task } from '../typings'; import { UUID } from '../utils/uuid'; import { PaginatePayload } from '../hooks/crud-reducer'; const { tasks } = google.tasks({ version: 'v1', auth: oAuth2Client }); export const taskUUID = new UUID(); // https://developers.google.com/tasks/performance#partial const fields: Record, unknown> = { completed: '', hidden: '', id: '', notes: '', position: '', status: '', title: '', due: '' }; export function getAllTasks( params: tasks_v1.Params$Resource$Tasks$List, prevTasks: Schema$Task[] = [] ): Observable> { const max = Number(params.maxResults || 100); const maxResults = String(Math.min(max, 100)); return defer(() => tasks.list({ ...params, showCompleted: true, showHidden: true, maxResults, fields: `nextPageToken,items(${Object.keys(fields).join(',')})` }) ).pipe( mergeMap(response => { const { nextPageToken, items = [] } = response.data; const data = prevTasks.concat( items .sort((a, b) => Number(a.position) - Number(b.position)) .map(d => ({ ...d, uuid: taskUUID.next() })) ); if (nextPageToken && data.length < max) { return getAllTasks({ ...params, pageToken: nextPageToken }, data); } return of(data); }) ); } ================================================ FILE: src/service/tasksList.ts ================================================ import { defer } from 'rxjs'; import { map } from 'rxjs/operators'; import { google, tasks_v1 } from 'googleapis'; import { oAuth2Client } from './auth'; import { Schema$TaskList } from '../typings'; export const { tasklists } = google.tasks({ version: 'v1', auth: oAuth2Client }); export function getAllTasklist( params?: tasks_v1.Params$Resource$Tasklists$List ) { return defer(() => tasklists.list(params)).pipe( map(res => { return (res.data.items || []) as Schema$TaskList[]; }) ); } ================================================ FILE: src/serviceWorker.ts ================================================ // This optional code is used to register a service worker. // register() is not called by default. // This lets the app load faster on subsequent visits in production, and gives // it offline capabilities. However, it also means that developers (and users) // will only see deployed updates on subsequent visits to a page, after all the // existing tabs open on the page have been closed, since previously cached // resources are updated in the background. // To learn more about the benefits of this model and instructions on how to // opt-in, read https://bit.ly/CRA-PWA const isLocalhost = Boolean( window.location.hostname === 'localhost' || // [::1] is the IPv6 localhost address. window.location.hostname === '[::1]' || // 127.0.0.1/8 is considered localhost for IPv4. window.location.hostname.match( /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); type Config = { onSuccess?: (registration: ServiceWorkerRegistration) => void; onUpdate?: (registration: ServiceWorkerRegistration) => void; }; export function register(config?: Config) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. const publicUrl = new URL( (process as { env: { [key: string]: string } }).env.PUBLIC_URL, window.location.href ); if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to // serve assets; see https://github.com/facebook/create-react-app/issues/2374 return; } window.addEventListener('load', () => { const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; if (isLocalhost) { // This is running on localhost. Let's check if a service worker still exists or not. checkValidServiceWorker(swUrl, config); // Add some additional logging to localhost, pointing developers to the // service worker/PWA documentation. navigator.serviceWorker.ready.then(() => { console.log( 'This web app is being served cache-first by a service ' + 'worker. To learn more, visit https://bit.ly/CRA-PWA' ); }); } else { // Is not localhost. Just register service worker registerValidSW(swUrl, config); } }); } } function registerValidSW(swUrl: string, config?: Config) { navigator.serviceWorker .register(swUrl) .then(registration => { registration.onupdatefound = () => { const installingWorker = registration.installing; if (installingWorker == null) { return; } installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { // At this point, the updated precached content has been fetched, // but the previous service worker will still serve the older // content until all client tabs are closed. console.log( 'New content is available and will be used when all ' + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' ); // Execute callback if (config && config.onUpdate) { config.onUpdate(registration); } } else { // At this point, everything has been precached. // It's the perfect time to display a // "Content is cached for offline use." message. console.log('Content is cached for offline use.'); // Execute callback if (config && config.onSuccess) { config.onSuccess(registration); } } } }; }; }) .catch(error => { console.error('Error during service worker registration:', error); }); } function checkValidServiceWorker(swUrl: string, config?: Config) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl) .then(response => { // Ensure service worker exists, and that we really are getting a JS file. const contentType = response.headers.get('content-type'); if ( response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1) ) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then(registration => { registration.unregister().then(() => { window.location.reload(); }); }); } else { // Service worker found. Proceed as normal. registerValidSW(swUrl, config); } }) .catch(() => { console.log( 'No internet connection found. App is running in offline mode.' ); }); } export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then(registration => { registration.unregister(); }); } } ================================================ FILE: src/store/actions/auth.ts ================================================ import { useActions, GetCreatorsAction } from '../../hooks/crud-reducer'; export function authenticated() { return { type: 'AUTHENTICATED' as const }; } export function logout() { return { type: 'LOGOUT' as const }; } const actions = { authenticated, logout }; export type AuthActions = GetCreatorsAction; export const useAuthActions = () => useActions({ authenticated, logout }); ================================================ FILE: src/store/actions/index.ts ================================================ export * from './auth'; export * from './task'; export * from './taskList'; export * from './preferences'; ================================================ FILE: src/store/actions/preferences.ts ================================================ import { useActions } from '../../hooks/crud-reducer'; import { DeepPartial } from '../../utils/form'; function updatePreferences(payload: DeepPartial) { return { type: 'UPDATE_PREFERENCES' as const, payload }; } export type UpdatePreferences = ReturnType; export type PreferenceActions = UpdatePreferences; export const preferenceActions = { updatePreferences }; export const usePreferenceActions = () => useActions(preferenceActions); ================================================ FILE: src/store/actions/task.ts ================================================ import { tasks_v1 } from 'googleapis'; import { useActions, getCRUDActionsCreator, GetCreatorsAction, PaginatePayload, UpdatePayload } from '../../hooks/crud-reducer'; import { Schema$Task } from '../../typings'; import { taskUUID } from '../../service'; interface Payload$CreateTask extends Partial { prevTask?: string; inherit?: { uuid: string; keys: (keyof Schema$Task)[] }; } interface Payload$MoveTask { to: number; from: number; uuid: string; } export interface Payload$MoveToAnotherList { tasklistId: string; uuid: string; } const [actions, actionTypes] = getCRUDActionsCreator()({ UPDATE: 'UPDATE_TASK', PAGINATE: 'PAGINATE_TASK' } as const); export const TaskActionTypes = { ...actionTypes, GET: 'GET_TASKS', CREATE: 'CREATE_TASK', CREATE_SUCCESS: 'CREATE_TASK_SUCCESS', DELETE: 'DELETE_TASK', FOCUS: 'FOCUS_TASK', UPDATE_SUCCESS: 'UPDATE_TASK_SUCCESS', MOVE_TASK: 'MOVE_TASK', MOVE_TASK_SUCCESS: 'MOVE_TASK_SUCCESS', DELETE_ALL_COMPLETED_TASKS: 'DELETE_ALL_COMPLETED_TASKS', DELETE_ALL_COMPLETED_TASKS_SUCCESS: 'DELETE_ALL_COMPLETED_TASKS_SUCCESS', SYNC: 'SYNC_TASKS', MOVE_TO_ANOTHER_LIST: 'MOVE_TO_ANOTHER_LIST' } as const; export function getTasks(payload: tasks_v1.Params$Resource$Tasks$List) { return { type: TaskActionTypes.GET, payload }; } export function createTask(payload: Payload$CreateTask = {}) { return { type: TaskActionTypes.CREATE, payload: { uuid: taskUUID.next(), ...payload } }; } export function deleteTask(payload: { uuid: string; prevTaskIndex?: number }) { return { type: TaskActionTypes.DELETE, payload }; } export function setFocus(payload?: string | number | null) { return { type: TaskActionTypes.FOCUS, payload }; } export function createTaskSuccess(payload: Schema$Task) { return { // ...actions.update(payload), type: TaskActionTypes.CREATE_SUCCESS, payload }; } export function updateTaskSuccess(payload: UpdatePayload) { return { type: TaskActionTypes.UPDATE_SUCCESS, payload }; } export function moveTask(payload: Payload$MoveTask) { return { type: TaskActionTypes.MOVE_TASK, payload }; } export function moveTaskSuccess() { return { type: TaskActionTypes.MOVE_TASK_SUCCESS }; } export function deleteAllCompletedTasks() { return { type: TaskActionTypes.DELETE_ALL_COMPLETED_TASKS }; } export function deleteAllCompletedTasksSuccess() { return { type: TaskActionTypes.DELETE_ALL_COMPLETED_TASKS_SUCCESS }; } export function syncTasks(payload: PaginatePayload) { return { type: TaskActionTypes.SYNC, payload }; } export function moveToAnotherList(payload: Payload$MoveToAnotherList) { return { type: TaskActionTypes.MOVE_TO_ANOTHER_LIST, payload }; } export const taskActions = { ...actions, getTasks, createTask, deleteTask, setFocus, moveTask, deleteAllCompletedTasks, syncTasks, moveToAnotherList }; export type TaskActions = | GetCreatorsAction | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType; export const useTaskActions = () => useActions(taskActions); ================================================ FILE: src/store/actions/taskList.ts ================================================ import { tasks_v1 } from 'googleapis'; import { getCRUDActionsCreator, useActions, PaginatePayload, GetCreatorsAction } from '../../hooks/crud-reducer'; import { Schema$TaskList } from '../../typings'; const [actions, actionTypes] = getCRUDActionsCreator()({ CREATE: 'CREATE_TASK_LIST', DELETE: 'DELETE_TASK_LIST', UPDATE: 'UPDATE_TASK_LIST', PAGINATE: 'PAGINATE_TASK_LIST' } as const); export const TaskListActionTypes = { ...actionTypes, GET: 'GET_TASKLISTS', NEW: 'NEW_TASK_LIST', DELETE_CURRENT_TASKLIST: 'DELETE_CURRENT_TASKLIST', SORT_BY: 'SORT_TASKLIST_BY', DISABLE: 'DISABLE_TASKLIST', SYNC: 'SYNC_TASKLIST' } as const; export function getTaskLists( payload?: tasks_v1.Params$Resource$Tasklists$List ) { return { type: TaskListActionTypes.GET, payload }; } export function newTaskList(payload: string) { return { type: TaskListActionTypes.NEW, payload }; } export function deleteCurrTaskList() { return { type: TaskListActionTypes.DELETE_CURRENT_TASKLIST }; } export function sortTaskListBy(payload: { id: string; orderType: 'order' | 'date'; }) { return { type: TaskListActionTypes.SORT_BY, payload }; } export function syncTaskList(payload: PaginatePayload) { return { type: TaskListActionTypes.SYNC, payload }; } export const taskListActions = { ...actions, getTaskLists, newTaskList, deleteCurrTaskList, sortTaskListBy }; export type TaskListActions = | GetCreatorsAction | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType; export const useTaskListActions = () => useActions(taskListActions); ================================================ FILE: src/store/epics/auth.ts ================================================ import { generatePath } from 'react-router-dom'; import { ofType, Epic } from 'redux-observable'; import { RouterAction, replace } from 'connected-react-router'; import { of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { AuthActions } from '../actions/auth'; import { authenticate } from '../../service'; import { RootState } from '../reducers'; import { PATHS } from '../../constants'; type Actions = AuthActions | RouterAction; type AuthEpic = Epic; const authEpic: AuthEpic = action$ => action$.pipe( ofType('AUTHENTICATED'), switchMap(() => { authenticate(); return of(replace(generatePath(PATHS.TASKLIST, {}))); }) ); const logoutEpic: AuthEpic = action$ => action$.pipe( ofType('LOGOUT'), switchMap(() => { window.logout(); return of(replace(PATHS.AUTH)); }) ); export default [authEpic, logoutEpic]; ================================================ FILE: src/store/epics/index.ts ================================================ import { combineEpics } from 'redux-observable'; import authEpics from './auth'; import taskEpics from './task'; import taskListEpics from './taskList'; import syncDataEpic from './preferences'; export default combineEpics( ...authEpics, ...taskEpics, ...taskListEpics, ...syncDataEpic ); ================================================ FILE: src/store/epics/preferences.ts ================================================ import { Epic, ofType } from 'redux-observable'; import { defer, empty, fromEvent, merge, timer, of, concat } from 'rxjs'; import { switchMap, map, takeUntil, filter, delay } from 'rxjs/operators'; import { TaskListActions, TaskListActionTypes, syncTaskList } from '../actions/taskList'; import { TaskActions, TaskActionTypes, syncTasks } from '../actions/task'; import { RootState } from '../reducers'; import { getAllTasklist, getAllTasks } from '../../service'; import { currentTaskListsSelector } from '../selectors'; import { PreferenceActions } from '../actions'; type Actions = TaskListActions | TaskActions | PreferenceActions; type PreferencesEpic = Epic; export const syncDataEpic: PreferencesEpic = (action$, state$) => { const userActions$ = action$.pipe( filter( ({ type }) => type !== TaskListActionTypes.SYNC && type !== TaskActionTypes.SYNC ) ); const inactive$ = userActions$.pipe( switchMap(() => { const { inactiveHours } = state$.value.preferences.sync; const ms = inactiveHours * 60 * 60 * 1000; return timer(Math.max(ms, 60 * 1000)); }) ); const reconnect$ = fromEvent(window, 'online').pipe( map(() => (state$.value.preferences.sync.reconnection ? empty() : of([]))) ); return merge(reconnect$, inactive$).pipe( switchMap(() => { const { enabled } = state$.value.preferences.sync; return enabled ? concat( defer(() => getAllTasklist()).pipe(map(syncTaskList)), defer(() => { const taskList = currentTaskListsSelector(state$.value); return taskList ? getAllTasks({ tasklist: taskList.id }) : Promise.reject(new Error('Sync tasks failed')); }).pipe( delay(100), map(syncTasks) // ) ).pipe(takeUntil(userActions$)) : empty(); }) ); }; export const savePreferencesEpic: PreferencesEpic = (action$, state$) => { return action$.pipe( ofType('UPDATE_PREFERENCES'), switchMap(({ payload: changes }) => { window.preferencesStorage.save(state$.value.preferences); if (changes.theme) { window.__setTheme(changes.theme); } if (changes.accentColor) { window.__setAccentColor(changes.accentColor); } if (changes.titleBar) { window.__setTitleBar(changes.titleBar); } return empty(); }) ); }; export default [syncDataEpic, savePreferencesEpic]; ================================================ FILE: src/store/epics/task.ts ================================================ import { ofType, Epic, ActionsObservable } from 'redux-observable'; import { RouterAction } from 'connected-react-router'; import { Observable, empty, defer, of, forkJoin, from, concat } from 'rxjs'; import { switchMap, mergeMap, map, filter, catchError, groupBy, debounceTime, takeUntil, shareReplay, take, tap, concatMap, retry } from 'rxjs/operators'; import { TaskActions, createTaskSuccess, updateTaskSuccess, moveTaskSuccess, deleteAllCompletedTasksSuccess, taskActions } from '../actions/task'; import { RootState } from '../reducers'; import { taskSelector, currentTaskListsSelector } from '../selectors'; import { tasksAPI, getAllTasks } from '../../service'; import { ExtractAction, Schema$Task } from '../../typings'; import { NProgress } from '../../utils/nprogress'; type Actions = TaskActions | RouterAction; type TaskEpic = Epic; const waitForTaskCreated$ = ( action$: ActionsObservable, uuid: string ): Observable => action$.pipe( ofType>( 'CREATE_TASK_SUCCESS' ), filter(action => action.payload.uuid === uuid), map(action => action.payload), take(1) ); const deleteTask$ = ( action$: ActionsObservable, uuid: string ): Observable> => action$.pipe( ofType>('DELETE_TASK'), filter(action => action.payload.uuid === uuid), take(1) ); const nprogressEpic: TaskEpic = action$ => action$.pipe( ofType('PAGINATE_TASK'), switchMap(() => { NProgress.done(); return empty(); }) ); const getTasksEpic: TaskEpic = (action$, state$) => action$.pipe( ofType>('GET_TASKS'), switchMap(action => { NProgress.start(); const max = state$.value.preferences.maxTasks; return getAllTasks({ ...action.payload, maxResults: String(max) }).pipe( map(payload => taskActions.paginate(payload)), tap(() => NProgress.done()) ); }) ); const createTaskEpic: TaskEpic = (action$, state$) => action$.pipe( ofType>('CREATE_TASK'), mergeMap(action => { const { prevTask: prevTaskUUID, uuid } = action.payload; const tasklist = currentTaskListsSelector(state$.value); const prevTask = prevTaskUUID ? taskSelector(prevTaskUUID)(state$.value) : undefined; const prevTask$ = !prevTask || prevTask.id ? of(prevTask) : waitForTaskCreated$(action$, prevTask.uuid); const { uuid: ignore, ...requestBody } = taskSelector(uuid)(state$.value) || {}; const createTask$ = prevTask$.pipe( switchMap(prevTask => defer(() => tasksAPI.insert({ previous: prevTask && prevTask.id!, tasklist: tasklist && tasklist.id!, requestBody }) ) ), map(res => res.data), shareReplay(1) ); return createTask$.pipe( map(task => createTaskSuccess({ ...task, uuid })), takeUntil( deleteTask$(action$, uuid).pipe( tap(() => { createTask$.subscribe(task => tasksAPI.delete({ task: task.id!, tasklist: tasklist && tasklist.id }) ); }) ) ) ); }) ); const updateTaskEpic: TaskEpic = (action$, state$) => action$.pipe( ofType>('UPDATE_TASK'), groupBy(action => action.payload.uuid), mergeMap(group$ => group$.pipe( debounceTime(250), switchMap(action => { const { uuid, ...changes } = action.payload; const tasklist = currentTaskListsSelector(state$.value)!; const task = taskSelector(uuid)(state$.value); const task$ = task && task.id ? of(task) : waitForTaskCreated$(action$, uuid); const patchUpdate$ = task$.pipe( switchMap(task => defer(() => tasksAPI.patch({ task: task.id!, tasklist: tasklist.id!, requestBody: changes }) ).pipe( map(res => res.data), catchError(() => empty()) ) ), shareReplay(1) ); return patchUpdate$.pipe( map(task => updateTaskSuccess({ ...task, ...changes, uuid })), takeUntil( deleteTask$(action$, group$.key).pipe( tap(() => { // patch update will create a new task if task is not exits patchUpdate$.subscribe(task => tasksAPI.delete({ task: task.id!, tasklist: tasklist && tasklist.id }) ); }) ) ) ); }) ) ) ); const deleteTaskEpic: TaskEpic = (action$, state$) => action$.pipe( ofType>('DELETE_TASK'), mergeMap(action => { const { uuid } = action.payload; const task = state$.value.task.deleted[uuid]; const tasklist = currentTaskListsSelector(state$.value); return (task && task.id ? of(task) : waitForTaskCreated$(action$, uuid) ).pipe( switchMap(task => defer(() => tasksAPI.delete({ task: task.id!, tasklist: tasklist && tasklist.id }) ).pipe(mergeMap(() => empty())) ) ); }) ); const moveTaskEpic: TaskEpic = (action$, state$) => { return action$.pipe( ofType>('MOVE_TASK'), groupBy(action => action.payload.uuid), mergeMap(group$ => group$.pipe( debounceTime(500), switchMap((action: ExtractAction) => { const tasklist = currentTaskListsSelector(state$.value); const index = action.payload.to; const todo = state$.value.task.todo.ids; const payload = [todo[index - 1], todo[index]].map(uuid => { const task = uuid && taskSelector(uuid)(state$.value); if (task) { return task.id ? of(task) : waitForTaskCreated$(action$, task.uuid); } return of(undefined); }) as [any, any]; return forkJoin(...payload).pipe( mergeMap(([prevTask, currTask]) => { const taskId = currTask && currTask.id; const prevId = prevTask && prevTask.id; // currTask may be delete before successful loaded if (taskId) { return defer(() => tasksAPI.move({ task: taskId, previous: prevId || undefined, tasklist: tasklist && tasklist.id }) ).pipe( // map(moveTaskSuccess) ); } return empty(); }) ); }) ) ) ); }; const deleteCompletedTasksEpic: TaskEpic = (action$, state$) => action$.pipe( ofType>( 'DELETE_ALL_COMPLETED_TASKS' ), switchMap(() => { const tasklist = currentTaskListsSelector(state$.value); const completedTasks = state$.value.task.completed.list; if (tasklist) { return from(completedTasks).pipe( concatMap(task => defer(() => tasksAPI.delete({ task: task.id!, tasklist: tasklist.id }) ).pipe( retry(1), catchError(() => of([])) ) ), map(() => deleteAllCompletedTasksSuccess()) ); } return empty(); }) ); const moveToAnotherListEpic: TaskEpic = (action$, state$) => action$.pipe( ofType>( 'MOVE_TO_ANOTHER_LIST' ), switchMap(action => { const tasklist = currentTaskListsSelector(state$.value); const task = state$.value.task.deleted[action.payload.uuid]; if (tasklist && task && task.id) { const { uuid, ...requestBody } = task; const newTask$ = defer(() => tasksAPI.insert({ tasklist: action.payload.tasklistId, requestBody }) ); const deleteTask$ = defer(() => tasksAPI.delete({ task: task.id!, tasklist: tasklist.id }) ); concat(newTask$, deleteTask$).subscribe(); } else { console.warn( 'Cannot move task to anthor list', !!tasklist, !!task, !!task.id ); } return empty(); }) ); export default [ nprogressEpic, getTasksEpic, createTaskEpic, updateTaskEpic, deleteTaskEpic, moveTaskEpic, deleteCompletedTasksEpic, moveToAnotherListEpic ]; ================================================ FILE: src/store/epics/taskList.ts ================================================ import { ofType, Epic } from 'redux-observable'; import { generatePath } from 'react-router-dom'; import { RouterAction, push, replace } from 'connected-react-router'; import { of, defer, empty, merge } from 'rxjs'; import { switchMap, mergeMap, map, tap } from 'rxjs/operators'; import { taskListActions, TaskListActions } from '../actions/taskList'; import { RootState } from '../reducers'; import { tasklists, getAllTasklist, taskListAPI } from '../../service'; import { PATHS } from '../../constants'; import { ExtractAction, Schema$TaskList } from '../../typings'; import { currentTaskListsSelector } from '../selectors'; import NProgress from '../../utils/nprogress'; type Actions = TaskListActions | RouterAction; type TaskEpic = Epic; const gotoMasterTasklist = () => of(replace(generatePath(PATHS.TASKLIST, {}))); const getTaskListsEpic: TaskEpic = action$ => action$.pipe( ofType>('GET_TASKLISTS'), tap(() => NProgress.start()), switchMap(action => getAllTasklist(action.payload).pipe( map(payload => taskListActions.paginate(payload)) ) ) ); const newTaskListEpic: TaskEpic = action$ => action$.pipe( ofType>('NEW_TASK_LIST'), tap(() => NProgress.start()), switchMap(action => defer(() => tasklists.insert({ requestBody: { title: action.payload } }) ).pipe( map(res => res.data as Schema$TaskList), mergeMap(tasklist => of( taskListActions.create(tasklist), push( generatePath(PATHS.TASKLIST, { taskListId: tasklist.id }) ) ) ) ) ) ); const deleteCurrentTaskListEpic: TaskEpic = (action$, state$) => action$.pipe( ofType('DELETE_CURRENT_TASKLIST'), tap(() => NProgress.start()), switchMap(() => { const current = currentTaskListsSelector(state$.value); return current ? defer(() => taskListAPI.delete({ tasklist: current.id })).pipe( mergeMap(() => merge( of(taskListActions.delete({ id: current.id })), gotoMasterTasklist() ) ) ) : empty(); }) ); const updateTaskListEpic: TaskEpic = action$ => action$.pipe( ofType>( 'UPDATE_TASK_LIST' ), switchMap(action => { const { id } = action.payload; return defer(() => id ? taskListAPI.update({ tasklist: id, requestBody: action.payload }) : Promise.resolve() ).pipe(mergeMap(() => empty())); }) ); const sortTaskListEpic: TaskEpic = (action$, state$) => action$.pipe( ofType('DELETE_TASK_LIST', 'SORT_TASKLIST_BY'), switchMap(() => { window.taskListSortByDateStorage.save(state$.value.taskList.sortByDate); return empty(); }) ); const redirectEpic: TaskEpic = (action$, state$) => action$.pipe( ofType('PAGINATE_TASK_LIST', 'SYNC_TASKLIST'), switchMap(() => currentTaskListsSelector(state$.value) ? empty() : gotoMasterTasklist() ) ); export default [ getTaskListsEpic, newTaskListEpic, deleteCurrentTaskListEpic, updateTaskListEpic, sortTaskListEpic, redirectEpic ]; ================================================ FILE: src/store/index.ts ================================================ import { createHashHistory } from 'history'; import { createStore, applyMiddleware, compose } from 'redux'; import { createEpicMiddleware, Epic } from 'redux-observable'; import { routerMiddleware } from 'connected-react-router'; import { BehaviorSubject } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import rootEpic from './epics'; import createRootReducer from './reducers'; export const history = createHashHistory(); const epic$ = new BehaviorSubject(rootEpic); const hotReloadingEpic: Epic = (action$, state$, dependencies) => epic$.pipe(switchMap(epic => epic(action$, state$, dependencies))); export default function configureStore() { const epicMiddleware = createEpicMiddleware(); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const enhancer = composeEnhancers( applyMiddleware(routerMiddleware(history), epicMiddleware) ); const store = createStore(createRootReducer(history), undefined, enhancer); epicMiddleware.run(hotReloadingEpic); if (process.env.NODE_ENV !== 'production') { if (module.hot) { module.hot.accept('./reducers', () => { store.replaceReducer(createRootReducer(history)); }); module.hot.accept('./epics', () => { const nextRootEpic = require('./epics').default; epic$.next(nextRootEpic); }); } } return store; } export * from './actions'; export * from './reducers'; export * from './epics'; export * from './selectors'; ================================================ FILE: src/store/reducers/auth.ts ================================================ import { AuthActions } from '../actions/auth'; interface State { loggedIn: boolean; } const initialState: State = { loggedIn: !!window.tokenStorage.get() }; export default function (state = initialState, action: AuthActions): State { switch (action.type) { case 'AUTHENTICATED': return { ...state, loggedIn: true }; case 'LOGOUT': return { ...state, loggedIn: false }; default: return state; } } ================================================ FILE: src/store/reducers/index.ts ================================================ import { combineReducers } from 'redux'; import { connectRouter } from 'connected-react-router'; import { taskReducer } from './task'; import { taskListReducer } from './taskList'; import auth from './auth'; import { preferencesReducer } from './preferences'; const rootReducer = (history: Parameters[0]) => combineReducers({ router: connectRouter(history), taskList: taskListReducer, task: taskReducer, preferences: preferencesReducer, auth }); export type RootState = ReturnType>; export default rootReducer; ================================================ FILE: src/store/reducers/preferences.ts ================================================ import { PreferenceActions } from '../actions/preferences'; interface State extends Schema$Preferences {} const initialState: State = { ...window.preferencesStorage.get(), ...(window.platform === 'darwin' ? { titleBar: 'native' } : {}) }; export function preferencesReducer( state = initialState, action: PreferenceActions ): State { switch (action.type) { case 'UPDATE_PREFERENCES': return { ...state, ...action.payload, sync: { ...state.sync, ...action.payload.sync } }; default: return state; } } ================================================ FILE: src/store/reducers/task.ts ================================================ import { TaskActionTypes, TaskActions, taskActions } from '../actions/task'; import { taskSelector } from '../selectors'; import { createCRUDReducer, parsePaginatePayload } from '../../hooks/crud-reducer'; import { Schema$Task } from '../../typings'; interface State { loading?: boolean; todo: typeof taskState; completed: typeof taskState; focused: string | null; deleted: { [x: string]: Schema$Task }; } const [taskState, reducer] = createCRUDReducer('uuid', { prefill: false, actionTypes: TaskActionTypes }); const initialState: State = { loading: true, todo: taskState, completed: taskState, focused: null, deleted: {} }; export function taskReducer( state = initialState, action: TaskActions ): typeof initialState { switch (action.type) { case 'GET_TASKS': return { ...state, loading: true }; case 'SYNC_TASKS': case 'PAGINATE_TASK': return (() => { const todo: Schema$Task[] = []; const completed: Schema$Task[] = []; const payload = parsePaginatePayload(action.payload); for (const task of payload.data) { task.hidden || task.status === 'completed' ? completed.push(task) : todo.push(task); } return { ...state, loading: false, todo: reducer(taskState, taskActions.paginate(todo)), completed: reducer(taskState, taskActions.paginate(completed)) }; })(); case 'CREATE_TASK': return (() => { const { prevTask, uuid, inherit, ...task } = action.payload; const index = prevTask ? state.todo.ids.indexOf(prevTask) + 1 : 0; const inheritFrom = inherit && state.todo.byIds[inherit.uuid]; const newTask = { uuid, ...task, ...(inheritFrom && inherit!.keys.reduce( (t, k) => ({ ...t, [k]: inheritFrom[k] }), {} as Partial )) }; return { ...state, focused: uuid, todo: { ...state.todo, ids: [ ...state.todo.ids.slice(0, index), uuid, ...state.todo.ids.slice(index) ], list: [ ...state.todo.list.slice(0, index), newTask, ...state.todo.list.slice(index) ], byIds: { ...state.todo.byIds, [uuid]: newTask } } }; })(); case 'UPDATE_TASK': return (() => { const { uuid } = action.payload; const isTodoTask = state.todo.ids.includes(uuid); const deleteTask = taskActions.deleteTask({ uuid }); const updateTask = taskActions.update(action.payload); if (action.payload.status === 'completed') { const { id, hidden, ...task } = state.todo.byIds[uuid]; return { ...state, todo: reducer(state.todo, deleteTask), completed: reducer( state.completed, taskActions.createTask({ ...task, ...state.todo.byIds[uuid], hidden: true }) ) }; } else if (action.payload.status === 'needsAction') { const { id, hidden, ...task } = state.completed.byIds[uuid]; return { ...state, todo: reducer( state.todo, taskActions.createTask({ ...task, ...action.payload, hidden: false }) ), completed: reducer(state.completed, deleteTask) }; } return { ...state, ...(isTodoTask ? { todo: reducer(state.todo, updateTask) } : { completed: reducer(state.completed, updateTask) }) }; })(); case 'DELETE_TASK': return (() => { const { uuid, prevTaskIndex } = action.payload; const isTodoTask = state.todo.ids.includes(uuid); const [newState, task] = isTodoTask ? [reducer(state.todo, action), state.todo.byIds[uuid]] : [reducer(state.completed, action), state.completed.byIds[uuid]]; const prevTask = typeof prevTaskIndex === 'number' && state.todo.ids[prevTaskIndex]; const [first, second] = state.todo.ids; return { ...state, ...(isTodoTask ? { todo: newState } : { completed: newState }), deleted: { ...state.deleted, [uuid]: task! }, focused: // focus if task deleted by backspace or it is the first task and focused prevTask || (state.focused && first === uuid && second) || null }; })(); case 'FOCUS_TASK': return { ...state, focused: (typeof action.payload === 'number' ? state.todo.ids[ Math.max(0, Math.min(state.todo.ids.length - 1, action.payload)) ] : action.payload) || null }; case 'CREATE_TASK_SUCCESS': case 'UPDATE_TASK_SUCCESS': return (() => { const task = { ...action.payload, ...taskSelector(action.payload.uuid)({ task: state }) }; const isTodoTask = state.todo.ids.includes(task.uuid); const _action = taskActions.update(task); const newState = isTodoTask ? { todo: reducer(state.todo, _action) } : { completed: reducer(state.completed, _action) }; return { ...state, ...newState }; })(); case 'MOVE_TASK': const { from, to } = action.payload; return to < 0 ? state : { ...state, todo: { ...state.todo, ids: move(state.todo.ids, from, to), list: move(state.todo.list, from, to) } }; case 'DELETE_ALL_COMPLETED_TASKS_SUCCESS': return { ...state, completed: initialState.completed }; case 'MOVE_TO_ANOTHER_LIST': return (() => { const { uuid } = action.payload; return { ...state, focued: null, deleted: { ...state.deleted, [uuid]: state.todo.byIds[uuid]! }, todo: reducer(state.todo, taskActions.deleteTask({ uuid })) }; })(); default: return state; } } // credit: https://github.com/sindresorhus/array-move function move(arr: T[], from: number, to: number) { const clone = arr.slice(); clone.splice(to < 0 ? clone.length + to : to, 0, clone.splice(from, 1)[0]); return clone; } ================================================ FILE: src/store/reducers/taskList.ts ================================================ import { TaskListActionTypes, TaskListActions, taskListActions } from '../actions/taskList'; import { createCRUDReducer, removeFromArray } from '../../hooks/crud-reducer'; import { Schema$TaskList, ExtractAction } from '../../typings'; import { TaskActions } from '../actions'; type DefaultState = typeof defaultState; interface State extends DefaultState { loading: boolean; sortByDate: string[]; } const [defaultState, reducer] = createCRUDReducer('id', { prefill: false, actionTypes: TaskListActionTypes }); const initialState: State = { ...defaultState, loading: true, sortByDate: window.taskListSortByDateStorage.get() }; export function taskListReducer( state = initialState, action: TaskListActions | ExtractAction ): State { switch (action.type) { case 'PAGINATE_TASK': return { ...state, loading: false }; case 'GET_TASKLISTS': case 'NEW_TASK_LIST': return { ...state, loading: true }; case 'CREATE_TASK_LIST': return { ...state, ...reducer(state, action), loading: false }; case 'DELETE_TASK_LIST': return { ...state, ...reducer(state, action), loading: false, sortByDate: removeFromArray( state.sortByDate, state.sortByDate.indexOf(action.payload.id) ) }; case 'DELETE_CURRENT_TASKLIST': return { ...state, loading: true }; case 'SORT_TASKLIST_BY': const { id, orderType } = action.payload; const { sortByDate } = state; return { ...state, sortByDate: orderType === 'date' ? [...state.sortByDate, id] : removeFromArray(sortByDate, sortByDate.indexOf(id)) }; case 'SYNC_TASKLIST': return { ...state, ...reducer(initialState, taskListActions.paginate(action.payload)), loading: false }; default: return { ...state, ...reducer(state, action) }; } } ================================================ FILE: src/store/selectors/index.ts ================================================ export * from './task'; export * from './taskList'; export * from './preferences'; ================================================ FILE: src/store/selectors/preferences.ts ================================================ import { RootState } from '../reducers'; export const preferencesSelector = (state: RootState) => state.preferences; export const titleBarSelector = (state: RootState) => state.preferences.titleBar; export const themeSelector = (state: RootState) => state.preferences.theme; ================================================ FILE: src/store/selectors/task.ts ================================================ import { RootState } from '../reducers'; import { createSelector } from 'reselect'; import { Schema$Task } from '../../typings'; export const todoTaskIdsSelector = (state: RootState) => state.task.todo.ids; export const todoTaskListSelector = (state: RootState) => state.task.todo.list; export const todoTaskByIdsSelector = (state: RootState) => state.task.todo.byIds; export const completedTaskIdsSelector = (state: RootState) => state.task.completed.ids; export const taskSelector = (id: string) => (state: Pick) => state.task.todo.byIds[id] || state.task.completed.byIds[id]; export const todoTaskSelector = (id: string) => (state: RootState) => state.task.todo.byIds[id]; export const completedTaskSelector = (id: string) => (state: RootState) => state.task.completed.byIds[id]; export const focusedSelector = (id: string) => (state: RootState) => state.task.focused === id; const future = new Date(10 ** 15).getTime(); const getDate = (t?: Schema$Task) => t && t.due ? new Date(t.due).getTime() : future; export const getDateLabel = (due: string | null | undefined, now: Date) => { let label = 'No date'; if (due) { const date = new Date(due); const dayDiff = date.dayDiff(now); if (dayDiff > 0) { label = 'Past'; } else if (dayDiff === 0) { label = 'Today'; } else if (dayDiff === -1) { label = 'Tomorrow'; } else if (dayDiff < -1) { label = 'Due ' + date.format('D, j M'); } } return label; }; export const todoTasksIdsByDateSelector = createSelector( todoTaskIdsSelector, todoTaskByIdsSelector, (ids, byIds) => { const order = ids .slice() .sort((a, b) => getDate(byIds[a]) - getDate(byIds[b])); const map = order.reduce((result, id) => { const task = byIds[id]; if (task) { const label = getDateLabel(task.due, new Date()); return { ...result, [label]: [...(result[label] || []), task] }; } return result; }, {} as Record); return { order, tasks: Object.entries(map) }; } ); ================================================ FILE: src/store/selectors/taskList.ts ================================================ import { createSelector } from 'reselect'; import { matchPath } from 'react-router-dom'; import { RootState } from '../reducers'; import { PATHS } from '../../constants'; import { Schema$TaskList } from '../../typings'; export const taskListIdsSelector = (state: RootState) => state.taskList.ids; export const taskListsSelector = (id: string) => (state: RootState) => state.taskList.byIds[id]; export const currentTaskListsSelector = (state: RootState) => { const matches = matchPath<{ taskListId: string }>( state.router.location.pathname, { path: PATHS.TASKLIST, exact: true } ); const id = matches && matches.params.taskListId; return id ? state.taskList.byIds[id] : (state.taskList.list[0] as Schema$TaskList); }; export const isMasterTaskListSelector = createSelector( taskListIdsSelector, currentTaskListsSelector, (ids, list) => list && list.id === ids[0] ); export const isSortByDateSelector = (id: string) => (state: RootState) => state.taskList.sortByDate.includes(id); ================================================ FILE: src/theme.ts ================================================ import { createMuiTheme } from '@material-ui/core/styles'; export const theme = createMuiTheme({ typography: { fontSize: 14, fontFamily: 'Roboto' }, props: { MuiDivider: { light: true, style: { margin: '0.4em 0', backgroundColor: 'var(--border-color)' } }, MuiSvgIcon: { fontSize: 'small', color: 'inherit' }, MuiButton: { color: 'inherit' } } }); export default theme; ================================================ FILE: src/typings/index.ts ================================================ import { tasks_v1 } from 'googleapis'; export interface Schema$Task { uuid: string; completed?: string | null; hidden?: boolean | null; id?: string | null; notes?: string | null; position?: string | null; status?: string | null; title?: string | null; due?: string | null; } export interface Schema$TaskList extends tasks_v1.Schema$TaskList { id: string; } export type ExtractAction< T1 extends { type: string }, T2 extends T1['type'] > = T1 extends { type: T2 } ? T1 : never; ================================================ FILE: src/utils/date.ts ================================================ /* eslint-disable */ /* tslint:disable:only-arrow-functions */ // Date class extension // reference : // http://stackoverflow.com/questions/5495815/javascript-code-for-showing-yesterdays-date-and-todays-date?answertab=votes#tab-top // http://stackoverflow.com/questions/9192956/getting-previous-date-using-javascript?answertab=votes#tab-top // http://stackoverflow.com/a/43020185 export const _ = ''; Date.prototype.getDayCn = function () { const days_full: DayCn[] = ['日', '一', '二', '三', '四', '五', '六']; return days_full[this.getDay()]; }; Date.prototype.addDays = function (n: number) { const date = new Date(); const time = this.getTime(); const changedDate = new Date(time + n * 24 * 60 * 60 * 1000); date.setTime(changedDate.getTime()); return date; }; Date.prototype.addMonths = function (n: number) { const month = this.getMonth(); const lastYear = month === 0 && n < 0; const nextYear = month === 11 && n > 0; if (lastYear) { return new Date(this.getFullYear() + n, 11, 1); } else if (nextYear) { return new Date(this.getFullYear() + n, 0, 1); } else { return new Date(this.getFullYear(), month + n, 1); } }; // Provide month names Date.prototype.getMonthName = function () { const month_names: MonthFullName[] = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; return month_names[this.getMonth()]; }; // Provide month abbreviation Date.prototype.getMonthAbbr = function () { const month_abbrs: MonthAbbr[] = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; return month_abbrs[this.getMonth()]; }; // Provide full day of week name Date.prototype.getDayFull = function () { const days_full: DayFullName[] = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ]; return days_full[this.getDay()]; }; // Provide full day of week name Date.prototype.getDayAbbr = function () { const days_abbr: DayAbbr[] = [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thur', 'Fri', 'Sat' ]; return days_abbr[this.getDay()]; }; // Provide the day of year 1-365 Date.prototype.getDayOfYear = function () { const onejan = new Date(this.getFullYear(), 0, 1); return Math.ceil((Number(this) - Number(onejan)) / 86400000); }; // Provide the day suffix (st,nd,rd,th) Date.prototype.getDaySuffix = function () { const d = this.getDate(); const sfx: DaySuffix[] = ['th', 'st', 'nd', 'rd']; const val = d % 100; return sfx[(val - 20) % 10] || sfx[val] || sfx[0]; }; // Provide Week of Year Date.prototype.getWeekOfYear = function () { const onejan = new Date(this.getFullYear(), 0, 1); return Math.ceil( ((Number(this) - Number(onejan)) / 86400000 + onejan.getDay() + 1) / 7 ); }; // Provide if it is a leap year or not Date.prototype.isLeapYear = function () { const yr = String(this.getFullYear()); if (parseInt(yr, 10) % 4 === 0) { if (parseInt(yr, 10) % 100 === 0) { if (parseInt(yr, 10) % 400 !== 0) { return false; } if (parseInt(yr, 10) % 400 === 0) { return true; } } if (parseInt(yr, 10) % 100 !== 0) { return true; } } if (parseInt(yr, 10) % 4 !== 0) { return false; } return false; }; // Provide Number of Days in a given month Date.prototype.getMonthDayCount = function () { const month_day_counts = [ 31, this.isLeapYear() ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; return month_day_counts[this.getMonth()]; }; Date.prototype.compare = function (dateObj: Date) { const date = dateObj.getDate(); const month = dateObj.getMonth(); const year = dateObj.getFullYear(); const curMonth = this.getMonth(); const curDate = this.getDate(); const sameYear = this.getFullYear() === year; const sameMonth = curMonth === month && sameYear; const sameDate = curDate === date && sameMonth; const lastMonth = curMonth === 1 ? month === 12 : month < curMonth; const nextMonth = curMonth === 12 ? month === 1 : month > curMonth; return { sameYear, sameDate, sameMonth, lastMonth, nextMonth }; }; Date.prototype.dayDiff = function (d: Date = new Date()) { return Math.floor((d.getTime() - this.getTime()) / 1000 / 60 / 60 / 24); }; Date.prototype.isToday = function () { return this.dayDiff(new Date()) === 0; }; Date.prototype.toISODateString = function () { const str = this.toISOString(); return ( str.substring(0, str.indexOf('T')) + str.substring(str.indexOf('T')).replace(/\d/g, '0') ); }; // format provided date into this.format format Date.prototype.format = function (dateFormat_: string) { // break apart format string into array of characters const dateFormat = dateFormat_.split(''); const date = this.getDate(); const month = this.getMonth(); const hours = this.getHours(); const minutes = this.getMinutes(); const seconds = this.getSeconds(); // get all date properties ( based on PHP date object functionality ) const date_props = { d: date < 10 ? '0' + date : date, D: this.getDayAbbr(), j: this.getDate(), l: this.getDayFull(), S: this.getDaySuffix(), w: this.getDay(), z: this.getDayOfYear(), W: this.getWeekOfYear(), F: this.getMonthName(), m: month < 9 ? '0' + (month + 1) : month + 1, M: this.getMonthAbbr(), n: month + 1, t: this.getMonthDayCount(), L: this.isLeapYear() ? '1' : '0', Y: this.getFullYear(), y: this.getFullYear() + ''.substring(2, 4), a: hours > 12 ? 'pm' : 'am', A: hours > 12 ? 'PM' : 'AM', g: hours % 12 > 0 ? hours % 12 : 12, G: hours > 0 ? hours : '12', h: hours % 12 > 0 ? hours % 12 : 12, H: hours, i: minutes < 10 ? '0' + minutes : minutes, s: seconds < 10 ? '0' + seconds : seconds }; // loop through format array of characters and add matching data else add the format character (:,/, etc.) let date_string = ''; for (const f of dateFormat) { if (f.match(/[a-zA-Z]/g)) { // @ts-ignore date_string += date_props[f] ? date_props[f] : ''; } else { date_string += f; } } return date_string; }; ================================================ FILE: src/utils/form/form.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import React, { ReactElement, ReactNode } from 'react'; import RcForm, { Field as RcField, useForm as RcUseForm } from 'rc-field-form'; import { FormProps as RcFormProps } from 'rc-field-form/es/Form'; import { FieldProps as RcFieldProps } from 'rc-field-form/es/Field'; import { Meta, FieldError, Store } from 'rc-field-form/lib/interface'; import { Validator, compose as composeValidator } from './validators'; import { NamePath, Paths, PathType, DeepPartial, Control } from './typings'; type HTMLDivProps = React.DetailedHTMLProps< React.HTMLAttributes, HTMLDivElement >; type HTMLLabelProps = React.DetailedHTMLProps< React.LabelHTMLAttributes, HTMLLabelElement >; export interface FieldData> extends Partial> { name: Name; value?: Name extends Paths ? PathType : Name extends keyof S ? S[Name] : undefined; } export type FormInstance = { getFieldValue(name: K): S[K]; getFieldValue>(name: T): PathType; getFieldsValue(nameList?: NamePath[]): S; getFieldError(name: NamePath): string[]; getFieldsError(nameList?: NamePath[]): FieldError[]; isFieldsTouched( nameList?: NamePath[], allFieldsTouched?: boolean ): boolean; isFieldsTouched(allFieldsTouched?: boolean): boolean; isFieldTouched(name: NamePath): boolean; isFieldValidating(name: NamePath): boolean; isFieldsValidating(nameList: NamePath[]): boolean; resetFields(fields?: NamePath[]): void; setFields(fields: FieldData>[]): void; setFieldsValue(value: DeepPartial): void; validateFields(nameList?: NamePath[]): Promise; submit: () => void; }; export interface FormProps extends Omit { form?: FormInstance; initialValues?: DeepPartial; onFinish?: (values: V) => void; onValuesChange?: (changes: DeepPartial, values: S) => void; ref?: React.Ref>; transoformInitialValues?: (payload: DeepPartial) => DeepPartial; beforeSubmit?: (payload: S) => V; } type OmititedRcFieldProps = Omit< RcFieldProps, 'name' | 'dependencies' | 'children' | 'rules' >; export interface FormItemLabelProps extends HTMLDivProps { label?: ReactNode; } interface BasicFormItemProps extends OmititedRcFieldProps { name?: NamePath; children?: ReactElement | ((value: S, fields: FieldData) => ReactElement); validators?: | Array | ((value: S) => Array); label?: ReactNode; noStyle?: boolean; className?: string; } type Deps = Array>; type FormItemPropsDeps = | { deps?: Deps; children?: ReactElement; validators?: Array; } | { deps: Deps; validators: (value: S) => Array; } | { deps: Deps; children: (value: S, fields: FieldData) => ReactElement; }; export type FormItemProps = BasicFormItemProps & FormItemPropsDeps; export interface FormItemClassName { item?: string; label?: string; error?: string; touched?: string; validating?: string; help?: string; } type Rule = NonNullable[number]; const getValues = (obj: any, paths: (string | number)[]) => paths.reduce((result, key) => result && result[key], obj); export function createShouldUpdate( names: Array = [] ): RcFieldProps['shouldUpdate'] { return (prev, curr) => { for (const name of names) { const paths = Array.isArray(name) ? name : [name]; if (getValues(prev, paths) !== getValues(curr, paths)) { return true; } } return false; }; } const defaultFormItemClassName: Required = { item: 'rc-form-item', label: 'rc-form-item-label', error: 'rc-form-item-error', touched: 'rc-form-item-touched', validating: 'rc-form-item-validating', help: 'rc-form-item-help' }; export function createForm({ itemClassName, ...defaultProps }: Partial> & { itemClassName?: FormItemClassName } = {}) { const ClassNames = { ...defaultFormItemClassName, ...itemClassName }; const FormItemLabel: React.FC = ({ className = defaultProps.className, children, label, ...props }) => React.createElement( 'div', { ...props, className: [className, ClassNames.item].filter(Boolean).join(' ').trim() }, React.createElement( 'label', { className: ClassNames.label }, label ), children ); const FormItem = (itemProps: FormItemProps) => { const { name, children, validators = [], deps = [], noStyle, label, className = '', ...props } = { ...defaultProps, ...itemProps } as FormItemProps & { deps?: Array; name: string | number; }; const rules: Rule[] = [ typeof validators === 'function' ? ({ getFieldsValue }) => ({ validator: composeValidator(validators(getFieldsValue(deps) as S)) }) : { validator: composeValidator(validators) } ]; return React.createElement( RcField, { name, rules, ...(deps.length ? { dependencies: deps, shouldUpdate: createShouldUpdate(deps) } : {}), ...props }, ( control: Control, fields: FieldData, form: FormInstance ) => { const { getFieldsValue } = form; const { touched, validating, errors } = fields; const childNode = typeof children === 'function' ? children(getFieldsValue(deps), fields) : name ? React.cloneElement(children as React.ReactElement, { ...control }) : children; if (noStyle) { return childNode; } const error = errors && errors[0]; return React.createElement( FormItemLabel, { label, className: [ className, error && ClassNames.error, touched && ClassNames.touched, validating && ClassNames.validating ] .filter(Boolean) .join(' ') .trim() }, childNode, React.createElement( 'div', { className: ClassNames.help }, error ) ); } ); }; const Form = React.forwardRef, FormProps>( ( { children, onFinish, beforeSubmit, initialValues, transoformInitialValues, ...props }, ref ) => React.createElement( RcForm, { ...props, ref, initialValues: initialValues && transoformInitialValues ? transoformInitialValues(initialValues) : initialValues, onFinish: onFinish && ((store: unknown) => { onFinish(beforeSubmit ? beforeSubmit(store as S) : (store as V)); }) } as RcFormProps, children ) ); const useForm: () => [FormInstance] = RcUseForm as any; return { Form, FormItem, FormList: RcForm.List, FormProvider: RcForm.FormProvider, FormItemLabel, useForm }; } export const { Form, FormItem, FormItemLabel, FormList, useForm, FormProvider } = createForm(); ================================================ FILE: src/utils/form/index.ts ================================================ import * as validators from './validators'; export { validators }; export * from './form'; export * from './typings'; ================================================ FILE: src/utils/form/typings.ts ================================================ type ValueOf = T[keyof T]; type Cons = T extends readonly any[] ? ((h: H, ...t: T) => void) extends (...r: infer R) => void ? R : never : never; type Prev = [ never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[] ]; // https://stackoverflow.com/a/58436959 export type Paths = [D] extends [never] ? never : T extends any[] // for array ? [number] : T extends object // eslint-disable-line @typescript-eslint/ban-types ? { [K in keyof T]-?: K extends keyof T[K] & keyof ValueOf ? (keyof T)[] // this turn [string, string, string] into string[] : | [K] | (Paths extends infer P ? P extends [] ? never : Cons : never); }[keyof T] : []; export type DeepPartial = T extends any[] | (() => void) ? T : T extends object // eslint-disable-line @typescript-eslint/ban-types ? { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; // eslint-disable-line @typescript-eslint/ban-types } : T; interface NextInt { 0: 1; 1: 2; 2: 3; 3: 4; 4: 5; [rest: number]: number; } export type PathType = { [K in keyof P & number & Index]: P[K] extends undefined ? T : P[K] extends keyof T ? NextInt[K] extends keyof P & number ? PathType> : T[P[K]] : never; }[Index]; export type NamePath = keyof T | Paths; export interface Control { value?: T; onChange?: (value?: T) => void; } export interface ControlProps extends Control { name?: (string | number)[]; } ================================================ FILE: src/utils/form/validators.ts ================================================ export type Validator = (rule: any, value: any) => Promise; export const compose = (validators: Array): Validator => { return async (rule, value) => { for (const validator of validators) { if (validator) { try { await validator(rule, value); } catch (err) { return Promise.reject(err); } } } return Promise.resolve(); }; }; export const required = (msg: string): Validator => (_, value) => { const val = typeof value === 'string' ? value.trim() : value; const valid = !!(Array.isArray(val) ? val.length : typeof value === 'boolean' // for checkbox ? value : !(typeof val === 'undefined' || val === null || val === '')); return valid ? Promise.resolve() : Promise.reject(msg); }; export const number: Validator = (_, value) => !value || /^-?\d+\.?\d*$/.test(value) ? Promise.resolve() : Promise.reject('Plase input number only'); export const integer = ( msg: string = 'Plase input integer only' ): Validator => (_, value) => value === '' || isNaN(Number(value)) || /^(-)?\d*$/.test(value) ? Promise.resolve() : Promise.reject(msg); export const maxDecimal = (max: number): Validator => (_, value) => value === '' || isNaN(Number(value)) || new RegExp(`^(\\d+|\\d+\\.\\d{0,${max}})$`).test(value) ? Promise.resolve() : Promise.reject(`Should not more then ${max} decimal`); const numberComparation = ( callback: (value: number, flag: number) => boolean ) => (flag: number, msg: string, inclusive = false) => { const validator: Validator = (_, value) => { const num = Number(value); return value === '' || isNaN(num) || callback(num, flag) || (inclusive && num === flag) ? Promise.resolve() : Promise.reject(msg); }; return validator; }; export const min = numberComparation((value, flag) => value > flag); export const max = numberComparation((value, flag) => value < flag); const lengthComparation = ( callback: (length: number, flag: number) => boolean ) => (flag: number, msg: string) => { const validator: Validator = (_, value) => { if (Array.isArray(value) || typeof value === 'string') { return callback(value.length, flag) ? Promise.resolve() : Promise.reject(msg); } return Promise.resolve(); }; return validator; }; export const minLength = lengthComparation( (length, minLength) => length >= minLength ); export const maxLength = lengthComparation( (length, maxLength) => length <= maxLength ); export const passwordFormat = ( msg: string = 'Password must contain number and english character' ): Validator => (_, value) => /^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z_]{6,20}$/.test(value) ? Promise.resolve() : Promise.reject(msg); export const shouldBeEqual = (val: any, msg: string): Validator => (_, value) => value === val ? Promise.resolve() : Promise.reject(msg); export const shouldNotBeEqual = (val: any, msg: string): Validator => ( _, value ) => (value !== val ? Promise.resolve() : Promise.reject(msg)); ================================================ FILE: src/utils/nprogress.ts ================================================ import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; NProgress.configure({ parent: '#root', easing: 'ease', showSpinner: false, trickleSpeed: 50 }); export { NProgress }; export const NProgressDone = () => NProgress.done(); export default NProgress; ================================================ FILE: src/utils/uuid.ts ================================================ export class UUID { private count: number = 0; next() { this.count++; // to string avoid conflict with index return String(this.count); } reset() { this.count = 0; } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "esnext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "pretty": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "noFallthroughCasesInSwitch": true }, "include": ["src", "common.d.ts"], "exclude": ["node_modules", "**/node_modules/*"] }