diff --git a/App.js b/App.js new file mode 100644 index 00000000..171b59f8 --- /dev/null +++ b/App.js @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from "react"; +import "../App.css"; +import { connect } from "react-redux"; +import Amplify, { PubSub } from "aws-amplify"; +import { AuthState, onAuthUIStateChange } from "@aws-amplify/ui-components"; +import * as projectsActions from "../actions/projects"; +import * as notesActions from "../actions/notes"; +import * as appActions from "../actions/app"; +import * as userActions from "../actions/user"; +import aws_exports from "../aws-exports"; +import { Route, useHistory, useRouteMatch } from "react-router-dom"; +import ProjectsPanel from "./ProjectsPanel"; +import SidePanel from "./SidePanel"; +import NotesPanel from "./NotesPanel"; +import Loading from "./Loading"; +import Login from "./Login"; +Amplify.configure(aws_exports); +PubSub.configure(aws_exports); +import {ToastProvider, DefaultToast} from 'react-toast-notifications' +const App = (props) => { + + + const { dispatch, user, projects, app } = props; + const [state, setState] = useState({ + projectPanel: false, + sidePanel: false, + }); + const history = useHistory(); + const routeMatch = useRouteMatch({ + exact: true, + sensitive: true, + path: [ + "/local/:projectPermalink", + "/:username/:projectPermalink/:notePermalink", + "/:username/:projectPermalink", + "/", + ], + }); + + const fetchLocalProjects = () => { + if (user.state !== AuthState.SignedIn) { + dispatch(projectsActions.handleFetchOwnedProjects()); + } + }; + + const setHideShowProjectPanel = () => { + setState((state) => { + return { ...state, sidePanel: false, projectPanel: !state.projectPanel }; + }); + }; + + const setHideShowSidePanel = () => { + setState((state) => { + return { ...state, projectPanel: false, sidePanel: !state.sidePanel }; + }); + }; + + useEffect(() => { + dispatch(appActions.setHistory(history)); + onAuthUIStateChange(async (nextAuthState, authData) => { + dispatch(userActions.handleSetData(authData)); + dispatch(userActions.setState(nextAuthState)); + if (nextAuthState === AuthState.SignedIn) { + window.removeEventListener("storage", fetchLocalProjects); + } + }); + if (user.state !== AuthState.SignedIn) { + window.addEventListener("storage", fetchLocalProjects); + } + }, []); + useEffect(() => { + if (routeMatch) { + const { + params: { username, projectPermalink, notePermalink }, + } = routeMatch; + if (history.action === "POP") { + if (!app.isLoading) { + if (user.state === AuthState.SignedIn) { + if (username && projectPermalink) { + const allProjects = Object.values({ + ...projects.owned, + ...projects.assigned, + }); + const reqUserProjects = allProjects.filter( + (x) => x.owner === username + ); + if (reqUserProjects.length > 0) { + const reqProject = reqUserProjects.filter( + (x) => x.permalink === projectPermalink + )[0]; + if (reqProject) { + dispatch(appActions.handleSetProject(reqProject.id, false)); + if (notePermalink) { + dispatch(notesActions.handleFetchNotes(reqProject.id)).then( + (notes) => { + const reqNote = Object.values(notes).filter( + (x) => + x.permalink === parseInt(params.notePermalink, 10) + )[0]; + if (reqNote) { + dispatch(appActions.handleSetNote(reqNote.id, false)); + } + } + ); + } else { + history.replace(`/${username}/${projectPermalink}`); + } + } else { + history.replace("/"); + } + } else { + history.replace("/"); + } + } + } else { + if (projectPermalink) { + const reqProject = Object.values(projects.owned).filter( + (x) => x.permalink === projectPermalink + )[0]; + if (reqProject) { + dispatch(appActions.handleSetProject(reqProject.id, false)); + } else { + history.replace("/"); + } + } + } + } + } + } + }, [routeMatch, app, user]); + const MyCustomToast = ({ children, ...props }) => ( + +
+
+ {children}
+
+ ); + return ( + + + +
+ } + /> + ( + <> + {app.isLoading ? ( + + ) : ( +
+ + + +
+ )} + + )} + /> +
+
+ ); +}; + +export default connect((state) => ({ + user: state.user, + projects: state.projects, + notes: state.notes, + app: state.app, +}))(App); diff --git a/NotesPanel.js b/NotesPanel.js new file mode 100644 index 00000000..8d879b49 --- /dev/null +++ b/NotesPanel.js @@ -0,0 +1,218 @@ +import { + sortableContainer, + sortableElement, + sortableHandle, +} from "react-sortable-hoc"; +import styledComponents from "styled-components"; +import { connect } from "react-redux"; +import parseLinkedList from "../utils/parseLinkedList"; +import handlerIcon from "../assets/apps.svg"; +import TaskItem from "./TaskItem"; +import NewTask from "./NewTask"; +import ShareBtn from "./ShareBtn"; +import ProjectNotSelected from "./ProjectNotSelected"; +import * as notesActions from "../actions/notes"; +import PasteBtn from "./PasteBtn"; +import * as projectsActions from "../actions/projects"; +import { initProjectState, OK, PENDING, initNoteState } from "../constants"; +import useWindowSize from "../utils/useWindowSize"; +import { useToasts } from 'react-toast-notifications' + + +const DragHandle = sortableHandle(() => ( + item handler +)); + +const SortableItem = sortableElement( + ({ index, value, readOnly, setHideShowSidePanel }) => ( + } + /> + ) +); + +const SortableContainer = sortableContainer(({ children }) => { + return
{children}
; +}); + +const NotesPanel = (props) => { + const { addToast } = useToasts(); + const { + app, + notes, + projects, + dispatch, + setHideShowProjectPanel, + setHideShowSidePanel, + } = props; + let { width } = useWindowSize(); + const onSortEnd = ({ oldIndex, newIndex }) => { + if (oldIndex > newIndex) { + const sortedNotes = parseLinkedList(notes, "prevNote", "nextNote"); + dispatch( + notesActions.handleUpdateNote({ + id: sortedNotes[oldIndex].id, + prevNote: sortedNotes[newIndex - 1]?.id || null, + nextNote: sortedNotes[newIndex]?.id || null, + }) + ); + } else if (oldIndex < newIndex) { + const sortedNotes = parseLinkedList(notes, "prevNote", "nextNote"); + dispatch( + notesActions.handleUpdateNote({ + id: sortedNotes[oldIndex].id, + prevNote: sortedNotes[newIndex]?.id || null, + nextNote: sortedNotes[newIndex + 1]?.id || null, + }) + ); + } + }; + return ( + + { + e.target.getAttribute("name") === "NotesPanelContainer" && + Object.keys(projects.owned).includes(app.selectedProject) && + app.noteAddingStatus === OK && + dispatch( + notesActions.handleCreateNote( + initNoteState( + app.selectedProject, + parseLinkedList(notes, "prevNote", "nextNote").reverse()[0]?.id + ) + ) + ) + }} + onKeyDown={(e) => { + if (e.code === "Enter") { + addToast('added a note') + } + }} + > + {app.selectedProject ? ( + <> + + + {Object.keys(projects.owned).includes(app.selectedProject) && ( + + )} + + { + { ...projects.owned, ...projects.assigned }[app.selectedProject] + .title + } + + + {parseLinkedList(notes, "prevNote", "nextNote").map( + (value, index) => ( + + ) + )} + + + ) : ( + <> + + {width <= 768 && ( + + + app.projectAddingStatus === OK && + dispatch( + projectsActions.handleCreateProject( + initProjectState( + parseLinkedList( + projects["owned"], + "prevProject", + "nextProject" + ).reverse()[0]?.id + ) + ) + ) + } + > + + + + + )} + + )} + + ); +}; + +const NotesPanelContainer = styledComponents.div` + flex: 3; + padding: 40px; + overflow: auto; + max-height: calc(100vh - 80px); +`; + +const NotesTitle = styledComponents.div` + font-weight: 600; + margin: 0 8px 20px 8px; + font-size: 2em; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +`; + +const Button = styledComponents.button` +background: none; +border: none; +font-size: 14px; +font-weight: 600; +display: none; +@media only screen and (max-width: 768px) { +display: block; +} +`; + +const ProjectAdder = styledComponents.div` + & > span { + height: 20px; + float: right; + font-size: 1.5em; + width: 20px; + display: flex; + justify-content: center; + align-items: center; + padding: 5px; + border-radius: 4px; + font-weight: bold; + transition: background-color 0.3s; + ${({ isInactive }) => + isInactive + ? ` + color: #D3D3D3; + ` + : ` + cursor: pointer; + color: #222222; + &:hover { + background-color: #E4E4E2; + } + `} + } +`; + +export default connect((state) => ({ + notes: state.notes, + app: state.app, + projects: state.projects, +}))(NotesPanel); diff --git a/ProjectsPanel.js b/ProjectsPanel.js new file mode 100644 index 00000000..cea6adfa --- /dev/null +++ b/ProjectsPanel.js @@ -0,0 +1,280 @@ +import { useState, useEffect } from "react"; +import styledComponents from "styled-components"; +import { + sortableContainer, + sortableElement, + sortableHandle, +} from "react-sortable-hoc"; +import { connect } from "react-redux"; +import * as projectsActions from "../actions/projects"; +import ProjectItem from "./ProjectItem"; +import { AuthState } from "@aws-amplify/ui-components"; +import { initProjectState, OK, PENDING } from "../constants"; +import parseLinkedList from "../utils/parseLinkedList"; +import useWindowSize from "../utils/useWindowSize"; + +const DragHandle = sortableHandle(() => ); + +const SortableItem = sortableElement(({ index, value }) => ( + } /> +)); + +const SortableContainer = sortableContainer(({ children }) => { + return
{children}
; +}); + +const ProjectsPanel = (props) => { + const { + user, + app, + projects, + dispatch, + setHideShowProjectPanel, + projectPanel, + } = props; + let { width } = useWindowSize(); + const [scope, setScope] = useState("owned"); + const onSortEnd = ({ oldIndex, newIndex }) => { + if (oldIndex > newIndex) { + const sortedProjects = parseLinkedList( + projects["owned"], + "prevProject", + "nextProject" + ); + dispatch( + projectsActions.handleUpdateProject({ + id: sortedProjects[oldIndex].id, + prevProject: sortedProjects[newIndex - 1]?.id || null, + nextProject: sortedProjects[newIndex]?.id || null, + }) + ); + } else if (oldIndex < newIndex) { + const sortedProjects = parseLinkedList( + projects["owned"], + "prevProject", + "nextProject" + ); + dispatch( + projectsActions.handleUpdateProject({ + id: sortedProjects[oldIndex].id, + prevProject: sortedProjects[newIndex]?.id || null, + nextProject: sortedProjects[newIndex + 1]?.id || null, + }) + ); + } + }; + const loadDataOnlyOnce = async () => { + try { + if ( + app.projectAddingStatus === OK && + Object.keys(projects.owned).length === 0 + ) { + let initProject = initProjectState( + parseLinkedList( + projects["owned"], + "prevProject", + "nextProject" + ).reverse()[0]?.id + ); + await dispatch(projectsActions.handleCreateProject(initProject)); + await dispatch(appActions.handleSetProject(initProject.id)); + } + } catch (err) { + console.log("err when initial project", err); + } + }; + useEffect(() => { + loadDataOnlyOnce(); + }, []); + + return ( + + {width <= 768 && ( + + )} + {user.state === AuthState.SignedIn && ( + +
+ scope !== "owned" && setScope("owned")} + > + Owned + + scope !== "assigned" && setScope("assigned")} + > + Assigned + +
+
+ )} + + {scope === "assigned" ? ( + <> + {Object.values(projects[scope]).map((project) => ( + + ))} + + ) : ( + + {parseLinkedList(projects[scope], "prevProject", "nextProject").map( + (value, index) => ( + + ) + )} + + )} + + {scope === "owned" && ( + + + app.projectAddingStatus === OK && + dispatch( + projectsActions.handleCreateProject( + initProjectState( + parseLinkedList( + projects["owned"], + "prevProject", + "nextProject" + ).reverse()[0]?.id + ) + ) + ) + } + > + + + + + )} +
+ ); +}; + +const Button = styledComponents.button` +background: none; +border: none; +font-size: 14px; +font-weight: 600; +position: absolute; +top: 1%; +right: 4px; +`; + +const Panel = styledComponents.div` + background-color: #FFFFFF; + flex: 2; + height: 100vh; + display: flex; + flex-direction: column; + box-shadow: 0px 0px 8px 1px #dadada; + top: 0; + left: 0; + & > span { + font-weight: 600; + cursor: pointer; + } + @media only screen and (max-width: 768px) { + flex-direction: column; + height: 100vh; + text-align: left; + padding: 0; + position: absolute; + top: 0; + left: 0; + transition: transform 0.3s ease-in-out; + transform: ${({ open }) => (open ? "translateX(0)" : "translateX(-100%)")}; + z-index: 1; + min-width: 300px; + } +`; + +const ProjectHandler = styledComponents.div` + background-color: #00000050; + width: 8px; +`; + +const PanelTabs = styledComponents.div` + display: flex; + justify-content: center; + align-items: center; + height: 90px; + & > div { + display: flex; + flex-direction: row; + gap: 5px; + padding: 5px; + background-color: #BDBDBD; + border-radius: 24px; + height: fit-content; + width: fit-content; + & > span { + border-radius: 24px; + padding: 2px 10px; + font-weight: 600; + display: flex; + justify-content: center; + background-color: transparent; + color: #FFFFFF; + cursor: pointer; + &.active { + color: #222222; + background-color: #FFFFFF; + cursor: default; + } + } + } +`; + +const ProjectItems = styledComponents.div` + overflow-y: auto; + padding: 10px 0; + flex: 1; + & > div:last-child > div { + border-bottom: 1px solid #E4E4E2; + } + + @media only screen and (max-width: 768px) { + padding: 30px 0; + overflow: unset; + } +`; + +const ProjectAdder = styledComponents.div` + padding: 10px; + & > span { + height: 20px; + float: right; + font-size: 1.5em; + width: 20px; + display: flex; + justify-content: center; + align-items: center; + padding: 5px; + border-radius: 4px; + font-weight: bold; + transition: background-color 0.3s; + ${({ isInactive }) => + isInactive + ? ` + color: #D3D3D3; + ` + : ` + cursor: pointer; + color: #222222; + &:hover { + background-color: #E4E4E2; + } + `} + } +`; + +export default connect((state) => ({ + user: state.user, + app: state.app, + projects: state.projects, +}))(ProjectsPanel); diff --git a/SidePanel.js b/SidePanel.js new file mode 100644 index 00000000..071a7425 --- /dev/null +++ b/SidePanel.js @@ -0,0 +1,363 @@ +import { connect } from "react-redux"; +import { DatePicker } from "./DatePicker"; +import * as notesActions from "../actions/notes"; +import { AuthState } from "@aws-amplify/ui-components"; +import styledComponents from "styled-components"; +import ShareBtn from "./ShareBtn"; +import { Select } from "./Select"; +import { Tag } from "./Tag"; +import Comments from "./Comments"; +import "draft-js/dist/Draft.css"; +import AssigneeField from "./AssigneeField"; +import useWindowSize from "../utils/useWindowSize"; +import { useToasts } from 'react-toast-notifications' +const SidePanel = (props) => { + const { addToast } = useToasts(); + const { + user, + notes, + app, + readOnly, + dispatch, + sidePanel, + setHideShowSidePanel, + } = props; + let { width } = useWindowSize(); + const handleChange = (e) => { + dispatch( + notesActions.handleUpdateNote({ + id: app.selectedNote, + [e.target.name]: e.target.value, + }) + ); + }; + return ( + + {width <= 768 && ( + + )} + {app.selectedNote && ( + <> + e.preventDefault()}> + + { + if (e.code === "Enter") { + addToast('added a note') + } + }} + type="text" + name="note" + placeholder="Note…" + onChange={handleChange} + value={notes[app.selectedNote].note || ""} + contentEditable={false} + readOnly={readOnly} + > + {user.state === AuthState.SignedIn && ( +
+ + +
+ )} +
+ + { + if (e.code === "Enter") { + addToast('added a task') + } + }} + type="text" + name="task" + placeholder="task" + onChange={handleChange} + value={notes[app.selectedNote].task || ""} + contentEditable={false} + readOnly={readOnly} + > +
+
+ + { + if (e.code === "Enter") { + addToast('added a description') + } + }} + type="text" + name="description" + placeholder="description" + onChange={handleChange} + value={notes[app.selectedNote].description || ""} + contentEditable={false} + readOnly={readOnly} + > +
+
+ + { + if (e.code === "Enter") { + addToast('added steps') + } + }} + type="text" + name="steps" + placeholder="steps" + onChange={handleChange} + value={notes[app.selectedNote].steps || ""} + contentEditable={false} + readOnly={readOnly} + > +
+
+ + { + if (e.code === "Enter") { + addToast('added a note') + } + }} + name="due" + onChange={handleChange} + placeholder="due" + value={notes[app.selectedNote].due} + readOnly={readOnly} + /> +
+
+ + { + if (e.code === "Enter") { + addToast('added a watcher') + } + }} + type="text" + name="watcher" + placeholder="watcher" + onChange={handleChange} + value={notes[app.selectedNote].watcher || ""} + contentEditable={false} + readOnly={readOnly} + > +
+
+ + { + if (e.code === "Enter") { + addToast('tagged something') + } + }} + name="tag" + onChange={handleChange} + value={notes[app.selectedNote].tag || []} + readOnly={readOnly} + /> +
+
+ + { + if (e.code === "Enter") { + addToast('added this task to a sprint') + } + }} + type="text" + name="sprint" + placeholder="sprint" + onChange={handleChange} + value={notes[app.selectedNote].sprint || ""} + contentEditable={false} + readOnly={readOnly} + > +
+
+ + + + {user.state === AuthState.SignedIn && } + + )} + + ); +}; + +const Panel = styledComponents.div` + background-color: #FFFFFF; + height: 100vh; + flex: 3; + box-shadow: 0px 0px 8px 1px #dadada; + top: 0; + left: 0; + display: flex; + flex-direction: column; + + @media only screen and (max-width: 768px) { + flex-direction: column; + height: 100vh; + width: 100vw; + text-align: left; + position: absolute; + top: 0; + left: 0; + transition: transform 0.3s ease-in-out; + transform: ${({ open }) => (open ? "translateX(0)" : "translateX(-100%)")}; + z-index: 1; + box-shadow: unset; + } +`; + +const DetailsForm = styledComponents.form` + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + padding: 30px; + .ant-picker { + font-size: 12px; + width: 120px; + border: 1px solid transparent; + border-radius: 4px; + &:focus, &:hover { + border: 1px solid #d9d9d9; + } + } + @media only screen and (max-width: 768px) { + margin-top: 40px; + display: flex; + flex-direction: column; + gap: 16px; + overflow: unset; + padding: unset; + & > input { + width: unset; + padding: 0 20px; + } + } + & > h2 > span { + cursor: pointer; + } + & > input { + border: 0.5px solid transparent; + border-radius: 4px; + padding: 4px 8px; + width: 100%; + font-weight: 600; + font-size: 24px; + transition: border 0.3s, box-shadow 0.3s; + &:hover { + border: 0.5px solid #9198a1; + } + &:focus { + border: 0.5px solid #6F7782; + box-shadow: 0 0 0 2px rgb(24 144 255 / 20%); + } + } + @media only screen and (max-width: 768px) { + & > input { + width: auto; + margin: 0 20px; + } + } + & > div { + display: flex; + width: 100%; + flex-direction: row; + align-items: center; + gap: 20px; + & > label { + color: #6F7782; + margin-bottom: 0; + width: 150px; + font-size: 12px; + } + & > input { + border: 0.5px solid transparent; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + transition: border 0.3s, box-shadow 0.3s; + &:hover { + border: 0.5px solid #9198a1; + } + &:focus { + border: 0.5px solid #6F7782; + box-shadow: 0 0 0 2px rgb(24 144 255 / 20%); + } + } + } + + @media only screen and (max-width: 768px) { + & > div { + align-items: initial; + width: auto; + flex-direction: column; + padding: 0 20px; + gap: unset; + & > label { + color: #6F7782; + margin-bottom: 0; + font-size: 14px; + width: auto; + font-weight: 600; + } + & > input { + border: 0.5px solid transparent; + border-radius: 4px; + padding: 14px 10px; + font-size: 14px; + transition: border 0.3s, box-shadow 0.3s; + border: 0.5px solid #6F7782; + margin-top: 5px; + &:hover { + border: 0.5px solid #9198a1; + } + &:focus { + border: 0.5px solid #6F7782; + box-shadow: 0 0 0 2px rgb(24 144 255 / 20%); + } + } + } + } + & > input[type="submit"] { + display: none; + } +`; + +const Button = styledComponents.button` +background: none; +border: none; +font-size: 14px; +font-weight: 600; +position: absolute; +top: 4%; +right: 15px; +`; + +export default connect((state) => ({ + user: state.user, + notes: state.notes, + app: state.app, + comments: state.comments, + users: state.users, +}))(SidePanel); diff --git a/Tag.js b/Tag.js new file mode 100644 index 00000000..3024ecfc --- /dev/null +++ b/Tag.js @@ -0,0 +1,98 @@ +import React from 'react'; +import styledComponents from "styled-components" +import { useToasts } from 'react-toast-notifications' + +export const Tag = (props) => { + const { readOnly } = props + const { addToast } = useToasts(); + const handleTagClick = (e) => { + const tag = e.target.innerText; + } + return ( + + {props.value.map(x => ( + + {x} + { + const tagsSet = new Set(props.value || []) + tagsSet.delete(x) + props.onChange({ target: { + value: Array.from(tagsSet), + name: props.name + }}) + }}> + × + + + ))} + {!readOnly && + { + if (e.code === "Enter") { + const tagsSet = new Set(props.value || []) + tagsSet.add(e.target.value) + e.target.value = "" + props.onChange({ target: { + value: Array.from(tagsSet), + name: props.name + }}), addToast(`tagged something`, {appereance: 'info'}) + } + }} /> + } + + ) +} + +const TagContainer = styledComponents.div` + display: block; + width: 50%; +` +const TagItem = styledComponents.span` + padding: 4px 8px; + gap: 5px; + border-radius: 4px; + width: fit-content; + height: fit-content; + font-size: 12px; + border: 1px solid #d9d9d9; + background-color: #fafafa; + margin: 2px; + display: inline-flex; + flex-direction: row; + align-items: center; + & > span { + cursor: pointer; + &:nth-child(1) { + text-decoration: underline; + } + &:nth-child(2) { + border-radius: 100%; + height: 12px; + width: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + &:hover { + background-color: grey; + color: #FFFFFF; + } + } + } +` + +const TagInput = styledComponents.span` + padding: 4px 8px; + border-radius: 4px; + width: calc(50px - 16px); + height: fit-content; + font-size: 12px; + border: 1px solid #d9d9d9; + background-color: #fafafa; + margin: 2px; + & > input { + border: none; + width: 50px; + background-color: #fafafa; + } +` diff --git a/index.css b/index.css new file mode 100644 index 00000000..b08897af --- /dev/null +++ b/index.css @@ -0,0 +1,16 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} +.css-1rjh2i2-Icon{ + background-color: #fff !important; +} \ No newline at end of file