diff --git a/apps/backend/.lintstagedrc b/apps/backend/.lintstagedrc index 374aa6e2c..1be163e7d 100644 --- a/apps/backend/.lintstagedrc +++ b/apps/backend/.lintstagedrc @@ -1,3 +1,4 @@ { - "**/*.{js,jsx,ts,tsx}": "eslint" + "**/*.{js,jsx,ts,tsx}": "eslint", + "*": "prettier --ignore-unknown --write" } diff --git a/apps/frontend/.lintstagedrc b/apps/frontend/.lintstagedrc index 374aa6e2c..1be163e7d 100644 --- a/apps/frontend/.lintstagedrc +++ b/apps/frontend/.lintstagedrc @@ -1,3 +1,4 @@ { - "**/*.{js,jsx,ts,tsx}": "eslint" + "**/*.{js,jsx,ts,tsx}": "eslint", + "*": "prettier --ignore-unknown --write" } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 0c96f4831..f529819b1 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -47,6 +47,7 @@ "react-infinite-scroll-component": "^6.1.0", "slugify": "^1.6.6", "tiptap-markdown": "^0.8.2", + "use-debounce": "^10.0.0", "uuid": "^9.0.1", "yup": "^1.3.2" }, diff --git a/apps/frontend/src/components/post-form.jsx b/apps/frontend/src/components/post-form.jsx index 4ff438343..65adad07e 100644 --- a/apps/frontend/src/components/post-form.jsx +++ b/apps/frontend/src/components/post-form.jsx @@ -2,7 +2,12 @@ import { useCallback, useEffect, useState } from "react"; import Tiptap from "@/components/tiptap"; import EditorDrawer from "@/components/editor-drawer"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faChevronLeft, faEdit } from "@fortawesome/free-solid-svg-icons"; +import { + faChevronLeft, + faCircleCheck, + faCircleXmark, + faEdit, +} from "@fortawesome/free-solid-svg-icons"; import { v4 as uuidv4 } from "uuid"; import slugify from "slugify"; import { @@ -14,6 +19,9 @@ import { Stack, FormControl, FormErrorMessage, + Divider, + chakra, + Spinner, } from "@chakra-ui/react"; import { Field, Form, Formik } from "formik"; import { updatePost } from "@/lib/posts"; @@ -22,6 +30,9 @@ import NextLink from "next/link"; import { useRouter } from "next/router"; import { isEditor } from "@/lib/current-user"; import ScheduleMenu from "./schedule-menu"; +import { useDebouncedCallback } from "use-debounce"; + +const Icon = chakra(FontAwesomeIcon); const PostForm = ({ tags, user, authors, post }) => { const toast = useToast(); @@ -43,6 +54,16 @@ const PostForm = ({ tags, user, authors, post }) => { const [featureImage, setFeatureImageUrl] = useState(); const [featureImageId, setFeatureImageId] = useState(); + const [saveStatus, setSaveStatus] = useState(null); + const [postStatus, setPostStatus] = useState(null); + + const debouncedContentSave = useDebouncedCallback( + () => { + handleSubmit({ isAutoSave: true }); + }, + 3000, + { maxWait: 5000 }, + ); useEffect(() => { if (post) { @@ -63,6 +84,14 @@ const PostForm = ({ tags, user, authors, post }) => { ); setFeatureImageId(feature_image.data.id); } + + if (post.attributes.publishedAt != null) { + setPostStatus("published"); + } else if (post.attributes.scheduled_at != null) { + setPostStatus("scheduled"); + } else { + setPostStatus("draft"); + } } }, [post]); @@ -97,7 +126,13 @@ const PostForm = ({ tags, user, authors, post }) => { }; const handleSubmit = useCallback( - async (shouldPublish = null, scheduledDate = "", scheduledTime = "") => { + async ({ + shouldPublish = null, + scheduledDate = "", + scheduledTime = "", + isAutoSave = false, + } = {}) => { + setSaveStatus("saving"); const nonce = uuidv4(); const token = user.jwt; @@ -146,27 +181,34 @@ const PostForm = ({ tags, user, authors, post }) => { if (shouldPublish === "unpublished") { data.data.publishedAt = null; data.data.scheduled_at = null; + setPostStatus("draft"); } if (shouldPublish === "later") { data.data.scheduled_at = handleSchedule(); + setPostStatus("scheduled"); } if (shouldPublish == "now") { data.data.publishedAt = new Date().toISOString(); data.data.scheduled_at = null; + setPostStatus("published"); } try { await updatePost(postId, data, token); - toast({ - title: getTitle(), - description: `The post ${ - shouldPublish != null ? "status" : "" - } has been updated.`, - status: "success", - duration: 5000, - isClosable: true, - }); + if (!isAutoSave) { + toast({ + title: getTitle(), + description: `The post ${ + shouldPublish != null ? "status" : "" + } has been updated.`, + status: "success", + duration: 5000, + isClosable: true, + }); + } + debouncedContentSave.cancel(); + setSaveStatus("saved"); setUnsavedChanges(false); } catch (error) { toast({ @@ -176,6 +218,7 @@ const PostForm = ({ tags, user, authors, post }) => { duration: 5000, isClosable: true, }); + setSaveStatus("error"); } }, [ @@ -188,6 +231,7 @@ const PostForm = ({ tags, user, authors, post }) => { author, postId, user, + debouncedContentSave, ], ); @@ -239,6 +283,10 @@ const PostForm = ({ tags, user, authors, post }) => { function handleContentChange(content) { setContent(content); + if (postStatus === "draft") { + // Enable auto save only for drafts + debouncedContentSave(content); + } if (!unsavedChanges) { setUnsavedChanges(true); @@ -265,7 +313,32 @@ const PostForm = ({ tags, user, authors, post }) => { Posts - + + {saveStatus !== null && ( + <> + + {saveStatus === "saved" && ( + <> + + Saved + + )} + {saveStatus === "saving" && ( + <> + + Saving... + + )} + {saveStatus === "error" && ( + <> + + Error + + )} + + + + )} {isEditor(user) && ( )} diff --git a/apps/frontend/src/components/schedule-menu.jsx b/apps/frontend/src/components/schedule-menu.jsx index 62d72d081..0d4dfb4ad 100644 --- a/apps/frontend/src/components/schedule-menu.jsx +++ b/apps/frontend/src/components/schedule-menu.jsx @@ -165,7 +165,7 @@ const PublishedMenu = ({ setScheduleOption("now"); } - handleSubmit(scheduleOption); + handleSubmit({ shouldPublish: scheduleOption }); onClose(); }} mr="1rem" @@ -283,7 +283,11 @@ const NotPublishedMenu = ({ setScheduleOption("now"); } - handleSubmit(scheduleOption, scheduledDate, scheduledTime); + handleSubmit({ + shouldPublish: scheduleOption, + scheduledDate, + scheduledTime, + }); onClose(); }} isDisabled={ diff --git a/apps/frontend/src/components/tip-tap-extensions/heading-extension.jsx b/apps/frontend/src/components/tip-tap-extensions/heading-extension.jsx new file mode 100644 index 000000000..82ef858ed --- /dev/null +++ b/apps/frontend/src/components/tip-tap-extensions/heading-extension.jsx @@ -0,0 +1,53 @@ +import BaseHeading from "@tiptap/extension-heading"; +import slugify from "slugify"; + +export const Heading = BaseHeading.configure({ + levels: [1, 2, 3, 4, 5, 6], +}).extend({ + addAttributes() { + return { + ...this.parent?.(), + id: { + default: null, + parseHTML: (element) => ({ + id: slugify(element.textContent, { + lower: true, + }), + }), + renderHTML: (attributes) => ({ + id: attributes.id, + }), + }, + }; + }, + + onSelectionUpdate({ editor }) { + const { $from } = editor.state.selection; + + // This line gets the node at the depth of + // the start of the selection. If the selection + // starts in a heading, this will be the heading node. + + const node = $from.node($from.depth); + + if (node.type.name === "heading") { + editor.commands.updateAttributes("heading", { + id: slugify(node.textContent, { + lower: true, + }), + }); + } + }, + + renderHTML({ node }) { + return [ + "h" + node.attrs.level, + { + id: slugify(node.textContent, { + lower: true, + }), + }, + 0, + ]; + }, +}); diff --git a/apps/frontend/src/components/tiptap.jsx b/apps/frontend/src/components/tiptap.jsx index 303f8f33e..902c2772a 100644 --- a/apps/frontend/src/components/tiptap.jsx +++ b/apps/frontend/src/components/tiptap.jsx @@ -7,6 +7,8 @@ import { MenuList, Text, useDisclosure, + useColorMode, + useColorModeValue } from "@chakra-ui/react"; import { autoUpdate, @@ -244,6 +246,7 @@ function ToolBar({ editor, user }) { } function BubbleMenuBar({ editor, isLinkHover }) { + const menuBgColor = useColorModeValue("white", "gray.700"); const addLink = () => { const url = window.prompt("URL"); @@ -263,7 +266,7 @@ function BubbleMenuBar({ editor, isLinkHover }) { borderRadius="lg" overflowX="auto" id="bubble-menu" - background="white" + background={menuBgColor} >