From 366c5cbff6460b205298699b8fa0940698807c0b Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Thu, 14 May 2026 15:47:59 +0500 Subject: [PATCH] refactor: make internal editor code more generic (Second Part) --- .../UserArticles/UserArticleDetailPage.tsx | 52 ++ .../UserArticleDraftListingPage.tsx | 182 +++++++ .../UserArticles/UserArticleEditPage.tsx | 62 +++ .../UserArticles/UserArticleListingPage.tsx | 177 +++++++ .../UserArticles/UserArticleNewPage.tsx | 39 ++ .../app/articles/[slugOrId]/draft/page.tsx | 25 + .../src/app/articles/[slugOrId]/edit/page.tsx | 12 + .../main/src/app/articles/[slugOrId]/page.tsx | 95 ++++ .../main/src/app/articles/draft/page.tsx | 15 + frontends/main/src/app/articles/new/page.tsx | 15 + frontends/main/src/app/articles/page.tsx | 15 + frontends/main/src/common/urls.ts | 13 + .../TiptapEditor/ArticleEditor.tsx | 403 +--------------- .../contentTypes/article/ArticleEditor.tsx | 83 ++++ .../contentTypes/article/articleExtensions.ts | 11 + .../contentTypes/news/NewsEditor.tsx | 74 +++ .../contentTypes/news/newsExtensions.ts | 153 ++++++ .../TiptapEditor/core/GenericEditor.tsx | 449 ++++++++++++++++++ .../src/page-components/TiptapEditor/index.ts | 7 + .../TiptapEditor/useArticleSchema.ts | 149 +----- 20 files changed, 1494 insertions(+), 537 deletions(-) create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx create mode 100644 frontends/main/src/app/articles/[slugOrId]/draft/page.tsx create mode 100644 frontends/main/src/app/articles/[slugOrId]/edit/page.tsx create mode 100644 frontends/main/src/app/articles/[slugOrId]/page.tsx create mode 100644 frontends/main/src/app/articles/draft/page.tsx create mode 100644 frontends/main/src/app/articles/new/page.tsx create mode 100644 frontends/main/src/app/articles/page.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts create mode 100644 frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx 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], )