diff --git a/frontends/api/src/hooks/articles/index.test.ts b/frontends/api/src/hooks/articles/index.test.ts index a186aa30e9..3f8a65d9d0 100644 --- a/frontends/api/src/hooks/articles/index.test.ts +++ b/frontends/api/src/hooks/articles/index.test.ts @@ -90,7 +90,7 @@ describe("Article CRUD", () => { const { id, ...patchData } = article expect(makeRequest).toHaveBeenCalledWith("patch", url, patchData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.root, + queryKey: articleKeys.detail(article.id), }) }) diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/articles/index.ts index 202ba0fe7d..a55a122d74 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/articles/index.ts @@ -18,7 +18,7 @@ const useArticleList = ( } /** - * Query is diabled if id is undefined. + * Query is disabled if id is undefined. */ const useArticleDetail = (id: number | undefined) => { return useQuery({ @@ -58,8 +58,8 @@ const useArticlePartialUpdate = () => { PatchedRichTextArticleRequest: data, }) .then((response) => response.data), - onSuccess: (_data) => { - client.invalidateQueries({ queryKey: articleKeys.root }) + onSuccess: (article: Article) => { + client.invalidateQueries({ queryKey: articleKeys.detail(article.id) }) }, }) } diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx deleted file mode 100644 index 6832e53b57..0000000000 --- a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client" - -import React from "react" -import { TiptapEditor, theme, styled, HEADER_HEIGHT } from "ol-components" -import { Permission } from "api/hooks/user" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" - -const PageContainer = styled.div({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`, -}) - -const EditorContainer = styled.div({ - minHeight: 0, -}) - -const StyledTiptapEditor = styled(TiptapEditor)({ - width: "70vw", - height: `calc(100% - ${HEADER_HEIGHT}px - 132px)`, - overscrollBehavior: "contain", -}) - -const NewArticlePage: React.FC = () => { - return ( - - - - - - - - ) -} - -export { NewArticlePage } diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx index 950aeceba6..4bbad45876 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx @@ -2,66 +2,27 @@ import React from "react" import { useArticleDetail } from "api/hooks/articles" -import { - Container, - LoadingSpinner, - styled, - Typography, - TiptapEditorContainer, -} from "ol-components" -import { ButtonLink } from "@mitodl/smoot-design" +import { LoadingSpinner, ArticleEditor } from "ol-components" import { notFound } from "next/navigation" import { Permission } from "api/hooks/user" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { articlesEditView } from "@/common/urls" - -const Page = styled(Container)({ - marginTop: "40px", - marginBottom: "40px", -}) - -const ControlsContainer = styled.div({ - display: "flex", - justifyContent: "flex-end", - margin: "10px", -}) -const WrapperContainer = styled.div({ - borderBottom: "1px solid rgb(222, 208, 208)", - paddingBottom: "10px", -}) export const ArticleDetailPage = ({ articleId }: { articleId: number }) => { - const id = Number(articleId) - const { data, isLoading } = useArticleDetail(id) + const { + data: article, + isLoading, + isFetching, + } = useArticleDetail(Number(articleId)) - const editUrl = articlesEditView(id) - - if (isLoading) { - return + if (isLoading || isFetching) { + return } - if (!data) { + if (!article) { return notFound() } return ( - - - - {data?.title} - - - - - Edit - - - - - + ) } diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx index e2d95495ab..f0677a99c9 100644 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx @@ -2,14 +2,14 @@ import React from "react" import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { waitFor, fireEvent } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { factories, urls } from "api/test-utils" +import { factories, urls, makeRequest } from "api/test-utils" import { ArticleEditPage } from "./ArticleEditPage" -const pushMock = jest.fn() +const mockPush = jest.fn() jest.mock("next-nprogress-bar", () => ({ useRouter: () => ({ - push: pushMock, + push: mockPush, }), })) @@ -38,12 +38,12 @@ describe("ArticleEditPage", () => { renderWithProviders() - expect(await screen.findByText("Edit Article")).toBeInTheDocument() - expect(screen.getByTestId("editor")).toBeInTheDocument() + await screen.findByTestId("editor") + expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument() }) - test("submits article successfully and redirects", async () => { + test("submits article successfully", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, @@ -65,22 +65,30 @@ describe("ArticleEditPage", () => { }) setMockResponse.get(urls.articles.details(article.id), article) - // ✅ Mock successful update response const updated = { ...article, title: "Updated Title" } setMockResponse.patch(urls.articles.details(article.id), updated) renderWithProviders() - const titleInput = await screen.findByPlaceholderText("Enter article title") + await screen.findByTestId("editor") + + const titleInput = await screen.findByPlaceholderText("Article title") fireEvent.change(titleInput, { target: { value: "Updated Title" } }) await waitFor(() => expect(titleInput).toHaveValue("Updated Title")) - await userEvent.click(screen.getByText(/save article/i)) + await userEvent.click(screen.getByRole("button", { name: "Save" })) - // ✅ Wait for redirect after update success - await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/articles/123")) + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + "patch", + urls.articles.details(article.id), + expect.objectContaining({ title: "Updated Title" }), + ), + ) + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/articles/123")) }) test("shows error alert on failure", async () => { @@ -105,7 +113,6 @@ describe("ArticleEditPage", () => { }) setMockResponse.get(urls.articles.details(article.id), article) - // ✅ Mock failed update (500) setMockResponse.patch( urls.articles.details(article.id), { detail: "Server Error" }, @@ -114,10 +121,12 @@ describe("ArticleEditPage", () => { renderWithProviders() - const titleInput = await screen.findByPlaceholderText("Enter article title") + await screen.findByTestId("editor") + + const titleInput = await screen.findByPlaceholderText("Article title") fireEvent.change(titleInput, { target: { value: "Bad Article" } }) - await userEvent.click(screen.getByText(/save article/i)) + await userEvent.click(screen.getByRole("button", { name: "Save" })) expect(await screen.findByText(/Mock Error/i)).toBeInTheDocument() }) diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx index 22cb88b48b..9ee00c088f 100644 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx @@ -1,125 +1,50 @@ "use client" -import React, { useEffect, useState } from "react" -import { Permission } from "api/hooks/user" + +import React from "react" import { useRouter } from "next-nprogress-bar" -import { useArticleDetail, useArticlePartialUpdate } from "api/hooks/articles" -import { Button, Alert } from "@mitodl/smoot-design" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import { useArticleDetail } from "api/hooks/articles" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { - Container, - Typography, styled, LoadingSpinner, - TiptapEditorContainer, - JSONContent, + ArticleEditor, + HEADER_HEIGHT, } from "ol-components" - -import { notFound } from "next/navigation" import { articlesView } from "@/common/urls" -const SaveButton = styled.div({ - textAlign: "right", - margin: "10px", -}) - -const ClientContainer = styled.div({ - width: "100%", - margin: "10px 0", -}) +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`, +})) const ArticleEditPage = ({ articleId }: { articleId: string }) => { + const { + data: article, + isLoading, + isFetching, + } = useArticleDetail(Number(articleId)) const router = useRouter() - const id = Number(articleId) - const { data: article, isLoading } = useArticleDetail(id) - - const [title, setTitle] = useState("") - const [json, setJson] = useState({ - type: "doc", - content: [{ type: "paragraph", content: [] }], - }) - const [alertText, setAlertText] = useState("") - - const { mutate: updateArticle, isPending } = useArticlePartialUpdate() - - const handleSave = () => { - const payload = { - id: id, - title: title.trim(), - content: json, - } - - updateArticle(payload, { - onSuccess: (article) => { - router.push(articlesView(article.id)) - }, - onError: (error) => { - setAlertText(`❌ ${error.message}`) - }, - }) - } - - useEffect(() => { - if (article && !title) { - setTitle(article.title) - setJson(article.content) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [article]) - - if (isLoading) { + if (isLoading || isFetching) { return } if (!article) { return notFound() } - const handleChange = (json: object) => { - setJson(json) - } - return ( - - - Edit Article - - {alertText && ( - - - {alertText} - - - )} - - - { - setTitle(e.target.value) - setAlertText("") - }} - /> - - - - - - + + { + router.push(articlesView(article.id)) + }} + /> + ) } diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx index dabe0cae81..81918bfce0 100644 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx @@ -1,108 +1,30 @@ "use client" -import React, { useState } from "react" -import { Permission } from "api/hooks/user" + +import React from "react" import { useRouter } from "next-nprogress-bar" -import { useArticleCreate } from "api/hooks/articles" -import { Button, Alert } from "@mitodl/smoot-design" +import { Permission } from "api/hooks/user" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { - TiptapEditorContainer, - Container, - Typography, - styled, - JSONContent, -} from "ol-components" +import { ArticleEditor, styled, HEADER_HEIGHT } from "ol-components" import { articlesView } from "@/common/urls" -const SaveButton = styled.div({ - textAlign: "right", - margin: "10px", -}) - -const ClientContainer = styled.div({ - width: "100%", - margin: "10px 0", -}) +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`, +})) const ArticleNewPage: React.FC = () => { const router = useRouter() - const [title, setTitle] = React.useState("") - const [json, setJson] = useState({ - type: "doc", - content: [{ type: "paragraph", content: [] }], - }) - const [alertText, setAlertText] = React.useState("") - - const { mutate: createArticle, isPending } = useArticleCreate() - - const handleSave = () => { - setAlertText("") - - const payload = { - title: title.trim(), - content: json, - } - - createArticle( - payload as { - content: object - title: string - }, - { - onSuccess: (article) => { - articlesView(article.id) - router.push(articlesView(article.id)) - }, - onError: (error) => { - setAlertText(`❌ ${error?.message}`) - }, - }, - ) - } - const handleChange = (json: object) => { - setJson(json) - } - return ( - -

Write Article

- {alertText && ( - - - {alertText} - - - )} - - - { - setTitle(e.target.value) - setAlertText("") - }} - onChange={handleChange} - /> - - - - -
+ + { + router.push(articlesView(article.id)) + }} + /> +
) } diff --git a/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx b/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx index f60b6d7cf7..73eb936c74 100644 --- a/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx +++ b/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx @@ -10,10 +10,10 @@ import userEvent from "@testing-library/user-event" import { factories, urls } from "api/test-utils" import { ArticleNewPage } from "./ArticleNewPage" -const pushMock = jest.fn() +const mockPush = jest.fn() jest.mock("next/navigation", () => ({ useRouter: () => ({ - push: pushMock, + push: mockPush, }), })) @@ -50,8 +50,7 @@ describe("ArticleNewPage", () => { renderWithProviders() - expect(await screen.findByText("Write Article")).toBeInTheDocument() - expect(screen.getByTestId("editor")).toBeInTheDocument() + await screen.findByTestId("editor") }) test("submits article successfully", async () => { @@ -61,7 +60,6 @@ describe("ArticleNewPage", () => { }) setMockResponse.get(urls.userMe.get(), user) - // Mock article creation API const createdArticle = factories.articles.article({ id: 101 }) setMockResponse.post(urls.articles.list(), createdArticle) @@ -69,14 +67,14 @@ describe("ArticleNewPage", () => { await screen.findByTestId("editor") - const titleInput = await screen.findByPlaceholderText("Enter article title") + const titleInput = await screen.findByPlaceholderText("Article title") fireEvent.change(titleInput, { target: { value: "My Article" } }) await waitFor(() => expect(titleInput).toHaveValue("My Article")) - await userEvent.click(screen.getByText(/save article/i)) + await userEvent.click(screen.getByRole("button", { name: "Save" })) await waitFor(() => - expect(pushMock).toHaveBeenCalledWith("/articles/101", undefined), + expect(mockPush).toHaveBeenCalledWith("/articles/101", undefined), ) }) @@ -87,7 +85,6 @@ describe("ArticleNewPage", () => { }) setMockResponse.get(urls.userMe.get(), user) - // Simulate failed API request (500) setMockResponse.post( urls.articles.list(), { detail: "Server error" }, @@ -96,13 +93,15 @@ describe("ArticleNewPage", () => { renderWithProviders() - await screen.findByPlaceholderText("Enter article title") + await screen.findByTestId("editor") + + await screen.findByPlaceholderText("Article title") - fireEvent.change(screen.getByPlaceholderText("Enter article title"), { + fireEvent.change(screen.getByPlaceholderText("Article title"), { target: { value: "My Article" }, }) await userEvent.click(screen.getByTestId("editor")) - await userEvent.click(screen.getByText("Save Article")) + await userEvent.click(screen.getByRole("button", { name: "Save" })) expect(await screen.findByText(/Mock Error/)).toBeInTheDocument() }) diff --git a/frontends/main/src/app/article/new/page.tsx b/frontends/main/src/app/article/new/page.tsx deleted file mode 100644 index d0694149d1..0000000000 --- a/frontends/main/src/app/article/new/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { NewArticlePage } from "@/app-pages/ArticlePage/NewArticlePage" - -export const metadata: Metadata = standardizeMetadata({ - title: "New Article", - 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 a468eab4b3..8843084378 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -24,11 +24,11 @@ export const PROGRAMLETTER_VIEW = "/program_letter/[id]/view/" export const programLetterView = (id: string) => generatePath(PROGRAMLETTER_VIEW, { id: String(id) }) export const ARTICLES_LISTING = "/articles/" -export const ARTICLES_DETAILS = "/articles/[id]" +export const ARTICLES_VIEW = "/articles/[id]" export const ARTICLES_EDIT = "/articles/[id]/edit" export const ARTICLES_CREATE = "/articles/new" export const articlesView = (id: number) => - generatePath(ARTICLES_DETAILS, { id: String(id) }) + generatePath(ARTICLES_VIEW, { id: String(id) }) export const articlesEditView = (id: number) => generatePath(ARTICLES_EDIT, { id: String(id) }) diff --git a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx new file mode 100644 index 0000000000..088f408551 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx @@ -0,0 +1,258 @@ +"use client" + +// Based on ./components/tiptap-templates/simple/simple-editor.tsx + +import React, { ChangeEventHandler, useState } from "react" +import styled from "@emotion/styled" +import { EditorContext, JSONContent, useEditor } from "@tiptap/react" + +// --- Tiptap Core Extensions --- +import { StarterKit } from "@tiptap/starter-kit" +import { TaskItem, TaskList } from "@tiptap/extension-list" + +import { Image } from "@tiptap/extension-image" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography as TiptapTypography } from "@tiptap/extension-typography" +import { Highlight } from "@tiptap/extension-highlight" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" + +import { Selection } from "@tiptap/extensions" + +// --- UI Primitives --- +import { Toolbar } from "./components/tiptap-ui-primitive/toolbar" +import { Spacer } from "./components/tiptap-ui-primitive/spacer" + +import TiptapEditor, { MainToolbarContent } from "./TiptapEditor" + +// --- Tiptap Node --- +import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension" +import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" + +import "./components/tiptap-node/blockquote-node/blockquote-node.scss" +import "./components/tiptap-node/code-block-node/code-block-node.scss" +import "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss" +import "./components/tiptap-node/list-node/list-node.scss" +import "./components/tiptap-node/image-node/image-node.scss" +import "./components/tiptap-node/heading-node/heading-node.scss" +import "./components/tiptap-node/paragraph-node/paragraph-node.scss" + +// --- Lib --- +import { handleImageUpload, MAX_FILE_SIZE } from "./lib/tiptap-utils" + +// --- Styles --- +import "./styles/_keyframe-animations.scss" +import "./styles/_variables.scss" +import "./components/tiptap-templates/simple/simple-editor.scss" + +import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" +import type { RichTextArticle } from "api/v1" +import { Alert, Button, ButtonLink, Input } from "@mitodl/smoot-design" +import Typography, { TypographyProps } from "@mui/material/Typography" +import Container from "@mui/material/Container" +import { useUserHasPermission, Permission } from "api/hooks/user" + +const ViewContainer = styled.div({ + width: "100vw", + height: "calc(100vh - 204px)", + overflow: "scroll", +}) + +const Title = styled(Typography)({ + margin: "60px auto", +}) + +const TitleInput = styled(Input)({ + width: "100%", + maxWidth: "1000px", + margin: "10px auto", + display: "block-flex", +}) + +const StyledToolbar = styled(Toolbar)({ + "&&": { + position: "fixed", + top: "72px", + }, +}) + +const StyledContainer = styled(Container)({ + marginTop: "60px", +}) + +const StyledAlert = styled(Alert)({ + margin: "0 auto 20px", + maxWidth: "1000px", +}) + +interface ArticleEditorProps { + value?: object + onSave?: (article: RichTextArticle) => void + readOnly?: boolean + title?: string + setTitle?: ChangeEventHandler + article?: RichTextArticle +} +const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { + const [title, setTitle] = React.useState(article?.title || "") + const { + mutate: createArticle, + isPending: isCreating, + isError: isCreateError, + error: createError, + } = useArticleCreate() + const { + mutate: updateArticle, + isPending: isUpdating, + isError: isUpdateError, + error: updateError, + } = useArticlePartialUpdate() + const isArticleEditor = useUserHasPermission(Permission.ArticleEditor) + + const [content, setContent] = useState( + article?.content || { + type: "doc", + content: [{ type: "paragraph", content: [] }], + }, + ) + const [touched, setTouched] = useState(false) + + const handleSave = () => { + if (article) { + updateArticle( + { + id: article.id, + title: title.trim(), + content, + }, + { + onSuccess: onSave, + }, + ) + } else { + createArticle( + { + title: title.trim(), + content, + }, + { + onSuccess: onSave, + }, + ) + } + } + + const editor = useEditor({ + immediatelyRender: false, + shouldRerenderOnTransaction: false, + content, + editable: !readOnly, + onUpdate: ({ editor }) => { + const json = editor.getJSON() + setTouched(true) + setContent(json) + }, + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions: [ + StarterKit.configure({ + horizontalRule: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + }), + HorizontalRule, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + Highlight.configure({ multicolor: true }), + TiptapTypography, + Superscript, + Subscript, + Selection, + Image, + ImageUploadNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: handleImageUpload, + onError: (error) => console.error("Upload failed:", error), + }), + ], + }) + + if (!editor) return null + + const isPending = isCreating || isUpdating + const isError = isCreateError || isUpdateError + const error = createError || updateError + + return ( + + + {isArticleEditor ? ( + readOnly ? ( + + + + Edit + + + ) : ( + + + + + ) + ) : null} + + {isError && ( + + + {error?.message ?? "An error occurred while saving"} + + + )} + {readOnly ? ( + + {article?.title} + + ) : ( + { + setTitle(e.target.value) + setTouched(true) + }} + placeholder="Article title" + className="input-field" + /> + )} + + + + + ) +} + +export { ArticleEditor } diff --git a/frontends/ol-components/src/components/TiptapEditor/EditorContainer.tsx b/frontends/ol-components/src/components/TiptapEditor/EditorContainer.tsx deleted file mode 100644 index 0b106b4350..0000000000 --- a/frontends/ol-components/src/components/TiptapEditor/EditorContainer.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client" - -// Based on ./components/tiptap-templates/simple/simple-editor.tsx - -import React, { useRef, useEffect, ChangeEventHandler } from "react" -import { EditorContext, useEditor } from "@tiptap/react" - -// --- Tiptap Core Extensions --- -import { StarterKit } from "@tiptap/starter-kit" -import { TaskItem, TaskList } from "@tiptap/extension-list" - -import { Image } from "@tiptap/extension-image" -import { TextAlign } from "@tiptap/extension-text-align" -import { Typography } from "@tiptap/extension-typography" -import { Highlight } from "@tiptap/extension-highlight" -import { Subscript } from "@tiptap/extension-subscript" -import { Superscript } from "@tiptap/extension-superscript" -import { Input } from "@mitodl/smoot-design" - -import styled from "@emotion/styled" - -import { Selection } from "@tiptap/extensions" - -// --- UI Primitives --- -import { Toolbar } from "./components/tiptap-ui-primitive/toolbar" - -import TiptapEditor, { MainToolbarContent } from "./TiptapEditor" - -// --- Tiptap Node --- -import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension" -import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" - -import "./components/tiptap-node/blockquote-node/blockquote-node.scss" -import "./components/tiptap-node/code-block-node/code-block-node.scss" -import "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss" -import "./components/tiptap-node/list-node/list-node.scss" -import "./components/tiptap-node/image-node/image-node.scss" -import "./components/tiptap-node/heading-node/heading-node.scss" -import "./components/tiptap-node/paragraph-node/paragraph-node.scss" - -// --- Lib --- -import { handleImageUpload, MAX_FILE_SIZE } from "./lib/tiptap-utils" - -// --- Styles --- -import "./styles/_keyframe-animations.scss" -import "./styles/_variables.scss" -import "./components/tiptap-templates/simple/simple-editor.scss" - -const TitleInput = styled(Input)({ - width: "100%", - margin: "10px 0", -}) - -interface SimpleEditorProps { - value?: object - onChange?: (json: object) => void - readOnly?: boolean - title?: string - setTitle?: ChangeEventHandler - "data-testid"?: string -} -export default function SimpleEditor({ - value, - onChange, - readOnly, - setTitle, - title, - "data-testid": testId, -}: SimpleEditorProps) { - const toolbarRef = useRef(null) - - const editor = useEditor({ - immediatelyRender: false, - shouldRerenderOnTransaction: false, - content: value || { - type: "doc", - content: [{ type: "paragraph", content: [] }], - }, - editable: !readOnly, - onUpdate: ({ editor }) => { - if (!readOnly) { - const json = editor.getJSON() - onChange?.(json) - } - }, - editorProps: { - attributes: { - autocomplete: "off", - autocorrect: "off", - autocapitalize: "off", - "aria-label": "Main content area, start typing to enter text.", - class: "simple-editor", - }, - }, - extensions: [ - StarterKit.configure({ - horizontalRule: false, - link: { - openOnClick: false, - enableClickSelection: true, - }, - }), - HorizontalRule, - TextAlign.configure({ types: ["heading", "paragraph"] }), - TaskList, - TaskItem.configure({ nested: true }), - Highlight.configure({ multicolor: true }), - Typography, - Superscript, - Subscript, - Selection, - Image, - ImageUploadNode.configure({ - accept: "image/*", - maxSize: MAX_FILE_SIZE, - limit: 3, - upload: handleImageUpload, - onError: (error) => console.error("Upload failed:", error), - }), - ], - }) - - // 👇 Important: update content when fetched JSON changes - useEffect(() => { - if (editor && value) { - editor.commands.setContent(value) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor]) - - return ( -
- - {!readOnly && ( - - - - )} - - {!readOnly && ( - - )} - - - -
- ) -} diff --git a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx index 9b0220ea83..e1451bdc60 100644 --- a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx @@ -3,8 +3,9 @@ // Based on ./components/tiptap-templates/simple/simple-editor.tsx import React from "react" +import styled from "@emotion/styled" import { EditorContent } from "@tiptap/react" - +import type { Editor } from "@tiptap/core" import { ImageUploadButton } from "./components/tiptap-ui/image-upload-button" // --- UI Primitives --- @@ -39,6 +40,25 @@ import "./styles/_keyframe-animations.scss" import "./styles/_variables.scss" import "./components/tiptap-templates/simple/simple-editor.scss" +const StyledEditorContent = styled(EditorContent)<{ readOnly: boolean }>( + ({ theme, readOnly }) => ({ + maxWidth: "1000px", + minHeight: "calc(100vh - 350px)", + backgroundColor: theme.custom.colors.white, + borderRadius: "10px", + margin: "20px auto", + ...(readOnly + ? { + maxWidth: "100%", + backgroundColor: "transparent", + ".tiptap.ProseMirror.simple-editor": { + padding: "0", + }, + } + : {}), + }), +) + export const MainToolbarContent = () => { return ( <> @@ -94,19 +114,23 @@ export const MainToolbarContent = () => { ) } -interface SimpleEditorProps { - value?: object - onChange?: (json: object) => void +interface TiptapEditorProps { + editor: Editor readOnly?: boolean - // eslint-disable-next-line @typescript-eslint/no-explicit-any - editor?: any + className?: string } -export default function SimpleEditor({ readOnly, editor }: SimpleEditorProps) { + +export default function TiptapEditor({ + editor, + readOnly, + className, +}: TiptapEditorProps) { return ( - ) } diff --git a/frontends/ol-components/src/components/TiptapEditor/index.ts b/frontends/ol-components/src/components/TiptapEditor/index.ts new file mode 100644 index 0000000000..60304b2ea2 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/index.ts @@ -0,0 +1 @@ +export { ArticleEditor } from "./ArticleEditor" diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 8528e62f4c..ef27b4aa64 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -171,9 +171,7 @@ export * from "./components/ThemeProvider/MITLearnGlobalStyles" export { AppRouterCacheProvider as NextJsAppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter" -export { default as TiptapEditor } from "./components/TiptapEditor/TiptapEditor" -export { default as TiptapEditorContainer } from "./components/TiptapEditor/EditorContainer" -export type { JSONContent } from "@tiptap/core" +export * from "./components/TiptapEditor" // /** // * @deprecated Please use component from @mitodl/smoot-design instead