diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/articles/index.ts index ba3d1f7b14..00e266cfb3 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/articles/index.ts @@ -90,4 +90,5 @@ export { useArticleCreate, useArticleDestroy, useArticlePartialUpdate, + articleQueries, } diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx index cabd4d98bc..a296f0be6c 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx @@ -12,19 +12,23 @@ const PageContainer = styled.div({ height: "100%", }) +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + export const ArticleDetailPage = ({ articleId }: { articleId: number }) => { - const { - data: article, - isLoading, - isFetching, - } = useArticleDetail(Number(articleId)) + const { data: article, isLoading } = useArticleDetail(Number(articleId)) const showArticleDetail = useFeatureFlagEnabled( FeatureFlags.ArticleEditorView, ) - if (isLoading || isFetching) { - return + if (isLoading) { + return } if (!article || !showArticleDetail) { return notFound() diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx index 3098b936ae..60f2633e95 100644 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx @@ -15,6 +15,14 @@ const PageContainer = styled.div(({ theme }) => ({ height: "100%", })) +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + const ArticleEditPage = ({ articleId }: { articleId: string }) => { const { data: article, @@ -24,7 +32,7 @@ const ArticleEditPage = ({ articleId }: { articleId: string }) => { const router = useRouter() if (isLoading || isFetching) { - return + return } if (!article) { return notFound() diff --git a/frontends/ol-components/src/components/LoadingSpinner/LoadingSpinner.tsx b/frontends/ol-components/src/components/LoadingSpinner/LoadingSpinner.tsx index a6db555c23..c9ebf51100 100644 --- a/frontends/ol-components/src/components/LoadingSpinner/LoadingSpinner.tsx +++ b/frontends/ol-components/src/components/LoadingSpinner/LoadingSpinner.tsx @@ -18,6 +18,7 @@ type LoadingSpinnerProps = { size?: number | string "aria-label"?: string color?: "primary" | "inherit" + className?: string } const noDelay = { transitionDelay: "0ms" } @@ -27,9 +28,10 @@ const LoadingSpinner: React.FC = ({ size, "aria-label": label = "Loading", color, + className, }) => { return ( - + diff --git a/frontends/ol-components/src/components/TiptapEditor/ArticleContext.tsx b/frontends/ol-components/src/components/TiptapEditor/ArticleContext.tsx new file mode 100644 index 0000000000..343233fb08 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/ArticleContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react" +import type { RichTextArticle } from "api/v1" + +interface ArticleContextValue { + article?: RichTextArticle +} + +const ArticleContext = createContext({}) + +export const ArticleProvider = ArticleContext.Provider + +export function useArticle() { + return useContext(ArticleContext).article +} diff --git a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx index e9677bac5d..143379e339 100644 --- a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx +++ b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx @@ -3,9 +3,11 @@ import React, { ChangeEventHandler, useState, useEffect } from "react" import styled from "@emotion/styled" import { EditorContext, JSONContent, useEditor } from "@tiptap/react" +import type { RichTextArticle } from "api/v1" +import { LoadingSpinner } from "../LoadingSpinner/LoadingSpinner" import Document from "@tiptap/extension-document" import { Placeholder, Selection } from "@tiptap/extensions" - +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" import { StarterKit } from "@tiptap/starter-kit" import { TaskItem, TaskList } from "@tiptap/extension-list" import { Heading } from "@tiptap/extension-heading" @@ -20,6 +22,7 @@ import { Toolbar } from "./vendor/components/tiptap-ui-primitive/toolbar" import { Spacer } from "./vendor/components/tiptap-ui-primitive/spacer" import TiptapEditor, { MainToolbarContent } from "./TiptapEditor" +import { ArticleProvider } from "./ArticleContext" import { DividerNode } from "./extensions/node/Divider/DividerNode" import { ArticleByLineInfoBarNode } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" @@ -38,6 +41,7 @@ import "./vendor/components/tiptap-node/image-node/image-node.scss" import "./vendor/components/tiptap-node/heading-node/heading-node.scss" import "./vendor/components/tiptap-node/paragraph-node/paragraph-node.scss" +import type { ExtendedNodeConfig } from "./extensions/node/types" import { handleImageUpload, MAX_FILE_SIZE } from "./vendor/lib/tiptap-utils" import "./vendor/styles/_keyframe-animations.scss" @@ -49,7 +53,6 @@ import { useArticlePartialUpdate, useMediaUpload, } from "api/hooks/articles" -import type { RichTextArticle } from "api/v1" import { Alert, Button, ButtonLink } from "@mitodl/smoot-design" import Typography from "@mui/material/Typography" import { useUserHasPermission, Permission } from "api/hooks/user" @@ -82,6 +85,12 @@ const StyledToolbar = styled(Toolbar)(({ theme }) => ({ const StyledAlert = styled(Alert)({ margin: "20px auto", maxWidth: "1000px", + position: "fixed", + top: "108px", + left: "50%", + width: "690px", + transform: "translateX(-50%)", + zIndex: 1, }) const ArticleDocument = Document.extend({ @@ -104,13 +113,11 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { const { mutate: createArticle, isPending: isCreating, - isError: isCreateError, error: createError, } = useArticleCreate() const { mutate: updateArticle, isPending: isUpdating, - isError: isUpdateError, error: updateError, } = useArticlePartialUpdate() @@ -215,7 +222,6 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { onUpdate: ({ editor }) => { const json = editor.getJSON() - setContent(json) setTouched(true) }, @@ -256,11 +262,46 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { Placeholder.configure({ showOnlyCurrent: false, includeChildren: true, - placeholder: ({ node }) => { + placeholder: ({ node, editor }): string => { + let parentNode: typeof node | null = null + + editor.state.doc.descendants((n: ProseMirrorNode) => { + n.forEach((childNode: ProseMirrorNode) => { + if (childNode === node) { + parentNode = n + } + }) + if (parentNode) { + return false + } + return undefined + }) + + if (parentNode) { + const parentExtension = editor.extensionManager.extensions.find( + (ext) => ext.name === parentNode!.type.name, + ) + + if ( + parentExtension && + "config" in parentExtension && + parentExtension.config && + typeof (parentExtension.config as ExtendedNodeConfig) + .getPlaceholders === "function" + ) { + const placeholder = ( + parentExtension.config as ExtendedNodeConfig + ).getPlaceholders(node) + if (placeholder) { + return placeholder + } + } + } + if (node.type.name === "heading") { - return "Add heading..." + return "Add a heading" } - return "Add text..." + return "Add some text" }, }), HorizontalRule, @@ -283,7 +324,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { maxSize: MAX_FILE_SIZE, limit: 3, upload: uploadHandler, - onError: (error) => console.error("Upload failed:", error), + onError: (error) => setUploadError(error.message), }), BannerNode, ], @@ -320,79 +361,78 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { if (!editor) return null const isPending = isCreating || isUpdating - const isError = isCreateError || isUpdateError - const error = createError || updateError - - const publishButtonLabel = (() => { - if (isPending && article?.is_published) return "Updating..." - - if (isPending && isPublishing && !article?.is_published) - return "Publishing..." - - if (!isPending && article?.is_published) return "Update" - - return "Publish" - })() + const error = createError || updateError || uploadError return ( - - {isArticleEditor ? ( - readOnly ? ( - - - - Edit - - - ) : ( - - - {(!article || !article?.is_published) && ( + + + {isArticleEditor ? ( + readOnly ? ( + + + + Edit + + + ) : ( + + + {!article?.is_published ? ( + + ) : null} + - )} - - - - ) - ) : null} - {isError || - (uploadError && ( + + ) + ) : null} + {error ? ( - {error?.message ?? - uploadError ?? - "An error occurred while saving"} + {error instanceof Error ? error.message : error} - ))} + ) : null} - - + + + ) } diff --git a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx index cb604b6c6c..a4fbe4d1d8 100644 --- a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx @@ -37,6 +37,7 @@ import { UndoRedoButton } from "./vendor/components/tiptap-ui/undo-redo-button" import { LearningResourceButton } from "./extensions/ui/LearningResource/LearningResourceButton" import { Button } from "./vendor/components/tiptap-ui-primitive/button" import { DividerButton } from "./extensions/ui/Divider/DividerButton" +import { RiArrowDropDownFill } from "@remixicon/react" import { DropdownMenu, DropdownMenuTrigger, @@ -149,12 +150,16 @@ const Toolbar = styled.div({ }, }) -const StyledDropdownMenuWrapper = styled(DropdownMenuContent)` +const StyledDropdownMenu = styled(DropdownMenuContent)` &.tiptap-dropdown-menu { background-color: #e1e3ed; border-radius: 8px; padding: 4px; } + + .tiptap-button { + width: 100%; + } ` interface TiptapEditorToolbarProps { @@ -165,22 +170,27 @@ export function InsertDropdownMenu({ editor }: TiptapEditorToolbarProps) { return ( - + - + + + + - + - + - + ) } @@ -231,12 +241,10 @@ export const MainToolbarContent = ({ editor }: TiptapEditorToolbarProps) => { - - - + ) diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx index e58075eb75..9d7a0a4d66 100644 --- a/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx +++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx @@ -4,9 +4,11 @@ import type { ReactNodeViewProps } from "@tiptap/react" import styled from "@emotion/styled" import Container from "@mui/material/Container" import { RiShareFill } from "@remixicon/react" -import { useUserMe } from "api/hooks/user" import Avatar from "@mui/material/Avatar" import { ActionButton } from "@mitodl/smoot-design" +import { useUserMe } from "api/hooks/user" +import { useArticle } from "../../../ArticleContext" +import { calculateReadTime } from "../../utils" const StyledWrapper = styled.div(({ theme }) => ({ width: "100vw", @@ -52,29 +54,22 @@ const InfoText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) -const Spacer = styled.div({ - marginBottom: "56px", -}) +const ArticleByLineInfoBar = ({ editor }: ReactNodeViewProps) => { + const article = useArticle() -const ArticleByLineInfoBar = ({ node }: ReactNodeViewProps) => { - const { authorName, avatarUrl, readTime, publishedDate, editable } = - node.attrs - const { isLoading, data: user } = useUserMe() + const { data: user } = useUserMe() - if (editable) { - return ( - - - - ) + let author = null + if (editor?.isEditable && !article?.user) { + author = user + } else { + author = article?.user } - const author = - !isLoading && - (authorName || - (editable && (user?.first_name || user?.last_name) - ? `${user?.first_name || ""} ${user?.last_name || ""}`.trim() - : null)) + const publishedDate = article?.is_published ? article?.created_on : null + + const content = editor?.isEditable ? editor?.getJSON() : article?.content + const readTime = calculateReadTime(content) return ( @@ -82,18 +77,25 @@ const ArticleByLineInfoBar = ({ node }: ReactNodeViewProps) => { {author && ( - - {user?.first_name?.charAt(0) || ""} - {user?.last_name?.charAt(0) || ""} + + {author.first_name?.charAt(0) || ""} + {author.last_name?.charAt(0) || ""} - - By {author} - {readTime && {readTime}} - - + + By {author.first_name} {author.last_name} + + {readTime ? {readTime} min read : null} + {readTime && publishedDate ? - : null} {publishedDate - ? publishedDate - : new Date().toLocaleDateString()} + ? new Date(publishedDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : editor?.isEditable + ? null + : "Draft"} )} diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts index f0e4487b5c..746126091a 100644 --- a/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts +++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts @@ -2,52 +2,11 @@ import { Node, mergeAttributes } from "@tiptap/core" import { ReactNodeViewRenderer } from "@tiptap/react" import ArticleByLineInfoBar from "./ArticleByLineInfoBar" -declare module "@tiptap/core" { - interface Commands { - byline: { - insertByline: (options: { - authorName: string - avatarUrl?: string - readTime?: string - publishedDate?: string - }) => ReturnType - } - } -} - export const ArticleByLineInfoBarNode = Node.create({ name: "byline", atom: true, selectable: false, - addOptions() { - return { - authorName: "", - avatarUrl: "", - readTime: "", - publishedDate: "", - editable: true, - } - }, - - addAttributes() { - return { - authorName: { - default: null, - }, - avatarUrl: { - default: null, - }, - readTime: { - default: null, - }, - publishedDate: { - default: null, - }, - editable: { default: true }, - } - }, - parseHTML() { return [{ tag: "byline" }] }, @@ -56,20 +15,6 @@ export const ArticleByLineInfoBarNode = Node.create({ return ["byline", mergeAttributes(HTMLAttributes), 0] }, - addCommands() { - return { - insertByline: - ({ authorName, avatarUrl, readTime, publishedDate }) => - ({ chain }) => { - return chain() - .insertContent({ - type: this.name, - attrs: { authorName, avatarUrl, readTime, publishedDate }, - }) - .run() - }, - } - }, addNodeView() { return ReactNodeViewRenderer(ArticleByLineInfoBar) }, diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/Banner/BannerNode.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Banner/BannerNode.tsx index ffee214f3b..94af877686 100644 --- a/frontends/ol-components/src/components/TiptapEditor/extensions/node/Banner/BannerNode.tsx +++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Banner/BannerNode.tsx @@ -6,9 +6,11 @@ import { NodeViewWrapper, NodeViewContent, } from "@tiptap/react" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" import { BannerBackground } from "../../../../Banner/Banner" import Container from "@mui/material/Container" import styled from "@emotion/styled" +import type { ExtendedNodeConfig } from "../types" const FullWidthContainer = styled.div({ position: "relative", @@ -64,7 +66,7 @@ const BannerWrapper = () => { ) } -const BannerNode = Node.create({ +const bannerNodeConfig: ExtendedNodeConfig = { name: "banner", selectable: false, @@ -78,13 +80,25 @@ const BannerNode = Node.create({ return [{ tag: "banner" }] }, - renderHTML({ HTMLAttributes }) { + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { return ["banner", mergeAttributes(HTMLAttributes), 0] }, addNodeView() { return ReactNodeViewRenderer(BannerWrapper) }, -}) + + getPlaceholders: (childNode: ProseMirrorNode) => { + if (childNode.type.name === "heading") { + return "Add a title" + } + if (childNode.type.name === "paragraph") { + return "Add a subheading" + } + return null + }, +} + +const BannerNode = Node.create(bannerNodeConfig) export { BannerNode } diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/Image/ImageWithCaption.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Image/ImageWithCaption.tsx index 74f23a6ded..6eb43b3ac9 100644 --- a/frontends/ol-components/src/components/TiptapEditor/extensions/node/Image/ImageWithCaption.tsx +++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Image/ImageWithCaption.tsx @@ -9,7 +9,7 @@ import { DefaultWidth, WideWidth, FullWidth } from "./Icons" const ARTICLE_MAX_WIDTH = 890 const CONTAINER_PADDING = 24 -const Container = styled.div({ +const Container = styled.div(({ theme }) => ({ position: "relative", margin: "2rem auto", textAlign: "center", @@ -29,13 +29,11 @@ const Container = styled.div({ [`@media (min-width: ${ARTICLE_MAX_WIDTH + CONTAINER_PADDING * 2}px)`]: { "&.layout-wide img": { - width: "90vw", - maxWidth: "90vw", + width: "92vw", + maxWidth: "1400px", position: "relative", left: "50%", - right: "50%", - marginLeft: "-45vw", - marginRight: "-45vw", + transform: "translateX(-50%)", }, }, @@ -45,8 +43,7 @@ const Container = styled.div({ position: "relative", left: "50%", right: "50%", - marginLeft: "-50vw", - marginRight: "-50vw", + transform: "translateX(-50%)", }, ".caption-input": { @@ -92,19 +89,30 @@ const Container = styled.div({ border: "none", background: "transparent", filter: "brightness(0.9)", - color: "white", + color: theme.custom.colors.white, cursor: "pointer", + "&:hover": { + background: theme.custom.colors.darkGray1, + }, "&.active": { background: "#9be19b", - color: "black", + color: theme.custom.colors.black, fontWeight: "bold", + cursor: "default", + svg: { + fill: theme.custom.colors.black, + }, + }, + + svg: { + fill: theme.custom.colors.white, }, }, ".alt-text-button": { - color: "white", - fontSize: "12px", + color: theme.custom.colors.white, + fontSize: "14px", borderRadius: "4px", padding: "2px 6px", width: "100px", @@ -116,7 +124,7 @@ const Container = styled.div({ display: "flex", }, }, -}) +})) enum Layout { default = "default", @@ -188,10 +196,10 @@ export function ImageWithCaption({ )} diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/types.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/node/types.ts new file mode 100644 index 0000000000..d1fbb39399 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/types.ts @@ -0,0 +1,6 @@ +import { Node } from "@tiptap/react" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" + +export type ExtendedNodeConfig = Parameters[0] & { + getPlaceholders: (childNode: ProseMirrorNode) => string | null +} diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/utils.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/utils.ts index 23dd9029dd..a9b2984ffa 100644 --- a/frontends/ol-components/src/components/TiptapEditor/extensions/utils.ts +++ b/frontends/ol-components/src/components/TiptapEditor/extensions/utils.ts @@ -1,3 +1,5 @@ +import { JSONContent } from "@tiptap/react" + export function generateUUID(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID() @@ -10,3 +12,41 @@ export function generateUUID(): string { return v.toString(16) }) } + +function extractTextFromNode(node: JSONContent | null | undefined): string { + if (!node) return "" + + if (node.type === "text" && node.text) { + return node.text + } + + if (node.content && Array.isArray(node.content)) { + return node.content.map(extractTextFromNode).join(" ") + } + + return "" +} + +function countWords(text: string): number { + if (!text || !text.trim()) return 0 + return text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0).length +} + +export function calculateReadTime( + content: JSONContent | null | undefined, + wordsPerMinute: number = 250, +): number | null { + if (!content) return null + + const text = extractTextFromNode(content) + const wordCount = countWords(text) + + if (wordCount === 0) return null + + const readingTime = wordCount / wordsPerMinute + + return Math.round(readingTime) +}