diff --git a/gatsby-browser.js b/gatsby-browser.js index b6aa7cd0..c57786c5 100644 --- a/gatsby-browser.js +++ b/gatsby-browser.js @@ -2,10 +2,12 @@ import { createMuiTheme, MuiThemeProvider } from "@material-ui/core"; import "firebase/analytics"; import "firebase/auth"; import "firebase/database"; +import "firebase/storage"; import React from "react"; import { DatabaseProvider } from "./src/contexts/DatabaseContext"; import { ModalProvider } from "./src/contexts/ModalContext"; import { ResumeProvider } from "./src/contexts/ResumeContext"; +import { StorageProvider } from "./src/contexts/StorageContext"; import { TemplateProvider } from "./src/contexts/TemplateContext"; import { ThemeProvider } from "./src/contexts/ThemeContext"; import { UserProvider } from "./src/contexts/UserContext"; @@ -29,7 +31,9 @@ export const wrapRootElement = ({ element }) => ( - {element} + + {element} + diff --git a/gatsby-config.js b/gatsby-config.js index 1fd56f8c..fbdcf92a 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -2,6 +2,7 @@ require("dotenv").config(); module.exports = { plugins: [ + `gatsby-plugin-react-helmet`, { resolve: `gatsby-plugin-prefetch-google-fonts`, options: { @@ -24,7 +25,6 @@ module.exports = { }, }, `gatsby-plugin-lodash`, - `gatsby-plugin-react-helmet`, { resolve: `gatsby-plugin-manifest`, options: { diff --git a/gatsby-ssr.js b/gatsby-ssr.js index d6039e77..b29ef852 100644 --- a/gatsby-ssr.js +++ b/gatsby-ssr.js @@ -1,3 +1,4 @@ import "firebase/analytics"; import "firebase/auth"; import "firebase/database"; +import "firebase/storage"; diff --git a/package-lock.json b/package-lock.json index 256982b2..90c935ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3552,6 +3552,11 @@ "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=" }, + "array-move": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-move/-/array-move-2.2.2.tgz", + "integrity": "sha512-lKc6C+nsOSA1o7eHSP/HshlGDYUI7QKyaus5kPDm2zEEPQID9xlspnraLR8l+rDlqg9mGo8ziE7F8TEnF6D3Tw==" + }, "array-reduce": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", @@ -9469,6 +9474,11 @@ } } }, + "gatsby-source-gravatar": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gatsby-source-gravatar/-/gatsby-source-gravatar-1.0.0.tgz", + "integrity": "sha512-pN48dBm1oinhhrtypdOOgRu0nO3ZPARdZYKdTwIm8Pyfql1BbHUHwLfYf8E14SFkthHkUnXLh63q/qiQIGvqbw==" + }, "gatsby-telemetry": { "version": "1.3.18", "resolved": "https://registry.npmjs.org/gatsby-telemetry/-/gatsby-telemetry-1.3.18.tgz", diff --git a/package.json b/package.json index 8a0bb08d..285bb821 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@material-ui/core": "^4.11.0", + "array-move": "^2.2.2", "classnames": "^2.2.6", "firebase": "^7.15.5", "formik": "^2.1.4", @@ -30,6 +31,7 @@ "gatsby-plugin-react-helmet": "^3.3.9", "gatsby-plugin-sharp": "^2.6.18", "gatsby-source-filesystem": "^2.3.18", + "gatsby-source-gravatar": "^1.0.0", "gatsby-transformer-sharp": "^2.5.10", "lodash": "^4.17.15", "moment": "^2.27.0", diff --git a/src/components/builder/center/Artboard.js b/src/components/builder/center/Artboard.js index 937b4806..3033804c 100644 --- a/src/components/builder/center/Artboard.js +++ b/src/components/builder/center/Artboard.js @@ -1,4 +1,5 @@ import React, { useContext } from "react"; +import { Helmet } from "react-helmet"; import ResumeContext from "../../../contexts/ResumeContext"; import TemplateContext from "../../../contexts/TemplateContext"; import Onyx from "../../../templates/Onyx"; @@ -9,8 +10,18 @@ const Artboard = () => { const { state } = useContext(ResumeContext); return ( -
- +
+ + {state.name} | Reactive Resume + + + +
+ +
); }; diff --git a/src/components/builder/left/LeftSidebar.js b/src/components/builder/left/LeftSidebar.js index 8be1a5a5..bc7c7f7b 100644 --- a/src/components/builder/left/LeftSidebar.js +++ b/src/components/builder/left/LeftSidebar.js @@ -1,9 +1,12 @@ -import React, { Fragment } from "react"; +import React, { Fragment, useContext } from "react"; +import ResumeContext from "../../../contexts/ResumeContext"; import sections from "../../../data/leftSections"; import LeftNavbar from "./LeftNavbar"; import styles from "./LeftSidebar.module.css"; const LeftSidebar = () => { + const { state } = useContext(ResumeContext); + return (
@@ -11,7 +14,7 @@ const LeftSidebar = () => {
{sections.map(({ id, component: Component }) => ( - +
))} diff --git a/src/components/builder/lists/EmptyList.js b/src/components/builder/lists/EmptyList.js new file mode 100644 index 00000000..05752dfd --- /dev/null +++ b/src/components/builder/lists/EmptyList.js @@ -0,0 +1,9 @@ +import React from "react"; + +const EmptyList = () => ( +
+ This list is empty. +
+); + +export default EmptyList; diff --git a/src/components/builder/lists/List.js b/src/components/builder/lists/List.js new file mode 100644 index 00000000..962740c8 --- /dev/null +++ b/src/components/builder/lists/List.js @@ -0,0 +1,26 @@ +import { isEmpty } from "lodash"; +import React from "react"; +import { MdAdd } from "react-icons/md"; +import Button from "../../shared/Button"; +import EmptyList from "./EmptyList"; +import styles from "./List.module.css"; + +const List = ({ items, onAdd, children }) => { + return ( +
+
+ {isEmpty(items) ? : children} +
+ +
+ ); +}; + +export default List; diff --git a/src/components/builder/lists/List.module.css b/src/components/builder/lists/List.module.css new file mode 100644 index 00000000..363aab2d --- /dev/null +++ b/src/components/builder/lists/List.module.css @@ -0,0 +1,3 @@ +.container { + @apply flex flex-col border border-secondary rounded; +} \ No newline at end of file diff --git a/src/components/builder/lists/small/SmallListItem.js b/src/components/builder/lists/small/SmallListItem.js new file mode 100644 index 00000000..e9baf44a --- /dev/null +++ b/src/components/builder/lists/small/SmallListItem.js @@ -0,0 +1,103 @@ +import { Menu, MenuItem } from "@material-ui/core"; +import React, { useContext, useState } from "react"; +import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; +import { MdMoreVert } from "react-icons/md"; +import ResumeContext from "../../../../contexts/ResumeContext"; +import styles from "./SmallListItem.module.css"; + +const SmallListItem = ({ + title, + subtitle, + path, + data, + isFirst, + isLast, + onEdit, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const { dispatch } = useContext(ResumeContext); + + const handleClick = (event) => setAnchorEl(event.currentTarget); + + const handleClose = () => setAnchorEl(null); + + const handleEdit = () => { + onEdit(); + handleClose(); + }; + + const handleMoveUp = () => { + dispatch({ + type: "on_move_item_up", + payload: { + path, + value: data, + }, + }); + + handleClose(); + }; + + const handleMoveDown = () => { + dispatch({ + type: "on_move_item_down", + payload: { + path, + value: data, + }, + }); + + handleClose(); + }; + + const handleDelete = () => { + dispatch({ + type: "on_delete_item", + payload: { + path, + value: data, + }, + }); + + handleClose(); + }; + + return ( +
+
+ {title} + {subtitle} +
+ +
+ + +
+ + + + + + +
+ Edit + + Delete + +
+
+
+ ); +}; + +export default SmallListItem; diff --git a/src/components/builder/lists/small/SmallListItem.module.css b/src/components/builder/lists/small/SmallListItem.module.css new file mode 100644 index 00000000..699169ec --- /dev/null +++ b/src/components/builder/lists/small/SmallListItem.module.css @@ -0,0 +1,17 @@ +.container { + @apply flex items-center justify-between border-t border-secondary px-8 py-5; +} + +.container:first-child { + @apply border-t-0; +} + +.menu { + @apply opacity-0; + @apply transition-opacity duration-200 ease-in-out; +} + +.container:hover .menu { + @apply opacity-100; + @apply transition-opacity duration-200 ease-in-out; +} diff --git a/src/components/builder/sections/Profile.js b/src/components/builder/sections/Profile.js index c3333b5c..79aa8da3 100644 --- a/src/components/builder/sections/Profile.js +++ b/src/components/builder/sections/Profile.js @@ -1,19 +1,45 @@ -import React from "react"; +import React, { useContext, useRef } from "react"; import { MdFileUpload } from "react-icons/md"; +import StorageContext from "../../../contexts/StorageContext"; +import { handleKeyDown } from "../../../utils"; import Heading from "../../shared/Heading"; import Input from "../../shared/Input"; import styles from "./Profile.module.css"; const Profile = () => { + const fileInputRef = useRef(null); + const { uploadPhotograph } = useContext(StorageContext); + + const handleIconClick = () => { + fileInputRef.current.click(); + }; + + const handleImageUpload = (e) => { + const file = e.target.files[0]; + uploadPhotograph(file); + }; + return (
Profile
-
+
handleKeyDown(e, handleIconClick)} + > +
- +
diff --git a/src/components/builder/sections/Profile.module.css b/src/components/builder/sections/Profile.module.css index d2ac4b0c..40d90b5e 100644 --- a/src/components/builder/sections/Profile.module.css +++ b/src/components/builder/sections/Profile.module.css @@ -2,5 +2,5 @@ width: 60px; height: 60px; flex: 0 0 60px; - @apply flex items-center justify-center bg-secondary text-secondary-dark rounded-full; + @apply flex items-center justify-center cursor-pointer bg-secondary text-secondary-dark rounded-full; } diff --git a/src/components/builder/sections/Social.js b/src/components/builder/sections/Social.js new file mode 100644 index 00000000..ed8b5c86 --- /dev/null +++ b/src/components/builder/sections/Social.js @@ -0,0 +1,40 @@ +import { get } from "lodash"; +import React, { useContext } from "react"; +import ModalContext from "../../../contexts/ModalContext"; +import Heading from "../../shared/Heading"; +import List from "../lists/List"; +import SmallListItem from "../lists/small/SmallListItem"; + +const Social = ({ state }) => { + const { emitter, events } = useContext(ModalContext); + + const path = "social.items"; + const items = get(state, path, []); + + const handleAdd = () => emitter.emit(events.SOCIAL_MODAL); + + const handleEdit = (data) => emitter.emit(events.SOCIAL_MODAL, data); + + return ( +
+ Social Network + + + {items.map((x, i) => ( + handleEdit(x)} + isLast={i === items.length - 1} + /> + ))} + +
+ ); +}; + +export default Social; diff --git a/src/components/builder/sections/SocialNetwork.js b/src/components/builder/sections/SocialNetwork.js deleted file mode 100644 index 0488b7f2..00000000 --- a/src/components/builder/sections/SocialNetwork.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import Heading from "../../shared/Heading"; - -const SocialNetwork = () => { - return ( -
- Social Network -
- ); -}; - -export default SocialNetwork; diff --git a/src/components/dashboard/CreateResume.js b/src/components/dashboard/CreateResume.js index 23d6cc3d..73526461 100644 --- a/src/components/dashboard/CreateResume.js +++ b/src/components/dashboard/CreateResume.js @@ -1,6 +1,7 @@ import React, { useContext } from "react"; import { MdAdd } from "react-icons/md"; import ModalContext from "../../contexts/ModalContext"; +import { handleKeyDown } from "../../utils"; import styles from "./CreateResume.module.css"; const CreateResume = () => { @@ -18,12 +19,12 @@ const CreateResume = () => { role="button" className={styles.page} onClick={handleClick} - onKeyDown={() => {}} + onKeyDown={(e) => handleKeyDown(e, handleClick)} >
-

Create New Resume

+

Create Resume

); diff --git a/src/components/dashboard/ResumePreview.js b/src/components/dashboard/ResumePreview.js index 56bb0d4e..749bdee9 100644 --- a/src/components/dashboard/ResumePreview.js +++ b/src/components/dashboard/ResumePreview.js @@ -64,7 +64,7 @@ const ResumePreview = ({ resume }) => { > Rename - Delete + Delete
diff --git a/src/components/router/PrivateRoute.js b/src/components/router/PrivateRoute.js index a1e06458..1df958dc 100644 --- a/src/components/router/PrivateRoute.js +++ b/src/components/router/PrivateRoute.js @@ -7,7 +7,7 @@ const PrivateRoute = ({ component: Component, location, ...props }) => { const { user, loading } = useContext(UserContext); if (loading) { - return ; + return ; } if (!user) { diff --git a/src/components/shared/Avatar.js b/src/components/shared/Avatar.js index 61f07cc8..e88418d2 100644 --- a/src/components/shared/Avatar.js +++ b/src/components/shared/Avatar.js @@ -1,15 +1,18 @@ import cx from "classnames"; -import React, { useContext } from "react"; +import { toUrl } from "gatsby-source-gravatar"; +import React, { useContext, useMemo } from "react"; import UserContext from "../../contexts/UserContext"; import styles from "./Avatar.module.css"; const Avatar = ({ className }) => { const { user } = useContext(UserContext); + const photoURL = useMemo(() => toUrl(user.email, "size=128"), [user.email]); + return ( {user.displayName} ); diff --git a/src/components/shared/Button.js b/src/components/shared/Button.js index 6ee91653..840f3782 100644 --- a/src/components/shared/Button.js +++ b/src/components/shared/Button.js @@ -1,5 +1,6 @@ import classNames from "classnames"; import React from "react"; +import { handleKeyDown } from "../../utils"; import styles from "./Button.module.css"; const Button = ({ @@ -16,13 +17,11 @@ const Button = ({ [styles.outline]: outline, }); - const handleKeyDown = () => {}; - return (
); diff --git a/src/contexts/DatabaseContext.js b/src/contexts/DatabaseContext.js index 57347a5f..0f45717b 100644 --- a/src/contexts/DatabaseContext.js +++ b/src/contexts/DatabaseContext.js @@ -1,7 +1,6 @@ import firebase from "gatsby-plugin-firebase"; import { debounce } from "lodash"; import React, { createContext, useContext, useEffect, useState } from "react"; -import { v4 as uuidv4 } from "uuid"; import UserContext from "./UserContext"; const defaultState = { @@ -38,27 +37,22 @@ const DatabaseProvider = ({ children }) => { }; const createResume = (resume) => { - const id = uuidv4(); + const { id } = resume; const createdAt = firebase.database.ServerValue.TIMESTAMP; - let firstName = "", - lastName = "", - photograph = ""; + let firstName, lastName; if (!user.isAnonymous) { [firstName, lastName] = user.displayName.split(" "); - photograph = user.photoURL; } firebase .database() .ref(`users/${user.uid}/resumes/${id}`) .set({ - id, profile: { - firstName, - lastName, - photograph, + firstName: firstName || "", + lastName: lastName || "", }, ...resume, createdAt, diff --git a/src/contexts/ModalContext.js b/src/contexts/ModalContext.js index a7c6d3a1..a15c1b78 100644 --- a/src/contexts/ModalContext.js +++ b/src/contexts/ModalContext.js @@ -4,6 +4,7 @@ import React, { createContext } from "react"; const events = { AUTH_MODAL: "auth_modal", CREATE_RESUME_MODAL: "create_resume_modal", + SOCIAL_MODAL: "social_modal", }; const emitter = createNanoEvents(); diff --git a/src/contexts/ResumeContext.js b/src/contexts/ResumeContext.js index a953f829..83bc4870 100644 --- a/src/contexts/ResumeContext.js +++ b/src/contexts/ResumeContext.js @@ -1,26 +1,88 @@ -import { set } from "lodash"; -import React, { createContext, useContext, useReducer } from "react"; +import arrayMove from "array-move"; +import { clone, findIndex, get, setWith } from "lodash"; +import React, { + createContext, + useCallback, + useContext, + useReducer, +} from "react"; import DatabaseContext from "./DatabaseContext"; -const ResumeContext = createContext({}); +const initialState = {}; + +const ResumeContext = createContext(initialState); const ResumeProvider = ({ children }) => { const { debouncedUpdate } = useContext(DatabaseContext); - const [state, dispatch] = useReducer((state, { type, payload }) => { - let newState; + const memoizedReducer = useCallback( + (state, { type, payload }) => { + let newState, index, items; - switch (type) { - case "on_input": - newState = set({ ...state }, payload.path, payload.value); - debouncedUpdate(newState); - return newState; - case "set_data": - return payload; - default: - throw new Error(); - } - }, {}); + switch (type) { + case "on_add_item": + items = get(state, payload.path, []); + newState = setWith( + clone(state), + payload.path, + [...items, payload.value], + clone + ); + debouncedUpdate(newState); + return newState; + + case "on_edit_item": + items = get(state, payload.path); + index = findIndex(items, ["id", payload.value.id]); + newState = setWith( + clone(state), + `${payload.path}[${index}]`, + payload.value, + clone + ); + debouncedUpdate(newState); + return newState; + + case "on_delete_item": + items = get(state, payload.path); + index = findIndex(items, ["id", payload.value.id]); + items.splice(index, 1); + newState = setWith(clone(state), payload.path, items, clone); + debouncedUpdate(newState); + return newState; + + case "on_move_item_up": + items = get(state, payload.path); + index = findIndex(items, ["id", payload.value.id]); + items = arrayMove(items, index, index - 1); + newState = setWith(clone(state), payload.path, items, clone); + debouncedUpdate(newState); + return newState; + + case "on_move_item_down": + items = get(state, payload.path); + index = findIndex(items, ["id", payload.value.id]); + items = arrayMove(items, index, index + 1); + newState = setWith(clone(state), payload.path, items, clone); + debouncedUpdate(newState); + return newState; + + case "on_input": + newState = setWith(clone(state), payload.path, payload.value, clone); + debouncedUpdate(newState); + return newState; + + case "set_data": + return payload; + + default: + throw new Error(); + } + }, + [debouncedUpdate] + ); + + const [state, dispatch] = useReducer(memoizedReducer, initialState); return ( diff --git a/src/contexts/StorageContext.js b/src/contexts/StorageContext.js new file mode 100644 index 00000000..02d97454 --- /dev/null +++ b/src/contexts/StorageContext.js @@ -0,0 +1,85 @@ +import firebase from "gatsby-plugin-firebase"; +import React, { createContext, useContext, useRef } from "react"; +import { toast } from "react-toastify"; +import { isFileImage } from "../utils"; +import ResumeContext from "./ResumeContext"; +import UserContext from "./UserContext"; + +const defaultState = { + uploadPhotograph: async () => {}, +}; + +const StorageContext = createContext(defaultState); + +const StorageProvider = ({ children }) => { + const toastId = useRef(null); + + const { user } = useContext(UserContext); + const { state, dispatch } = useContext(ResumeContext); + + const uploadPhotograph = async (file) => { + if (!isFileImage(file)) { + toast.error( + "You tried to upload a file that was not an image. That won't look good on your resume. Please try again." + ); + return null; + } + + const uploadTask = firebase + .storage() + .ref(`/users/${user.uid}/photographs/${state.id}`) + .put(file); + + let progress = 0; + toastId.current = toast("Firing up engines...", { + progress, + }); + + uploadTask.on( + "state_changed", + (snapshot) => { + progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + toast.update(toastId.current, { + render: "Uploading...", + progress, + hideProgressBar: false, + }); + }, + (error) => toast.error(error), + async () => { + const downloadURL = await uploadTask.snapshot.ref.getDownloadURL(); + dispatch({ + type: "on_input", + payload: { + path: "profile.photograph", + value: downloadURL, + }, + }); + + toast.update(toastId.current, { + render: + "Your photograph was uploaded successfully... and you look great!", + progress, + autoClose: 5000, + hideProgressBar: true, + }); + + toastId.current = null; + } + ); + }; + + return ( + + {children} + + ); +}; + +export default StorageContext; + +export { StorageProvider }; diff --git a/src/contexts/UserContext.js b/src/contexts/UserContext.js index dbae2ef1..179ca73e 100644 --- a/src/contexts/UserContext.js +++ b/src/contexts/UserContext.js @@ -6,13 +6,13 @@ import useAuthState from "../hooks/useAuthState"; const defaultUser = { uid: null, - displayName: null, email: null, - photoURL: null, + displayName: null, isAnonymous: false, }; const defaultState = { + loading: false, user: defaultUser, logout: async () => {}, loginWithGoogle: async () => {}, @@ -32,8 +32,8 @@ const UserProvider = ({ children }) => { useEffect(() => { if (firebaseUser) { const user = pick(firebaseUser, Object.keys(defaultUser)); - setUser(user); localStorage.setItem("user", JSON.stringify(user)); + setUser(user); const addUserToDatabase = async () => { const userRef = firebase.database().ref(`users/${user.uid}`); diff --git a/src/data/leftSections.js b/src/data/leftSections.js index d06a677e..ef522d55 100644 --- a/src/data/leftSections.js +++ b/src/data/leftSections.js @@ -1,7 +1,7 @@ import { AiOutlineTwitter } from "react-icons/ai"; import { MdPerson } from "react-icons/md"; import Profile from "../components/builder/sections/Profile"; -import SocialNetwork from "../components/builder/sections/SocialNetwork"; +import Social from "../components/builder/sections/Social"; export default [ { @@ -14,6 +14,6 @@ export default [ id: "social", name: "Social Network", icon: AiOutlineTwitter, - component: SocialNetwork, + component: Social, }, ]; diff --git a/src/modals/CreateResumeModal.js b/src/modals/CreateResumeModal.js deleted file mode 100644 index eba8089d..00000000 --- a/src/modals/CreateResumeModal.js +++ /dev/null @@ -1,103 +0,0 @@ -import { useFormik } from "formik"; -import React, { useContext, useEffect, useRef, useState } from "react"; -import * as Yup from "yup"; -import Button from "../components/shared/Button"; -import Input from "../components/shared/Input"; -import DatabaseContext from "../contexts/DatabaseContext"; -import ModalContext from "../contexts/ModalContext"; -import { getModalText } from "../utils"; -import BaseModal from "./BaseModal"; - -const CreateResumeSchema = Yup.object().shape({ - name: Yup.string() - .min(5, "Please enter at least 5 characters.") - .required("This is a required field."), -}); - -const CreateResumeModal = () => { - const modalRef = useRef(null); - - const [data, setData] = useState(null); - const [open, setOpen] = useState(false); - const [isEditMode, setEditMode] = useState(false); - - const { emitter, events } = useContext(ModalContext); - const { createResume, updateResume } = useContext(DatabaseContext); - - useEffect(() => { - const unbind = emitter.on(events.CREATE_RESUME_MODAL, (data) => { - setOpen(true); - setData(data); - }); - - return () => unbind(); - }, [emitter, events]); - - const formik = useFormik({ - initialValues: { - name: "", - }, - validationSchema: CreateResumeSchema, - onSubmit: (newData) => { - if (isEditMode) { - if (data !== newData) { - updateResume(newData); - } - } else { - createResume(newData); - } - - modalRef.current.handleClose(); - }, - }); - - useEffect(() => { - data && formik.setValues(data) && setEditMode(true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); - - const submitAction = ( -