diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx
new file mode 100644
index 0000000000..592546c9dd
--- /dev/null
+++ b/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx
@@ -0,0 +1,52 @@
+"use client"
+
+import React from "react"
+import { useArticleDetailRetrieve } from "api/hooks/articles"
+import { LoadingSpinner, styled } from "ol-components"
+import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor"
+import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider"
+import { notFound } from "next/navigation"
+
+const PageContainer = styled.div({
+ display: "flex",
+ height: "100%",
+})
+
+const Spinner = styled(LoadingSpinner)({
+ margin: "auto",
+ position: "absolute",
+ top: "40%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+})
+
+const UserArticleDetailPage = ({
+ articleId,
+ learningResourceIds = [],
+}: {
+ articleId: string
+ learningResourceIds?: number[]
+}) => {
+ const { data: article, isLoading } = useArticleDetailRetrieve(articleId)
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (!article) {
+ return notFound()
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+export { UserArticleDetailPage }
diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx
new file mode 100644
index 0000000000..bc8b162509
--- /dev/null
+++ b/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx
@@ -0,0 +1,182 @@
+"use client"
+
+import React, { useState, useRef, useEffect } from "react"
+import {
+ Container,
+ styled,
+ theme,
+ Grid2,
+ Card,
+ Pagination,
+ PaginationItem,
+ LoadingSpinner,
+ Typography,
+} from "ol-components"
+import { Permission } from "api/hooks/user"
+import { useArticleList } from "api/hooks/articles"
+import type { WebsiteContent } from "api/v1"
+import { LocalDate } from "ol-utilities"
+import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
+import { extractFirstImageFromArticle } from "@/common/articleUtils"
+import { userArticlesDraftView, userArticlesView } from "@/common/urls"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+
+const PAGE_SIZE = 20
+
+export const DEFAULT_BACKGROUND_IMAGE_URL =
+ "/images/backgrounds/banner_background.webp"
+
+const PageWrapper = styled.div`
+ background: ${theme.custom.colors.white};
+ min-height: calc(100vh - 200px);
+ padding: 80px 0;
+ ${theme.breakpoints.down("md")} {
+ padding: 40px 0;
+ }
+`
+
+const DraftArticleCard = styled(Card)`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+`
+
+const PaginationContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ margin-top: 40px;
+`
+
+const LoadingContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 400px;
+`
+
+const EmptyState = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ gap: 16px;
+`
+
+const DraftBadge = styled.span`
+ color: ${theme.custom.colors.silverGrayDark};
+ font-weight: ${theme.typography.fontWeightMedium};
+`
+
+const DraftUserArticle: React.FC<{ article: WebsiteContent }> = ({
+ article,
+}) => {
+ const articleUrl = article.is_published
+ ? userArticlesView(article.slug || String(article.id))
+ : userArticlesDraftView(String(article.id))
+
+ const imageUrl = extractFirstImageFromArticle(article.content)
+
+ return (
+
+ {
+
+ }
+
+ {article.title}
+
+
+
+ {!article.is_published && (
+ <>
+ {" • "}
+ Draft
+ >
+ )}
+
+
+ )
+}
+
+const UserArticleDraftPage: React.FC = () => {
+ const [page, setPage] = useState(1)
+ const scrollRef = useRef(null)
+
+ const { data: articles, isLoading: isLoadingArticles } = useArticleList({
+ limit: PAGE_SIZE,
+ offset: (page - 1) * PAGE_SIZE,
+ draft: true,
+ })
+
+ useEffect(() => {
+ if (page > 1 && scrollRef.current) {
+ scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" })
+ }
+ }, [page])
+
+ const draftArticles = articles?.results
+ const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0
+
+ if (isLoadingArticles) {
+ return
+ }
+
+ return (
+
+
+
+ {isLoadingArticles ? (
+
+
+
+ ) : draftArticles && draftArticles.length > 0 ? (
+ <>
+
+ {draftArticles.map((article) => (
+
+
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+ setPage(newPage)}
+ renderItem={(item) => (
+
+ )}
+ />
+
+ )}
+ >
+ ) : (
+
+ No Draft Articles
+
+ You don't have any draft articles yet. Create a new article
+ to get started.
+
+
+ )}
+
+
+
+ )
+}
+
+export { UserArticleDraftPage }
diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx
new file mode 100644
index 0000000000..95f3ab0902
--- /dev/null
+++ b/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import React from "react"
+import { useRouter } from "next-nprogress-bar"
+import { notFound } from "next/navigation"
+import { Permission } from "api/hooks/user"
+import { useArticleDetailRetrieve } from "api/hooks/articles"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+import { styled, LoadingSpinner } from "ol-components"
+import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor"
+import { userArticlesDraftView, userArticlesView } from "@/common/urls"
+import invariant from "tiny-invariant"
+
+const PageContainer = styled.div(({ theme }) => ({
+ color: theme.custom.colors.darkGray2,
+ display: "flex",
+ height: "100%",
+}))
+
+const Spinner = styled(LoadingSpinner)({
+ margin: "auto",
+ position: "absolute",
+ top: "40%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+})
+
+const UserArticleEditPage = ({ articleId }: { articleId: string }) => {
+ const {
+ data: article,
+ isLoading,
+ isFetching,
+ } = useArticleDetailRetrieve(articleId)
+ const router = useRouter()
+
+ if (isLoading || isFetching) {
+ return
+ }
+ if (!article) {
+ return notFound()
+ }
+
+ return (
+
+
+ {
+ if (saved.is_published) {
+ invariant(saved.slug, "Published article must have a slug")
+ return router.push(userArticlesView(saved.slug))
+ } else {
+ router.push(userArticlesDraftView(String(saved.id)))
+ }
+ }}
+ />
+
+
+ )
+}
+
+export { UserArticleEditPage }
diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx
new file mode 100644
index 0000000000..f23e45117b
--- /dev/null
+++ b/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import React, { useState, useRef, useEffect } from "react"
+import {
+ Container,
+ styled,
+ theme,
+ Grid2,
+ Card,
+ Pagination,
+ PaginationItem,
+ LoadingSpinner,
+ Typography,
+} from "ol-components"
+import { ButtonLink } from "@mitodl/smoot-design"
+import { useArticleList } from "api/hooks/articles"
+import type { WebsiteContent } from "api/v1"
+import { LocalDate } from "ol-utilities"
+import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
+import { extractFirstImageFromArticle } from "@/common/articleUtils"
+import {
+ userArticlesView,
+ USER_ARTICLES_CREATE,
+ USER_ARTICLES_LISTING,
+} from "@/common/urls"
+
+const PAGE_SIZE = 20
+const MAX_PAGE = 50
+
+export const DEFAULT_BACKGROUND_IMAGE_URL =
+ "/images/backgrounds/banner_background.webp"
+
+const getLastPage = (count: number): number => {
+ const pages = Math.ceil(count / PAGE_SIZE)
+ return pages > MAX_PAGE ? MAX_PAGE : pages
+}
+
+const PageHeader = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 32px;
+`
+
+const Section = styled.section`
+ background: ${theme.custom.colors.white};
+ padding: 80px 0;
+ ${theme.breakpoints.down("sm")} {
+ padding: 40px 0;
+ }
+`
+
+const ArticleCardWrapper = styled(Card)`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+`
+
+const PaginationContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ margin-top: 40px;
+`
+
+const EmptyState = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ gap: 16px;
+`
+
+const UserArticleCard: React.FC<{ article: WebsiteContent }> = ({
+ article,
+}) => {
+ const articleUrl = article.is_published
+ ? userArticlesView(article.slug || String(article.id))
+ : `${USER_ARTICLES_LISTING}${article.id}/draft`
+
+ const imageUrl = extractFirstImageFromArticle(article.content)
+
+ return (
+
+
+
+ {article.title}
+
+
+
+
+
+ )
+}
+
+const UserArticleListingPage: React.FC = () => {
+ const [page, setPage] = useState(1)
+ const scrollRef = useRef(null)
+
+ const { data: articles, isLoading } = useArticleList({
+ limit: PAGE_SIZE,
+ offset: (page - 1) * PAGE_SIZE,
+ })
+
+ useEffect(() => {
+ if (page > 1 && scrollRef.current) {
+ scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" })
+ }
+ }, [page])
+
+ const results = articles?.results
+ const totalPages = articles?.count ? getLastPage(articles.count) : 0
+
+ return (
+
+
+
+ Articles
+
+ New Article
+
+
+
+ {isLoading ? (
+
+ ) : results && results.length > 0 ? (
+ <>
+
+ {results.map((article) => (
+
+
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+ setPage(newPage)}
+ renderItem={(item) => (
+
+ )}
+ />
+
+ )}
+ >
+ ) : (
+
+ No Articles Yet
+
+ Get started by creating your first article.
+
+
+ New Article
+
+
+ )}
+
+
+ )
+}
+
+export { UserArticleListingPage }
diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx
new file mode 100644
index 0000000000..bb03defc55
--- /dev/null
+++ b/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import React from "react"
+import { useRouter } from "next-nprogress-bar"
+import { Permission } from "api/hooks/user"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+import { styled } from "ol-components"
+import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor"
+import { userArticlesDraftView, userArticlesView } from "@/common/urls"
+import invariant from "tiny-invariant"
+
+const PageContainer = styled.div(({ theme }) => ({
+ color: theme.custom.colors.darkGray2,
+ display: "flex",
+ height: "100%",
+}))
+
+const UserArticleNewPage: React.FC = () => {
+ const router = useRouter()
+
+ return (
+
+
+ {
+ if (article.is_published) {
+ invariant(article.slug, "Published article must have a slug")
+ return router.push(userArticlesView(article.slug))
+ } else {
+ router.push(userArticlesDraftView(String(article.id)))
+ }
+ }}
+ />
+
+
+ )
+}
+
+export { UserArticleNewPage }
diff --git a/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx
new file mode 100644
index 0000000000..a375abaa8f
--- /dev/null
+++ b/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx
@@ -0,0 +1,25 @@
+import React from "react"
+import { standardizeMetadata } from "@/common/metadata"
+import { UserArticleDetailPage } from "@/app-pages/UserArticles/UserArticleDetailPage"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+import { Permission } from "api/hooks/user"
+
+export const generateMetadata = async () => {
+ return standardizeMetadata({
+ title: "Draft Article",
+ })
+}
+
+const Page: React.FC> = async (
+ props,
+) => {
+ const { slugOrId } = await props.params
+
+ return (
+
+
+
+ )
+}
+
+export default Page
diff --git a/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx b/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx
new file mode 100644
index 0000000000..50010e8367
--- /dev/null
+++ b/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx
@@ -0,0 +1,12 @@
+import React from "react"
+import { UserArticleEditPage } from "@/app-pages/UserArticles/UserArticleEditPage"
+
+const Page: React.FC> = async (
+ props,
+) => {
+ const { slugOrId } = await props.params
+
+ return
+}
+
+export default Page
diff --git a/frontends/main/src/app/articles/[slugOrId]/page.tsx b/frontends/main/src/app/articles/[slugOrId]/page.tsx
new file mode 100644
index 0000000000..635bbc6e1d
--- /dev/null
+++ b/frontends/main/src/app/articles/[slugOrId]/page.tsx
@@ -0,0 +1,95 @@
+import React from "react"
+import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
+import { articleQueries } from "api/hooks/articles/queries"
+import { UserArticleDetailPage } from "@/app-pages/UserArticles/UserArticleDetailPage"
+import { getQueryClient } from "@/app/getQueryClient"
+import { learningResourceQueries } from "api/hooks/learningResources"
+import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils"
+import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata"
+import type { WebsiteContent } from "api/v1"
+import type { JSONContent } from "@tiptap/react"
+
+// Extracts the banner subheading paragraph at known location
+const extractArticleDescription = (
+ article: WebsiteContent,
+): string | undefined => {
+ const banner = article.content?.content?.[0]
+ const subheading = banner?.content?.[1]
+ const textNode = subheading?.content?.[0]
+ return textNode?.text
+}
+
+const extractImageMetadata = (
+ article: WebsiteContent,
+): { src: string; alt: string } | null => {
+ const imageWithCaption = article.content?.content?.find(
+ (node: JSONContent) => node.type === "imageWithCaption",
+ )
+ if (!imageWithCaption) {
+ return null
+ }
+ return {
+ src: imageWithCaption.attrs.src,
+ alt: imageWithCaption.attrs.caption || imageWithCaption.attrs.alt,
+ }
+}
+
+export const generateMetadata = async (
+ props: PageProps<"/articles/[slugOrId]">,
+) => {
+ const params = await props.params
+ const { slugOrId } = params
+
+ const queryClient = getQueryClient()
+
+ return safeGenerateMetadata(async () => {
+ const article = await queryClient.fetchQuery(
+ articleQueries.articlesDetailRetrieve(slugOrId),
+ )
+
+ const description = extractArticleDescription(article)
+ const leadImage = extractImageMetadata(article)
+
+ return standardizeMetadata({
+ title: article.title,
+ description,
+ image: leadImage?.src,
+ imageAlt: leadImage?.alt,
+ })
+ })
+}
+
+const Page: React.FC> = async (props) => {
+ const { slugOrId } = await props.params
+
+ const queryClient = getQueryClient()
+
+ await queryClient.fetchQueryOr404(
+ articleQueries.articlesDetailRetrieve(slugOrId),
+ )
+
+ const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey
+ const cacheData = queryClient.getQueryData(queryKey)
+
+ const learningResourceIds = cacheData?.content
+ ? extractLearningResourceIds(cacheData.content)
+ : []
+
+ if (learningResourceIds.length > 0) {
+ const bulkQuery = learningResourceQueries.list({
+ resource_id: learningResourceIds,
+ })
+ await queryClient.prefetchQuery(bulkQuery)
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default Page
diff --git a/frontends/main/src/app/articles/draft/page.tsx b/frontends/main/src/app/articles/draft/page.tsx
new file mode 100644
index 0000000000..a16b38a550
--- /dev/null
+++ b/frontends/main/src/app/articles/draft/page.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Metadata } from "next"
+import { standardizeMetadata } from "@/common/metadata"
+import { UserArticleDraftPage } from "@/app-pages/UserArticles/UserArticleDraftListingPage"
+
+export const metadata: Metadata = standardizeMetadata({
+ title: "Article Drafts",
+ robots: "noindex, nofollow",
+})
+
+const Page: React.FC> = () => {
+ return
+}
+
+export default Page
diff --git a/frontends/main/src/app/articles/new/page.tsx b/frontends/main/src/app/articles/new/page.tsx
new file mode 100644
index 0000000000..8bcd4029c2
--- /dev/null
+++ b/frontends/main/src/app/articles/new/page.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Metadata } from "next"
+import { standardizeMetadata } from "@/common/metadata"
+import { UserArticleNewPage } from "@/app-pages/UserArticles/UserArticleNewPage"
+
+export const metadata: Metadata = standardizeMetadata({
+ title: "MIT Learn | New Article",
+ robots: "noindex, nofollow",
+})
+
+const Page: React.FC> = () => {
+ return
+}
+
+export default Page
diff --git a/frontends/main/src/app/articles/page.tsx b/frontends/main/src/app/articles/page.tsx
new file mode 100644
index 0000000000..1a2061323b
--- /dev/null
+++ b/frontends/main/src/app/articles/page.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Metadata } from "next"
+import { standardizeMetadata } from "@/common/metadata"
+import { UserArticleListingPage } from "@/app-pages/UserArticles/UserArticleListingPage"
+
+export const metadata: Metadata = standardizeMetadata({
+ title: "MIT Learn | Articles",
+ robots: "noindex, nofollow",
+})
+
+const Page: React.FC> = () => {
+ return
+}
+
+export default Page
diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts
index d2a334a86f..2c47c3c2b2 100644
--- a/frontends/main/src/common/urls.ts
+++ b/frontends/main/src/common/urls.ts
@@ -48,6 +48,19 @@ export const articlesDraftView = (id: string) =>
export const articlesEditView = (id: number) =>
generatePath(ARTICLES_EDIT, { id: String(id) })
+// User-created articles (served under /articles)
+export const USER_ARTICLES_LISTING = "/articles/"
+export const USER_ARTICLES_VIEW = "/articles/[id]"
+export const USER_ARTICLES_DRAFT_VIEW = "/articles/[id]/draft"
+export const USER_ARTICLES_EDIT = "/articles/[id]/edit"
+export const USER_ARTICLES_CREATE = "/articles/new"
+export const userArticlesView = (id: string) =>
+ generatePath(USER_ARTICLES_VIEW, { id: String(id) })
+export const userArticlesDraftView = (id: string) =>
+ generatePath(USER_ARTICLES_DRAFT_VIEW, { id: String(id) })
+export const userArticlesEditView = (id: number) =>
+ generatePath(USER_ARTICLES_EDIT, { id: String(id) })
+
export const DEPARTMENTS = "/departments/"
export const TOPICS = "/topics/"
diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx
index ec580b7aa5..f5c01fe11e 100644
--- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx
+++ b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx
@@ -1,395 +1,8 @@
-"use client"
-
-import React, { ChangeEventHandler, useState, useEffect } from "react"
-import styled from "@emotion/styled"
-import { EditorContext, JSONContent, useEditor } from "@tiptap/react"
-import type { WebsiteContent } from "api/v1"
-import {
- LoadingSpinner,
- Typography,
- HEADER_HEIGHT,
- HEADER_HEIGHT_MD,
-} from "ol-components"
-
-import { Toolbar } from "./vendor/components/tiptap-ui-primitive/toolbar"
-import { Spacer } from "./vendor/components/tiptap-ui-primitive/spacer"
-
-import { TiptapEditor, MainToolbarContent, TipTapViewer } from "./TiptapEditor"
-import { ArticleProvider } from "./ArticleContext"
-
-import { handleImageUpload } from "./vendor/lib/tiptap-utils"
-import { useArticleSchema, newArticleDocument } from "./useArticleSchema"
-
-import "./vendor/styles/_keyframe-animations.scss"
-import "./vendor/styles/_variables.scss"
-import "./vendor/components/tiptap-templates/simple/simple-editor.scss"
-
-import {
- useArticleCreate,
- useArticlePartialUpdate,
- useMediaUpload,
-} from "api/hooks/articles"
-import { Alert, Button, ButtonLink } from "@mitodl/smoot-design"
-import { useUserHasPermission, Permission } from "api/hooks/user"
-import dynamic from "next/dynamic"
-import { extractLearningResourceIds, contentsMatch } from "./extensions/utils"
-import { LearningResourceProvider } from "./extensions/node/LearningResource/LearningResourceDataProvider"
-
-const LearningResourceDrawer = dynamic(
- () =>
- import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"),
- { ssr: false },
-)
-
-const TOOLBAR_HEIGHT = 43
-
-const ViewContainer = styled.div<{ toolbarVisible: boolean }>(
- ({ toolbarVisible, theme }) => ({
- width: "100vw",
- marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0,
- backgroundColor: theme.custom.colors.white,
- }),
-)
-
-const StyledToolbar = styled(Toolbar)(({ theme }) => ({
- "&&": {
- position: "fixed",
- top: HEADER_HEIGHT,
- [theme.breakpoints.down("md")]: {
- top: HEADER_HEIGHT_MD,
- },
- },
-}))
-
-const StyledAlert = styled(Alert)({
- margin: "20px auto",
- maxWidth: "1000px",
- position: "fixed",
- top: "108px",
- left: "50%",
- width: "690px",
- transform: "translateX(-50%)",
- zIndex: 1,
- "p:not(:first-child)": {
- margin: "10px 0",
- },
-})
-
-interface ArticleEditorProps {
- value?: object
- onSave?: (article: WebsiteContent) => void
- readOnly?: boolean
- title?: string
- setTitle?: ChangeEventHandler
- article?: WebsiteContent
-}
-const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
- const [title, setTitle] = React.useState(article?.title)
- const [isPublishing, setIsPublishing] = useState(false)
- const [uploadError, setUploadError] = useState(null)
- const [resetAttempted, setResetAttempted] = useState(false)
-
- const {
- mutate: createArticle,
- isPending: isCreating,
- error: createError,
- } = useArticleCreate()
- const {
- mutate: updateArticle,
- isPending: isUpdating,
- error: updateError,
- } = useArticlePartialUpdate()
-
- const uploadImage = useMediaUpload()
-
- const isArticleEditor = useUserHasPermission(Permission.ArticleEditor)
-
- const [content, setContent] = useState(
- article?.content || newArticleDocument,
- )
- const [touched, setTouched] = useState(false)
-
- // Extract author_name from the byline node
- const extractAuthorName = (content: JSONContent): string | "" => {
- const bylineNode = content.content?.find((node) => node.type === "byline")
- return bylineNode?.attrs?.authorName || ""
- }
-
- const handleSave = (publish: boolean) => {
- if (!title) return
- const authorName = extractAuthorName(content)
- if (article) {
- updateArticle(
- {
- id: article.id,
- title: title.trim(),
- content,
- is_published: publish,
- author_name: authorName,
- },
- {
- onSuccess: onSave,
- },
- )
- } else {
- createArticle(
- {
- title: title.trim(),
- content,
- is_published: publish,
- author_name: authorName,
- },
- {
- onSuccess: onSave,
- },
- )
- }
- }
-
- const uploadHandler = async (
- file: File,
- onProgress?: (e: { progress: number }) => void,
- abortSignal?: AbortSignal,
- ) => {
- setUploadError(null)
- return handleImageUpload(
- file,
- async (file: File, progressCb?: (percent: number) => void) => {
- try {
- uploadImage.setNextProgressCallback(progressCb)
-
- const response = await uploadImage.mutateAsync({ file })
-
- if (!response?.url) throw new Error("Upload failed")
- return response.url
- } catch (error) {
- if (error instanceof Error) {
- setUploadError(error.message)
- } else {
- setUploadError(String(error) || "Upload failed")
- }
-
- throw error
- }
- },
- onProgress,
- abortSignal,
- )
- }
-
- const { extensions, schemaError } = useArticleSchema({
- uploadHandler,
- setUploadError,
- enabled: isArticleEditor,
- content,
- })
-
- const editor = useEditor({
- immediatelyRender: false,
- shouldRerenderOnTransaction: false,
- content,
- editable: !readOnly,
-
- onUpdate: ({ editor }) => {
- const json = editor.getJSON()
- setContent(json)
- setTouched(true)
- },
-
- onCreate: ({ editor }) => {
- setTimeout(() => {
- editor.commands.setTextSelection(1)
- editor.commands.focus()
- }, 0)
-
- editor.commands.updateAttributes("mediaEmbed", { editable: !readOnly })
- editor.commands.updateAttributes("byline", { editable: readOnly })
- },
-
- editorProps: {
- attributes: {
- autocomplete: "off",
- autocorrect: "off",
- autocapitalize: "off",
- "aria-label": "Main content area, start typing to enter text.",
- class: "simple-editor",
- },
- },
- extensions,
- })
-
- useEffect(() => {
- if (!article || !editor) return
-
- if (article.content) {
- const currentContent = editor.getJSON()
- if (!contentsMatch(article.content, currentContent)) {
- setContent(article.content)
- setTouched(true)
- editor.commands.setContent(article.content)
- }
- }
-
- if (article.title !== undefined) {
- setTitle(article.title)
- }
- }, [article, editor])
-
- useEffect(() => {
- if (!editor) return
- const title = editor.$node("heading", { level: 1 })?.textContent || ""
- setTitle(title)
- }, [editor, content])
-
- useEffect(() => {
- if (!editor) return
- editor
- .chain()
- .command(({ tr, state }) => {
- state.doc.descendants((node, pos) => {
- if (
- node.type.name === "mediaEmbed" ||
- node.type.name === "imageWithCaption" ||
- node.type.name === "byline" ||
- node.type.name === "learningResource"
- ) {
- tr.setNodeMarkup(pos, undefined, {
- ...node.attrs,
- editable: !readOnly,
- })
- }
- })
- return true
- })
- .run()
- }, [editor, readOnly])
-
- if (!editor) return null
-
- const isPending = isCreating || isUpdating
- const error = createError || updateError || uploadError || schemaError
-
- const resourceIds = extractLearningResourceIds(content)
-
- return (
-
-
-
-
- {isArticleEditor ? (
- readOnly ? (
-
-
-
- Drafts
-
-
- Edit
-
-
- ) : (
-
-
- {!article?.is_published ? (
-
- ) : null}
-
-
-
- )
- ) : null}
- {error ? (
-
-
- {error instanceof Error ? error.message : error}
-
- {schemaError && !readOnly ? (
- <>
-
- Reset to attempt to align the article to the content
- template.
-
- {resetAttempted ? (
-
- Reset attempt failed.
-
- ) : null}
-
- >
- ) : null}
-
- ) : null}
- {readOnly ? (
- <>
-
-
- >
- ) : (
-
- )}
-
-
-
-
- )
-}
-
-export { ArticleEditor }
+/**
+ * Backward-compatible re-export.
+ * The news editor logic now lives in contentTypes/news/NewsEditor.tsx.
+ * All /news pages continue to import from this path without changes.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export { NewsEditor as ArticleEditor } from "./contentTypes/news/NewsEditor"
+export type { NewsEditorProps as ArticleEditorProps } from "./contentTypes/news/NewsEditor"
diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx
new file mode 100644
index 0000000000..1450e5ec5a
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import React from "react"
+import type { ChangeEventHandler } from "react"
+import type { WebsiteContent } from "api/v1"
+import { ButtonLink } from "@mitodl/smoot-design"
+import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles"
+import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer"
+import { GenericEditor } from "../../core/GenericEditor"
+import {
+ createArticleExtensions,
+ newArticleDocument,
+} from "./articleExtensions"
+
+// Article-specific: extract author name from the byline node
+const extractArticleExtraFields = (content: {
+ content?: Array<{ type?: string; attrs?: Record }>
+}): Record => {
+ const bylineNode = content.content?.find((node) => node.type === "byline")
+ return { author_name: bylineNode?.attrs?.authorName || "" }
+}
+
+interface ArticleEditorProps {
+ /** @deprecated unused, kept for API compatibility */
+ value?: object
+ onSave?: (article: WebsiteContent) => void
+ readOnly?: boolean
+ /** @deprecated unused, kept for API compatibility */
+ title?: string
+ /** @deprecated unused, kept for API compatibility */
+ setTitle?: ChangeEventHandler
+ article?: WebsiteContent
+}
+
+/**
+ * Editor shell configured for the article content type (served under /articles).
+ * Owns its own save mutations so GenericEditor stays API-agnostic.
+ *
+ * Currently uses the same websiteContent API as the news editor. When /articles
+ * gets its own Django model and viewset, swap in the new hooks here:
+ *
+ * const createMutation = useUserArticleCreate() // future hook
+ * const updateMutation = useUserArticlePartialUpdate()
+ *
+ * GenericEditor does not need to change at all.
+ */
+const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
+ // Swap these two lines when a dedicated UserArticle API exists.
+ const createMutation = useArticleCreate()
+ const updateMutation = useArticlePartialUpdate()
+
+ const editUrl = article
+ ? `/articles/${article.is_published ? article.slug : article.id}/edit`
+ : "/articles/new"
+
+ const toolbarSlot = readOnly ? (
+ <>
+
+
+ Drafts
+
+
+ Edit
+
+ >
+ ) : null
+
+ return (
+
+ )
+}
+
+export { ArticleEditor }
+export type { ArticleEditorProps }
diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts
new file mode 100644
index 0000000000..3c5ba195fb
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts
@@ -0,0 +1,11 @@
+/**
+ * Article content type extensions.
+ *
+ * Currently mirrors the news content type. Extensions and document structure
+ * will diverge as the /articles feature evolves.
+ */
+export {
+ createNewsExtensions as createArticleExtensions,
+ newNewsDocument as newArticleDocument,
+ NewsDocument as ArticleDocument,
+} from "../news/newsExtensions"
diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx
new file mode 100644
index 0000000000..ab380a4928
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import React from "react"
+import type { ChangeEventHandler } from "react"
+import type { WebsiteContent } from "api/v1"
+import { ButtonLink } from "@mitodl/smoot-design"
+import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles"
+import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer"
+import { GenericEditor } from "../../core/GenericEditor"
+import { createNewsExtensions, newNewsDocument } from "./newsExtensions"
+
+// News-specific: extract the author name from the byline node in the document
+const extractNewsExtraFields = (content: {
+ content?: Array<{ type?: string; attrs?: Record }>
+}): Record => {
+ const bylineNode = content.content?.find((node) => node.type === "byline")
+ return { author_name: bylineNode?.attrs?.authorName || "" }
+}
+
+interface NewsEditorProps {
+ /** @deprecated unused, kept for API compatibility */
+ value?: object
+ onSave?: (article: WebsiteContent) => void
+ readOnly?: boolean
+ /** @deprecated unused, kept for API compatibility */
+ title?: string
+ /** @deprecated unused, kept for API compatibility */
+ setTitle?: ChangeEventHandler
+ article?: WebsiteContent
+}
+
+/**
+ * Editor shell configured for the news content type (served under /news).
+ * Owns its own save mutations (websiteContent API) and passes them to
+ * GenericEditor — keeping the generic shell decoupled from any specific API.
+ */
+const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => {
+ // News content type uses the websiteContent (articles) API.
+ // A different content type would call different hooks here.
+ const createMutation = useArticleCreate()
+ const updateMutation = useArticlePartialUpdate()
+
+ const editUrl = article
+ ? `/news/${article.is_published ? article.slug : article.id}/edit`
+ : "/news/new"
+
+ const toolbarSlot = readOnly ? (
+ <>
+
+
+ Drafts
+
+
+ Edit
+
+ >
+ ) : null
+
+ return (
+
+ )
+}
+
+export { NewsEditor }
+export type { NewsEditorProps }
diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts
new file mode 100644
index 0000000000..edd34eab87
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts
@@ -0,0 +1,153 @@
+import type { Extension, Node, Mark } from "@tiptap/core"
+import Document from "@tiptap/extension-document"
+import { Placeholder, Selection } from "@tiptap/extensions"
+import { StarterKit } from "@tiptap/starter-kit"
+import { TaskItem, TaskList } from "@tiptap/extension-list"
+import { Heading } from "@tiptap/extension-heading"
+import { Image } from "@tiptap/extension-image"
+import { TextAlign } from "@tiptap/extension-text-align"
+import { Typography as TiptapTypography } from "@tiptap/extension-typography"
+import { Subscript } from "@tiptap/extension-subscript"
+import { Superscript } from "@tiptap/extension-superscript"
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
+import { HorizontalRule } from "../../vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
+import { ImageNode } from "../../extensions/node/Image/ImageNode"
+import { ImageWithCaptionNode } from "../../extensions/node/Image/ImageWithCaptionNode"
+import { DividerNode } from "../../extensions/node/Divider/DividerNode"
+import { ArticleByLineInfoBarNode } from "../../extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode"
+import { LearningResourceNode } from "../../extensions/node/LearningResource/LearningResourceNode"
+import { LearningResourceInputNode } from "../../extensions/node/LearningResource/LearningResourceInputNode"
+import { LearningResourceURLHandler } from "../../extensions/node/LearningResource/LearningResourcePaste"
+import { MediaEmbedURLHandler } from "../../extensions/node/MediaEmbed/MediaEmbedURLHandler"
+import { MediaEmbedNode } from "../../extensions/node/MediaEmbed/MediaEmbedNode"
+import { MediaEmbedInputNode } from "../../extensions/node/MediaEmbed/MediaEmbedInputNode"
+import { BannerNode } from "../../extensions/node/Banner/BannerNode"
+import type { ExtendedNodeConfig } from "../../extensions/node/types"
+import { MAX_FILE_SIZE } from "../../vendor/lib/tiptap-utils"
+import type { CreateExtensionsFn } from "../../core/GenericEditor"
+
+export const NewsDocument = Document.extend({
+ content: "banner byline block+",
+})
+
+export const newNewsDocument = {
+ type: "doc",
+ content: [
+ {
+ type: "banner",
+ content: [
+ {
+ type: "heading",
+ attrs: { level: 1 },
+ content: [],
+ },
+ {
+ type: "paragraph",
+ content: [],
+ },
+ ],
+ },
+ {
+ type: "byline",
+ attrs: { authorName: null },
+ },
+ { type: "paragraph", content: [] },
+ ],
+}
+
+/**
+ * Factory function that builds the full extensions list for the news content type.
+ * Pass to GenericEditor as `createExtensions`.
+ */
+export const createNewsExtensions: CreateExtensionsFn = (
+ uploadHandler,
+ setUploadError,
+): (Extension | Node | Mark)[] => [
+ NewsDocument,
+ StarterKit.configure({
+ document: false,
+ horizontalRule: false,
+ heading: false,
+ link: {
+ openOnClick: false,
+ enableClickSelection: true,
+ },
+ trailingNode: {
+ node: "paragraph",
+ },
+ }),
+ Heading.configure({
+ levels: [1, 2, 3, 4, 5, 6],
+ }),
+ Placeholder.configure({
+ showOnlyCurrent: false,
+ includeChildren: true,
+ 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 a heading"
+ }
+ return "Add some text"
+ },
+ }),
+ HorizontalRule,
+ LearningResourceURLHandler,
+ LearningResourceNode,
+ LearningResourceInputNode,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ TiptapTypography,
+ Superscript,
+ Subscript,
+ Selection,
+ Image,
+ MediaEmbedNode,
+ MediaEmbedInputNode,
+ DividerNode,
+ ArticleByLineInfoBarNode,
+ ImageWithCaptionNode,
+ MediaEmbedURLHandler,
+ ImageNode.configure({
+ accept: "image/*",
+ maxSize: MAX_FILE_SIZE,
+ limit: 3,
+ upload: uploadHandler,
+ onError: (error) => setUploadError(error.message),
+ }),
+ BannerNode,
+]
diff --git a/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx
new file mode 100644
index 0000000000..27a7be5bd3
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx
@@ -0,0 +1,449 @@
+"use client"
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import styled from "@emotion/styled"
+import { EditorContext, JSONContent, useEditor } from "@tiptap/react"
+import type { Extension, Node, Mark } from "@tiptap/core"
+import { getSchema } from "@tiptap/core"
+import type { WebsiteContent } from "api/v1"
+import {
+ LoadingSpinner,
+ Typography,
+ HEADER_HEIGHT,
+ HEADER_HEIGHT_MD,
+} from "ol-components"
+import { Alert, Button } from "@mitodl/smoot-design"
+import { useUserHasPermission, Permission } from "api/hooks/user"
+import { useMediaUpload } from "api/hooks/articles"
+import dynamic from "next/dynamic"
+
+import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar"
+import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor"
+import { handleImageUpload } from "../vendor/lib/tiptap-utils"
+import { useSchema } from "../useSchema"
+import { ArticleProvider } from "../ArticleContext"
+import { extractLearningResourceIds, contentsMatch } from "../extensions/utils"
+import { LearningResourceProvider } from "../extensions/node/LearningResource/LearningResourceDataProvider"
+
+const LearningResourceDrawer = dynamic(
+ () =>
+ import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"),
+ { ssr: false },
+)
+
+const TOOLBAR_HEIGHT = 43
+
+const ViewContainer = styled.div<{ toolbarVisible: boolean }>(
+ ({ toolbarVisible, theme }) => ({
+ width: "100vw",
+ marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0,
+ backgroundColor: theme.custom.colors.white,
+ }),
+)
+
+const StyledToolbar = styled(Toolbar)(({ theme }) => ({
+ "&&": {
+ position: "fixed",
+ top: HEADER_HEIGHT,
+ [theme.breakpoints.down("md")]: {
+ top: HEADER_HEIGHT_MD,
+ },
+ },
+}))
+
+const StyledAlert = styled(Alert)({
+ margin: "20px auto",
+ maxWidth: "1000px",
+ position: "fixed",
+ top: "108px",
+ left: "50%",
+ width: "690px",
+ transform: "translateX(-50%)",
+ zIndex: 1,
+ "p:not(:first-child)": {
+ margin: "10px 0",
+ },
+})
+
+export type UploadHandler = (
+ file: File,
+ onProgress?: (e: { progress: number }) => void,
+ abortSignal?: AbortSignal,
+) => Promise
+
+/**
+ * The data shape sent to the create/update API.
+ * `[key: string]: unknown` allows per-type extra fields (e.g. author_name).
+ */
+export interface SavePayload {
+ title: string
+ content: JSONContent
+ is_published: boolean
+ [key: string]: unknown
+}
+
+/**
+ * Per-type save mutations. Each content type owns its own API hooks and passes
+ * the resulting mutation objects here, so GenericEditor never imports a
+ * specific API hook directly.
+ *
+ * Example — news type uses websiteContent API:
+ * const create = useArticleCreate()
+ * const update = useArticlePartialUpdate()
+ *
+ *
+ * A future user-article type could use a completely different API hook:
+ * const create = useUserArticleCreate()
+ * const update = useUserArticlePartialUpdate()
+ *
+ */
+export interface SaveMutations {
+ create: {
+ mutate: (
+ data: SavePayload,
+ options: { onSuccess?: (result: WebsiteContent) => void },
+ ) => void
+ isPending: boolean
+ error: Error | null | unknown
+ }
+ update: {
+ mutate: (
+ data: SavePayload & { id: number },
+ options: { onSuccess?: (result: WebsiteContent) => void },
+ ) => void
+ isPending: boolean
+ error: Error | null | unknown
+ }
+}
+
+/**
+ * A factory function that builds the Tiptap extensions for a given content type.
+ * Receives upload utilities so extensions that handle image upload can be configured.
+ */
+export type CreateExtensionsFn = (
+ uploadHandler: UploadHandler,
+ setUploadError: (error: string | null) => void,
+) => (Extension | Node | Mark)[]
+
+export interface GenericEditorProps {
+ /**
+ * Factory that builds the full extensions list for this content type.
+ * Must be a stable reference (module-level function or useCallback).
+ */
+ createExtensions: CreateExtensionsFn
+ /** Initial document structure when no article is provided. */
+ initialDoc: JSONContent
+ /**
+ * Content-type-specific toolbar content.
+ * - In read-only mode this slot provides all toolbar items (e.g. Drafts + Edit links).
+ * - In edit mode this slot is appended after the Publish button.
+ */
+ toolbarSlot?: React.ReactNode
+ /** Optional CSS class forwarded to the editor container for per-type theming. */
+ className?: string
+ /**
+ * Extract additional fields to include in the save payload.
+ * E.g., for news: `(content) => ({ author_name: extractAuthorName(content) })`
+ */
+ extractExtraFields?: (content: JSONContent) => Record
+ /**
+ * Mutations for create and update. Provided by the content-type wrapper so
+ * GenericEditor stays decoupled from any specific API endpoint.
+ */
+ saveMutations: SaveMutations
+ onSave?: (article: WebsiteContent) => void
+ readOnly?: boolean
+ article?: WebsiteContent
+}
+
+const GenericEditor = ({
+ createExtensions,
+ initialDoc,
+ toolbarSlot,
+ className,
+ extractExtraFields,
+ saveMutations,
+ onSave,
+ readOnly,
+ article,
+}: GenericEditorProps) => {
+ const [isPublishing, setIsPublishing] = useState(false)
+ const [uploadError, setUploadError] = useState(null)
+ const [resetAttempted, setResetAttempted] = useState(false)
+ const [content, setContent] = useState(
+ article?.content || initialDoc,
+ )
+ const [title, setTitle] = useState(article?.title)
+ const [touched, setTouched] = useState(false)
+
+ const { create: createMutation, update: updateMutation } = saveMutations
+ const isPending = createMutation.isPending || updateMutation.isPending
+ const saveError = createMutation.error || updateMutation.error
+
+ const uploadImage = useMediaUpload()
+ // Keep a ref so the stable uploadHandler callback always calls the latest mutation.
+ const uploadImageRef = useRef(uploadImage)
+ uploadImageRef.current = uploadImage
+
+ const isArticleEditor = useUserHasPermission(Permission.ArticleEditor)
+
+ const uploadHandler = useCallback(
+ async (file, onProgress, abortSignal) => {
+ setUploadError(null)
+ return handleImageUpload(
+ file,
+ async (f, progressCb) => {
+ try {
+ uploadImageRef.current.setNextProgressCallback(progressCb)
+ const response = await uploadImageRef.current.mutateAsync({
+ file: f,
+ })
+ if (!response?.url) throw new Error("Upload failed")
+ return response.url
+ } catch (error) {
+ const msg =
+ error instanceof Error
+ ? error.message
+ : String(error) || "Upload failed"
+ setUploadError(msg)
+ throw error
+ }
+ },
+ onProgress,
+ abortSignal,
+ )
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ )
+
+ const extensions = useMemo(
+ () => createExtensions(uploadHandler, setUploadError),
+ [createExtensions, uploadHandler],
+ )
+
+ const schema = useMemo(() => getSchema(extensions), [extensions])
+
+ const schemaError = useSchema({
+ schema,
+ content,
+ enabled: isArticleEditor,
+ })
+
+ const handleSave = (publish: boolean) => {
+ if (!title) return
+ const extraFields = extractExtraFields?.(content) ?? {}
+ if (article) {
+ updateMutation.mutate(
+ {
+ id: article.id,
+ title: title.trim(),
+ content,
+ is_published: publish,
+ ...extraFields,
+ },
+ { onSuccess: onSave },
+ )
+ } else {
+ createMutation.mutate(
+ {
+ title: title.trim(),
+ content,
+ is_published: publish,
+ ...extraFields,
+ },
+ { onSuccess: onSave },
+ )
+ }
+ }
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ shouldRerenderOnTransaction: false,
+ content,
+ editable: !readOnly,
+
+ onUpdate: ({ editor }) => {
+ const json = editor.getJSON()
+ setContent(json)
+ setTouched(true)
+ },
+
+ onCreate: ({ editor }) => {
+ setTimeout(() => {
+ editor.commands.setTextSelection(1)
+ editor.commands.focus()
+ }, 0)
+
+ editor.commands.updateAttributes("mediaEmbed", { editable: !readOnly })
+ editor.commands.updateAttributes("byline", { editable: readOnly })
+ },
+
+ editorProps: {
+ attributes: {
+ autocomplete: "off",
+ autocorrect: "off",
+ autocapitalize: "off",
+ "aria-label": "Main content area, start typing to enter text.",
+ class: "simple-editor",
+ },
+ },
+ extensions,
+ })
+
+ // Sync incoming article changes (e.g., after a refetch)
+ useEffect(() => {
+ if (!article || !editor) return
+
+ if (article.content) {
+ const currentContent = editor.getJSON()
+ if (!contentsMatch(article.content, currentContent)) {
+ setContent(article.content)
+ setTouched(true)
+ editor.commands.setContent(article.content)
+ }
+ }
+
+ if (article.title !== undefined) {
+ setTitle(article.title)
+ }
+ }, [article, editor])
+
+ // Keep title in sync with the h1 heading inside the editor
+ useEffect(() => {
+ if (!editor) return
+ const headingTitle =
+ editor.$node("heading", { level: 1 })?.textContent || ""
+ setTitle(headingTitle)
+ }, [editor, content])
+
+ // Propagate readOnly changes to interactive node attrs
+ useEffect(() => {
+ if (!editor) return
+ editor
+ .chain()
+ .command(({ tr, state }) => {
+ state.doc.descendants((node, pos) => {
+ if (
+ node.type.name === "mediaEmbed" ||
+ node.type.name === "imageWithCaption" ||
+ node.type.name === "byline" ||
+ node.type.name === "learningResource"
+ ) {
+ tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ editable: !readOnly,
+ })
+ }
+ })
+ return true
+ })
+ .run()
+ }, [editor, readOnly])
+
+ if (!editor) return null
+
+ const error = saveError || uploadError || schemaError
+ const errorMessage =
+ error instanceof Error ? error.message : (error as string | null)
+ const resourceIds = extractLearningResourceIds(content)
+
+ return (
+
+
+
+
+ {isArticleEditor ? (
+ readOnly ? (
+ {toolbarSlot}
+ ) : (
+
+
+ {!article?.is_published ? (
+
+ ) : null}
+
+ {toolbarSlot}
+
+ )
+ ) : null}
+
+ {error ? (
+
+
+ {errorMessage}
+
+ {schemaError && !readOnly ? (
+ <>
+
+ Reset to attempt to align the article to the content
+ template.
+
+ {resetAttempted ? (
+
+ Reset attempt failed.
+
+ ) : null}
+
+ >
+ ) : null}
+
+ ) : null}
+
+ {readOnly ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+export { GenericEditor }
diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts
index 60304b2ea2..ce26ad9bdb 100644
--- a/frontends/main/src/page-components/TiptapEditor/index.ts
+++ b/frontends/main/src/page-components/TiptapEditor/index.ts
@@ -1 +1,8 @@
export { ArticleEditor } from "./ArticleEditor"
+export { NewsEditor } from "./contentTypes/news/NewsEditor"
+export { ArticleEditor as UserArticleEditor } from "./contentTypes/article/ArticleEditor"
+export { GenericEditor } from "./core/GenericEditor"
+export type {
+ GenericEditorProps,
+ CreateExtensionsFn,
+} from "./core/GenericEditor"
diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts
index 5e192b7265..ddbe4c3f17 100644
--- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts
+++ b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts
@@ -1,38 +1,13 @@
"use client"
import { useMemo } from "react"
-import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
import { getSchema } from "@tiptap/core"
import { useSchema } from "./useSchema"
-import Document from "@tiptap/extension-document"
-import { Placeholder, Selection } from "@tiptap/extensions"
-import { StarterKit } from "@tiptap/starter-kit"
-import { TaskItem, TaskList } from "@tiptap/extension-list"
-import { Heading } from "@tiptap/extension-heading"
-import { Image } from "@tiptap/extension-image"
-import { TextAlign } from "@tiptap/extension-text-align"
-import { Typography as TiptapTypography } from "@tiptap/extension-typography"
-import { Subscript } from "@tiptap/extension-subscript"
-import { Superscript } from "@tiptap/extension-superscript"
import type { JSONContent } from "@tiptap/react"
-import { HorizontalRule } from "./vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
-import { ImageNode } from "./extensions/node/Image/ImageNode"
-import { ImageWithCaptionNode } from "./extensions/node/Image/ImageWithCaptionNode"
-import { DividerNode } from "./extensions/node/Divider/DividerNode"
-import { ArticleByLineInfoBarNode } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode"
-import { LearningResourceNode } from "./extensions/node/LearningResource/LearningResourceNode"
-import { LearningResourceInputNode } from "./extensions/node/LearningResource/LearningResourceInputNode"
-import { LearningResourceURLHandler } from "./extensions/node/LearningResource/LearningResourcePaste"
-import { MediaEmbedURLHandler } from "./extensions/node/MediaEmbed/MediaEmbedURLHandler"
-import { MediaEmbedNode } from "./extensions/node/MediaEmbed/MediaEmbedNode"
-import { MediaEmbedInputNode } from "./extensions/node/MediaEmbed/MediaEmbedInputNode"
-import { BannerNode } from "./extensions/node/Banner/BannerNode"
-import type { ExtendedNodeConfig } from "./extensions/node/types"
-import { MAX_FILE_SIZE } from "./vendor/lib/tiptap-utils"
-
-const ArticleDocument = Document.extend({
- content: "banner byline block+",
-})
+import {
+ createNewsExtensions,
+ newNewsDocument,
+} from "./contentTypes/news/newsExtensions"
interface UseArticleSchemaOptions {
uploadHandler: (
@@ -45,30 +20,8 @@ interface UseArticleSchemaOptions {
content: JSONContent
}
-export const newArticleDocument = {
- type: "doc",
- content: [
- {
- type: "banner",
- content: [
- {
- type: "heading",
- attrs: { level: 1 },
- content: [],
- },
- {
- type: "paragraph",
- content: [],
- },
- ],
- },
- {
- type: "byline",
- attrs: { authorName: null },
- },
- { type: "paragraph", content: [] },
- ],
-}
+/** @deprecated Use newNewsDocument from contentTypes/news/newsExtensions */
+export const newArticleDocument = newNewsDocument
export const useArticleSchema = ({
uploadHandler,
@@ -77,95 +30,7 @@ export const useArticleSchema = ({
content,
}: UseArticleSchemaOptions) => {
const extensions = useMemo(
- () => [
- ArticleDocument,
- StarterKit.configure({
- document: false, // Disable default document to use our ArticleDocument
- horizontalRule: false,
- heading: false,
- link: {
- openOnClick: false,
- enableClickSelection: true,
- },
- trailingNode: {
- node: "paragraph",
- },
- }),
- Heading.configure({
- levels: [1, 2, 3, 4, 5, 6],
- }),
- Placeholder.configure({
- showOnlyCurrent: false,
- includeChildren: true,
- 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 a heading"
- }
- return "Add some text"
- },
- }),
- HorizontalRule,
- LearningResourceURLHandler,
- LearningResourceNode,
- LearningResourceInputNode,
- TextAlign.configure({ types: ["heading", "paragraph"] }),
- TaskList,
- TaskItem.configure({ nested: true }),
- TiptapTypography,
- Superscript,
- Subscript,
- Selection,
- Image,
- MediaEmbedNode,
- MediaEmbedInputNode,
- DividerNode,
- ArticleByLineInfoBarNode,
- ImageWithCaptionNode,
- MediaEmbedURLHandler,
- ImageNode.configure({
- accept: "image/*",
- maxSize: MAX_FILE_SIZE,
- limit: 3,
- upload: uploadHandler,
- onError: (error) => setUploadError(error.message),
- }),
- BannerNode,
- ],
+ () => createNewsExtensions(uploadHandler, setUploadError),
[uploadHandler, setUploadError],
)