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