diff --git a/components/admin/AddChapter.jsx b/components/admin/AddChapter.jsx index fdafdbc..b0a1c06 100644 --- a/components/admin/AddChapter.jsx +++ b/components/admin/AddChapter.jsx @@ -1,6 +1,7 @@ import { storage, store } from "@fb/client"; import { yupResolver } from "@hookform/resolvers/yup"; import { chapterFormValues, chapterValidator } from "@lib/validators"; +import { refreshPages } from "@services/client"; import axios from "axios"; import { collection, @@ -56,27 +57,12 @@ export default function AddChapter({ onCompleted }) { } }, [selectedStory, setValue]); - const refreshPages = async ({ refreshPassword }) => { - setProcessing("Refreshing Story..."); - try { - await axios.post("/api/revalidate", { - pwd: refreshPassword, - updateType: [`stories/${selectedStory.id}`], - }); - setProcessing("Processing Completed."); - setTimeout(() => { - reset(); - onCompleted(); - }, 500); - } catch (error) { - console.error(error); - } - }; - const addChapterDoc = async (snapshotRef, values) => { setProcessing("Creating Chapter..."); try { const fileUrl = await getDownloadURL(snapshotRef); + + // Payload for creating new chapter const chapter = { author: values.author, excerpt: values.excerpt, @@ -86,29 +72,52 @@ export default function AddChapter({ onCompleted }) { title: values.title, content: fileUrl, }; - const chapterRef = doc( - store, - "stories", - selectedStory.id, - "chapters", - values.chapterId - ); - await setDoc(chapterRef, chapter); - - setProcessing("Updating Story Info..."); - const storyUpdatePayload = { + // Payload for updating story + const storyUpdate = { lastUpdated: Timestamp.fromDate(new Date()), chapterSlugs: [...selectedStory.chapterSlugs, values.chapterId], wip: !values.markCompleted, draft: false, }; if (chapter.order === 1) - storyUpdatePayload.published = Timestamp.fromDate(new Date()); + storyUpdate.published = Timestamp.fromDate(new Date()); + + // New Chapter Reference + const newChapter = doc( + store, + "stories", + selectedStory.id, + "chapters", + values.chapterId + ); + // Previous Chapter Reference + const prevChapter = doc( + store, + "stories", + selectedStory.id, + "chapters", + values.previousChapter + ); + // Story Reference const storyRef = doc(store, "stories", selectedStory.id); - await setDoc(storyRef, storyUpdatePayload, { merge: true }); - setProcessing("Story Updated."); - refreshPages(values); + // Update All in parallel. + await Promise.all([ + setDoc(newChapter, chapter), + setDoc(prevChapter, { nextChapter: values.chapterId }, { merge: true }), + setDoc(storyRef, storyUpdate, { merge: true }), + ]); + + setProcessing("Refreshing Story..."); + await refreshPages(values.refreshPassword, [ + `stories/${selectedStory.id}`, + `stories/${selectedStory.id}/${values.previousChapter}`, + ]); + setProcessing("Processing Completed."); + setTimeout(() => { + reset(); + onCompleted(); + }, 500); } catch (error) { console.error(error); } diff --git a/components/admin/AddPost.jsx b/components/admin/AddPost.jsx index e1047c9..15c2814 100644 --- a/components/admin/AddPost.jsx +++ b/components/admin/AddPost.jsx @@ -70,14 +70,14 @@ export default function AddPost({ onCompleted }) { if (!formValues.draft) post.published = Timestamp.fromDate(new Date()); delete post.postId; - delete post.refreshPassword; // TODO: + delete post.refreshPassword; const docRef = doc(store, "posts", formValues.postId); await setDoc(docRef, post); if (!formValues.draft) { await await axios.post("/api/revalidate", { pwd: formValues.refreshPassword, - updateType: ["posts"], + paths: ["posts"], }); } setProcessing("Completed."); diff --git a/components/admin/Comments.jsx b/components/admin/Comments.jsx index 4f1ed11..02ab2c3 100644 --- a/components/admin/Comments.jsx +++ b/components/admin/Comments.jsx @@ -1,6 +1,7 @@ import { DATE_FORMATS } from "@constants/app"; import { store } from "@fb/client"; -import { IconCheck, IconPoint, IconTrash } from "@tabler/icons"; +import { refreshPages } from "@services/client"; +import { IconCheck, IconLoader3, IconPoint, IconTrash } from "@tabler/icons"; import dayjs from "dayjs"; import { collection, @@ -14,10 +15,33 @@ import { } from "firebase/firestore"; import Link from "next/link"; import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; import styles from "../../styles/modules/Admin.module.scss"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; export default function Comments() { const [comments, setComments] = useState([]); + const [processing, setProcessing] = useState(""); + const [selectedComments, setSelectedComments] = useState([]); + const [workingSet, setWorkingSet] = useState(""); + + const handleSelection = (e) => { + if (e.target.checked) { + setSelectedComments((prev) => [...prev, e.target.value]); + if (!workingSet) { + const commentInfo = comments.find((i) => i.id === e.target.value); + setWorkingSet(`${commentInfo.type}/${commentInfo.target}`); + } + } else { + const remainingComments = selectedComments.filter( + (c) => c !== e.target.value + ); + setSelectedComments(remainingComments); + if (remainingComments.length === 0) setWorkingSet(""); + } + }; + useEffect(() => { const q = query( collection(store, "comments"), @@ -35,14 +59,62 @@ export default function Comments() { }); }, []); - const approveComment = async (cId) => { - const cRef = doc(store, "comments", cId); - await updateDoc(cRef, { approved: true }); + const { + register, + getValues, + setError, + formState: { errors, isValid }, + } = useForm({ + mode: "onBlur", + shouldFocusError: true, + defaultValues: { + refreshPassword: "", + }, + resolver: yupResolver( + yup.object().shape({ + refreshPassword: yup.string().required(), + }) + ), + }); + + const approveComments = async () => { + try { + setProcessing("Approving..."); + if (!getValues("refreshPassword")) { + setError( + "refreshPassword", + { type: "required", message: "Required" }, + { shouldFocus: true } + ); + return; + } + await Promise.all( + selectedComments.map((comment) => { + return updateDoc(doc(store, "comments", comment), { approved: true }); + }) + ); + setProcessing("Refreshing Pages..."); + await refreshPages(getValues("refreshPassword"), [workingSet]); + setSelectedComments([]); + setWorkingSet(""); + } catch (error) { + console.log(error); + } }; - const deleteComment = async (cId) => { - const cRef = doc(store, "comments", cId); - await deleteDoc(cRef); + const deleteComments = async () => { + try { + setProcessing("Deleting..."); + await Promise.all( + selectedComments.map((comment) => { + return deleteDoc(doc(store, "comments", comment)); + }) + ); + setSelectedComments([]); + setWorkingSet(""); + } catch (error) { + console.log(error); + } }; if (!comments.length) @@ -55,50 +127,96 @@ export default function Comments() { ); return ( -
- {comments.map((c) => ( -
-
-
{c.title}
-
-

{c.userName}

-

- {c.email || "---No Email---"} +

+
+ + + + + {workingSet && ( +

+ Selected Comments For:{" "} + {workingSet} +

+ )} + +
+
+ {comments.map((c) => ( +
+
+ +
{c.title}
+
+

{c.userName}

+

+ {c.email || "---No Email---"} +

+
+
+

{c.body || "---No Body---"}

+
+
+

+ {dayjs(c.date).format(DATE_FORMATS.date)} + + + + {c.type === "stories" ? "Story ID: " : "Post ID: "} + + {c.target} +

-

{c.body || "---No Body---"}

-
-
-

- {dayjs(c.date).format(DATE_FORMATS.date)} - - - - {c.type === "stories" ? "Story ID: " : "Post ID: "} - - {c.target} - -

- - -
-
- ))} + ))} +
); } diff --git a/pages/api/revalidate.js b/pages/api/revalidate.js index ea1718e..6f93848 100644 --- a/pages/api/revalidate.js +++ b/pages/api/revalidate.js @@ -28,17 +28,17 @@ export default async function handler(req, res) { return res.status(401).json({ error: "Invalid credentials." }); // check if routes are provided. - const { updateType = [] } = req.body; - if (updateType.length === 0) + const { paths = [] } = req.body; + if (paths.length === 0) return res.status(400).json({ error: "Routes not provided." }); /* MAIN BUSINESS LOGIC */ try { await Promise.all([ res.revalidate("/"), - ...updateType.map((route) => res.revalidate(`/${route}`)), + ...paths.map((route) => res.revalidate(`/${route}`)), ]); - return res.json({ message: `Rebuilt routes: ${updateType.join(", ")}` }); + return res.json({ message: `Rebuilt routes: ${paths.join(", ")}` }); } catch (error) { return res.status(500).json({ error: "Something went wrong, revalidation of the routes failed.", diff --git a/pages/posts/[slug].jsx b/pages/posts/[slug].jsx index e5733d8..e0d98ff 100644 --- a/pages/posts/[slug].jsx +++ b/pages/posts/[slug].jsx @@ -6,8 +6,7 @@ import Subscribe from "@components/Subscribe"; import { APP_TITLE, AVG_READING_SPEED, - DATE_FORMATS, - ISR_INTERVAL, + DATE_FORMATS } from "@constants/app"; import firestore from "@fb/server"; import { useIntersection } from "@hooks/intersection"; @@ -218,6 +217,5 @@ export async function getStaticProps(ctx) { return obj; }), }, - revalidate: ISR_INTERVAL * 24 * 7, // revalidate every 1 week. }; } diff --git a/pages/stories/[slug]/index.jsx b/pages/stories/[slug]/index.jsx index 85289c3..7a859f0 100644 --- a/pages/stories/[slug]/index.jsx +++ b/pages/stories/[slug]/index.jsx @@ -3,7 +3,7 @@ import ContentCardLarge from "@components/ContentCardLarge"; import Markdown from "@components/Markdown"; import Share from "@components/Share"; import Subscribe from "@components/Subscribe"; -import { APP_TITLE, DATE_FORMATS, ISR_INTERVAL } from "@constants/app"; +import { APP_TITLE, DATE_FORMATS } from "@constants/app"; import { useSubscription } from "@context/Subscription"; import firestore from "@fb/server"; import { scrollToRef } from "@lib/utils"; @@ -265,6 +265,5 @@ export async function getStaticProps(ctx) { return obj; }), }, - revalidate: ISR_INTERVAL * 24 * 7, // revalidate every 1 week }; } diff --git a/pages/stories/index.jsx b/pages/stories/index.jsx index c722a21..979696b 100644 --- a/pages/stories/index.jsx +++ b/pages/stories/index.jsx @@ -39,6 +39,7 @@ export default function StoriesList({ stories }) { ); } +// TODO: This will be a client fetch, we'll not use server rendering here. /** @type {import('next').GetStaticProps} */ export async function getStaticProps() { const response = await storiesList(25); diff --git a/services/client.js b/services/client.js index 5f79f46..a5eb64c 100644 --- a/services/client.js +++ b/services/client.js @@ -1,5 +1,6 @@ -import { collection, getDocs, orderBy, query, where } from "firebase/firestore"; import { store } from "@fb/client"; +import axios from "axios"; +import { collection, getDocs, orderBy, query, where } from "firebase/firestore"; /** * Retrieve a list of approved comments for a specific content @@ -21,3 +22,16 @@ export async function commentsList(type, target) { id: doc.id, })); } + +/** + * Calls the own revalidation API to refresh a given list of pages. + * @param {String} refreshPassword password to refresh pages. + * @param {Array.} pagePaths List of paths to refresh + * @returns {import("axios").AxiosResponse} + */ +export function refreshPages(refreshPassword, pagePaths) { + return axios.post("/api/revalidate", { + pwd: refreshPassword, + paths: pagePaths, + }); +} diff --git a/styles/modules/Admin.module.scss b/styles/modules/Admin.module.scss index bed7cfe..c9616a3 100644 --- a/styles/modules/Admin.module.scss +++ b/styles/modules/Admin.module.scss @@ -68,23 +68,9 @@ border-radius: 0.5rem; background-color: lighten($color: $dark, $amount: 5); padding: 0.75rem; - &__approve { - display: flex; - justify-content: center; - align-items: center; - padding: 0.3rem 0.5rem; - color: $light; - border: none; - background-color: transparent; - border-radius: 0.3rem; - transition: all 0.15s ease-in-out; - &:hover { - background-color: lighten($color: $dark, $amount: 25); - } - } } -.preview { +.preview, .comments { position: relative; &__input { display: none; @@ -94,6 +80,7 @@ top: 0; padding: 0.7rem 1.25rem; border-bottom: 1px solid rgba($color: $light, $alpha: 0.1); + z-index: 1; } } diff --git a/utils/validators.js b/utils/validators.js index 7a3e915..72d5a24 100644 --- a/utils/validators.js +++ b/utils/validators.js @@ -16,6 +16,21 @@ export const storyFormValues = { chapterSlugs: [], }; + +/** + * The complete Triforce, or one or more components of the Triforce. + * @typedef {Object} ChapterFormValues + * @property {string} author + * @property {string} excerpt + * @property {string} chapterId + * @property {number} order + * @property {string} previousChapter + * @property {string} nextChapter + * @property {string} title + * @property {File} file + * @property {boolean} markCompleted + * @property {string} refreshPassword + */ export const chapterFormValues = { author: "", excerpt: "", @@ -267,4 +282,4 @@ export const messageValidator = yup.object().shape({ .required("Message is required") .min(20, "Message should be between 20-1024 characters in length") .max(1024, "Message should be between 20-1024 characters in length"), -}); +}); \ No newline at end of file