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}
>