diff --git a/frontends/api/src/hooks/articles/queries.ts b/frontends/api/src/hooks/articles/queries.ts deleted file mode 100644 index 38d7193479..0000000000 --- a/frontends/api/src/hooks/articles/queries.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { queryOptions } from "@tanstack/react-query" -import { websiteContentApi } from "../../clients" -import type { WebsiteContentApiWebsiteContentListRequest as ArticleListRequest } from "../../generated/v1" - -const articleKeys = { - root: ["articles"], - listRoot: () => [...articleKeys.root, "list"], - list: (params: ArticleListRequest) => [...articleKeys.listRoot(), params], - detailRoot: () => [...articleKeys.root, "detail"], - detail: (id: number) => [...articleKeys.detailRoot(), id], - articlesDetailRetrieve: (identifier: string) => [ - ...articleKeys.detailRoot(), - identifier, - ], -} - -const articleQueries = { - list: (params: ArticleListRequest) => - queryOptions({ - queryKey: articleKeys.list(params), - queryFn: () => - websiteContentApi.websiteContentList(params).then((res) => res.data), - }), - detail: (id: number) => - queryOptions({ - queryKey: articleKeys.detail(id), - queryFn: () => - websiteContentApi - .websiteContentRetrieve({ id }) - .then((res) => res.data), - }), - articlesDetailRetrieve: (identifier: string) => - queryOptions({ - queryKey: articleKeys.articlesDetailRetrieve(identifier), - queryFn: () => - websiteContentApi - .websiteContentDetailRetrieve({ identifier }) - .then((res) => res.data), - }), -} - -export { articleQueries, articleKeys } diff --git a/frontends/api/src/hooks/user/index.ts b/frontends/api/src/hooks/user/index.ts index 603246e399..be8c99e3ee 100644 --- a/frontends/api/src/hooks/user/index.ts +++ b/frontends/api/src/hooks/user/index.ts @@ -3,6 +3,10 @@ import type { User } from "../../generated/v0/api" import { userQueries } from "./queries" enum Permission { + /** + * Controls access to all website_content types (both "news" and "article" + * content_type). Despite the name, this is not limited to article content. + */ ArticleEditor = "is_article_editor", Authenticated = "is_authenticated", LearningPathEditor = "is_learning_path_editor", diff --git a/frontends/api/src/hooks/articles/index.test.ts b/frontends/api/src/hooks/website_content/index.test.ts similarity index 66% rename from frontends/api/src/hooks/articles/index.test.ts rename to frontends/api/src/hooks/website_content/index.test.ts index 43f8c6f037..7c031cfb85 100644 --- a/frontends/api/src/hooks/articles/index.test.ts +++ b/frontends/api/src/hooks/website_content/index.test.ts @@ -1,16 +1,16 @@ import { renderHook, waitFor } from "@testing-library/react" import { setupReactQueryTest } from "../test-utils" -import { articleKeys } from "./queries" +import { websiteContentKeys } from "./queries" import { setMockResponse, urls, makeRequest } from "../../test-utils" import { UseQueryResult } from "@tanstack/react-query" -import { articles as factory } from "../../test-utils/factories" +import { websiteContent as factory } from "../../test-utils/factories" import { - useArticleList, - useArticleDetail, - useArticleCreate, - useArticlePartialUpdate, - useArticleDestroy, + useWebsiteContentList, + useWebsiteContentDetail, + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useWebsiteContentDestroy, } from "./index" /** @@ -28,86 +28,86 @@ const assertApiCalled = async ( expect(result.current.data).toEqual(data) } -describe("useArticleList", () => { +describe("useWebsiteContentList", () => { it.each([undefined, { limit: 5 }, { limit: 5, offset: 10 }])( "Calls the correct API", async (params) => { - const data = factory.articles({ count: 3 }) + const data = factory.websiteContents({ count: 3 }) const url = urls.websiteContent.list(params) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) - const useTestHook = () => useArticleList(params) + const useTestHook = () => useWebsiteContentList(params) const { result } = renderHook(useTestHook, { wrapper }) assertApiCalled(result, url, "GET", data) }, ) }) -describe("useArticleDetail", () => { +describe("useWebsiteContentDetail", () => { it("Calls the correct API", async () => { - const data = factory.article() + const data = factory.websiteContent() const url = urls.websiteContent.details(data.id) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) - const useTestHook = () => useArticleDetail(data.id) + const useTestHook = () => useWebsiteContentDetail(data.id) const { result } = renderHook(useTestHook, { wrapper }) assertApiCalled(result, url, "GET", data) }) }) -describe("Article CRUD", () => { - test("useArticleCreate calls correct API", async () => { +describe("Website Content CRUD", () => { + test("useWebsiteContentCreate calls correct API", async () => { const url = urls.websiteContent.list() - const data = factory.article() - const { id, ...requestData } = factory.article() + const data = factory.websiteContent() + const { id, ...requestData } = factory.websiteContent() setMockResponse.post(url, data) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticleCreate, { wrapper }) + const { result } = renderHook(useWebsiteContentCreate, { wrapper }) result.current.mutate(requestData) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.listRoot(), + queryKey: websiteContentKeys.listRoot(), }) }) - test("useArticlePartialUpdate calls correct API", async () => { - const article = factory.article() + test("useWebsiteContentPartialUpdate calls correct API", async () => { + const article = factory.websiteContent() const url = urls.websiteContent.details(article.id) setMockResponse.patch(url, article) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticlePartialUpdate, { wrapper }) + const { result } = renderHook(useWebsiteContentPartialUpdate, { wrapper }) result.current.mutate(article) await waitFor(() => expect(result.current.isSuccess).toBe(true)) const { id, ...patchData } = article expect(makeRequest).toHaveBeenCalledWith("patch", url, patchData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.detail(article.id), + queryKey: websiteContentKeys.detail(article.id), }) }) - test("useArticleDestroy calls correct API", async () => { - const { id } = factory.article() + test("useWebsiteContentDestroy calls correct API", async () => { + const { id } = factory.websiteContent() const url = urls.websiteContent.details(id) setMockResponse.delete(url, null) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticleDestroy, { wrapper }) + const { result } = renderHook(useWebsiteContentDestroy, { wrapper }) result.current.mutate(id) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.listRoot(), + queryKey: websiteContentKeys.listRoot(), }) }) }) diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/website_content/index.ts similarity index 63% rename from frontends/api/src/hooks/articles/index.ts rename to frontends/api/src/hooks/website_content/index.ts index 65b92ea258..adeedce1fb 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/website_content/index.ts @@ -4,17 +4,17 @@ import type { AxiosProgressEvent } from "axios" import { websiteContentApi, mediaApi } from "../../clients" import type { - WebsiteContentApiWebsiteContentListRequest as ArticleListRequest, - WebsiteContent as Article, + WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest, + WebsiteContent, } from "../../generated/v1" -import { articleQueries, articleKeys } from "./queries" +import { websiteContentQueries, websiteContentKeys } from "./queries" -const useArticleList = ( - params: ArticleListRequest = {}, +const useWebsiteContentList = ( + params: WebsiteContentListRequest = {}, opts?: { enabled?: boolean }, ) => { return useQuery({ - ...articleQueries.list(params), + ...websiteContentQueries.list(params), ...opts, }) } @@ -22,26 +22,26 @@ const useArticleList = ( /** * Query is disabled if id is undefined. */ -const useArticleDetail = (id: number | undefined) => { +const useWebsiteContentDetail = (id: number | undefined) => { return useQuery({ - ...articleQueries.detail(id ?? -1), + ...websiteContentQueries.detail(id ?? -1), enabled: id !== undefined, }) } -const useArticleDetailRetrieve = (identifier: string | undefined) => { +const useWebsiteContentDetailRetrieve = (identifier: string | undefined) => { return useQuery({ - ...articleQueries.articlesDetailRetrieve(identifier ?? ""), + ...websiteContentQueries.websiteContentDetailRetrieve(identifier ?? ""), enabled: identifier !== undefined, }) } -const useArticleCreate = () => { +const useWebsiteContentCreate = () => { const client = useQueryClient() return useMutation({ mutationFn: ( data: Omit< - Article, + WebsiteContent, "id" | "user" | "created_on" | "updated_on" | "publish_date" >, ) => @@ -49,7 +49,7 @@ const useArticleCreate = () => { .websiteContentCreate({ WebsiteContentRequest: data }) .then((response) => response.data), onSuccess: () => { - client.invalidateQueries({ queryKey: articleKeys.listRoot() }) + client.invalidateQueries({ queryKey: websiteContentKeys.listRoot() }) }, }) } @@ -97,41 +97,46 @@ export const useMediaUpload = () => { } } -const useArticleDestroy = () => { +const useWebsiteContentDestroy = () => { const client = useQueryClient() return useMutation({ mutationFn: (id: number) => websiteContentApi.websiteContentDestroy({ id }), onSuccess: () => { - client.invalidateQueries({ queryKey: articleKeys.listRoot() }) + client.invalidateQueries({ queryKey: websiteContentKeys.listRoot() }) }, }) } -const useArticlePartialUpdate = () => { +const useWebsiteContentPartialUpdate = () => { const client = useQueryClient() return useMutation({ - mutationFn: ({ id, ...data }: Partial
& Pick) => + mutationFn: ({ + id, + ...data + }: Partial & Pick) => websiteContentApi .websiteContentPartialUpdate({ id, PatchedWebsiteContentRequest: data, }) .then((response) => response.data), - onSuccess: (article: Article) => { - client.invalidateQueries({ queryKey: articleKeys.detail(article.id) }) - const identifier = article.slug || article.id.toString() + onSuccess: (websiteContent: WebsiteContent) => { client.invalidateQueries({ - queryKey: articleKeys.articlesDetailRetrieve(identifier), + queryKey: websiteContentKeys.detail(websiteContent.id), + }) + const identifier = websiteContent.slug || websiteContent.id.toString() + client.invalidateQueries({ + queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier), }) }, }) } export { - useArticleList, - useArticleDetail, - useArticleCreate, - useArticleDestroy, - useArticlePartialUpdate, - articleQueries, - useArticleDetailRetrieve, + useWebsiteContentList, + useWebsiteContentDetail, + useWebsiteContentCreate, + useWebsiteContentDestroy, + useWebsiteContentPartialUpdate, + websiteContentQueries, + useWebsiteContentDetailRetrieve, } diff --git a/frontends/api/src/hooks/website_content/queries.ts b/frontends/api/src/hooks/website_content/queries.ts new file mode 100644 index 0000000000..35085ab650 --- /dev/null +++ b/frontends/api/src/hooks/website_content/queries.ts @@ -0,0 +1,45 @@ +import { queryOptions } from "@tanstack/react-query" +import { websiteContentApi } from "../../clients" +import type { WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest } from "../../generated/v1" + +const websiteContentKeys = { + root: ["website_content"], + listRoot: () => [...websiteContentKeys.root, "list"], + list: (params: WebsiteContentListRequest) => [ + ...websiteContentKeys.listRoot(), + params, + ], + detailRoot: () => [...websiteContentKeys.root, "detail"], + detail: (id: number) => [...websiteContentKeys.detailRoot(), id], + websiteContentDetailRetrieve: (identifier: string) => [ + ...websiteContentKeys.detailRoot(), + identifier, + ], +} + +const websiteContentQueries = { + list: (params: WebsiteContentListRequest) => + queryOptions({ + queryKey: websiteContentKeys.list(params), + queryFn: () => + websiteContentApi.websiteContentList(params).then((res) => res.data), + }), + detail: (id: number) => + queryOptions({ + queryKey: websiteContentKeys.detail(id), + queryFn: () => + websiteContentApi + .websiteContentRetrieve({ id }) + .then((res) => res.data), + }), + websiteContentDetailRetrieve: (identifier: string) => + queryOptions({ + queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier), + queryFn: () => + websiteContentApi + .websiteContentDetailRetrieve({ identifier }) + .then((res) => res.data), + }), +} + +export { websiteContentQueries, websiteContentKeys } diff --git a/frontends/api/src/test-utils/factories/index.ts b/frontends/api/src/test-utils/factories/index.ts index 53e0733990..d349f89087 100644 --- a/frontends/api/src/test-utils/factories/index.ts +++ b/frontends/api/src/test-utils/factories/index.ts @@ -1,4 +1,4 @@ -export * as articles from "./articles" +export * as websiteContent from "./websiteContent" export * as channels from "./channels" export * as hubspot from "./hubspot" export * as learningResources from "./learningResources" diff --git a/frontends/api/src/test-utils/factories/articles.ts b/frontends/api/src/test-utils/factories/websiteContent.ts similarity index 80% rename from frontends/api/src/test-utils/factories/articles.ts rename to frontends/api/src/test-utils/factories/websiteContent.ts index 2583690c91..9e19593357 100644 --- a/frontends/api/src/test-utils/factories/articles.ts +++ b/frontends/api/src/test-utils/factories/websiteContent.ts @@ -3,7 +3,7 @@ import { makePaginatedFactory } from "ol-test-utilities" import type { Factory } from "ol-test-utilities" import type { WebsiteContent } from "../../generated/v1" -const article: Factory = (overrides = {}) => ({ +const websiteContent: Factory = (overrides = {}) => ({ id: faker.number.int(), title: faker.lorem.sentence(), content: { @@ -25,6 +25,6 @@ const article: Factory = (overrides = {}) => ({ ...overrides, }) -const articles = makePaginatedFactory(article) +const websiteContents = makePaginatedFactory(websiteContent) -export { article, articles } +export { websiteContent, websiteContents } diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 303a8b29f4..ec6918ed40 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -57,18 +57,6 @@ const nextConfig = { destination: "/enrollmentcode/:code", permanent: true, }, - { - // can be removed once fastly redirect is in place - source: "/articles/:slug*", - destination: "/news/:slug*", - permanent: true, - }, - { - // can be removed once fastly redirect is in place - source: "/articles", - destination: "/news", - permanent: true, - }, ] }, diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx deleted file mode 100644 index 5a0bc49ed5..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import React from "react" -import { useArticleDetailRetrieve } from "api/hooks/articles" -import { LoadingSpinner, styled } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" -import { notFound } from "next/navigation" -import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" - -const PageContainer = styled.div({ - display: "flex", - height: "100%", -}) - -const Spinner = styled(LoadingSpinner)({ - margin: "auto", - position: "absolute", - top: "40%", - left: "50%", - transform: "translate(-50%, -50%)", -}) - -export const ArticleDetailPage = ({ - articleId, - learningResourceIds = [], -}: { - articleId: string - learningResourceIds?: number[] -}) => { - const { data: article, isLoading } = useArticleDetailRetrieve(articleId) - - if (isLoading) { - return ( - - - - ) - } - if (!article) { - return notFound() - } - - return ( - - - - - - ) -} diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx deleted file mode 100644 index aaab2df643..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"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/ArticleEditor" -import { articlesView, articlesDraftView } 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 ArticleEditPage = ({ articleId }: { articleId: string }) => { - const { - data: article, - isLoading, - isFetching, - } = useArticleDetailRetrieve(articleId) - const router = useRouter() - - if (isLoading || isFetching) { - return - } - if (!article) { - return notFound() - } - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(articlesView(article.slug)) - } else { - router.push(articlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { ArticleEditPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx index 4386640b6b..ff96d8da45 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx @@ -1,33 +1,33 @@ "use client" -import React from "react" -import Image from "next/image" -import { useSearchParams } from "@mitodl/course-search-utils/next" +import React, { useState, useRef, useEffect } from "react" import { Container, styled, theme, - Typography, Grid2, + Card, Pagination, PaginationItem, - css, LoadingSpinner, - PlainList, + Typography, } from "ol-components" -import Link from "next/link" +import { ButtonLink } from "@mitodl/smoot-design" +import { useWebsiteContentList } from "api/hooks/website_content" +import { useUserHasPermission, Permission } from "api/hooks/user" +import type { WebsiteContent } from "api/v1" +import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { extractFirstImage } from "@/common/websiteContentUtils" import { - useNewsEventsList, - NewsEventsListFeedTypeEnum, -} from "api/hooks/newsEvents" -import type { NewsFeedItem } from "api/v0" -import { LocalDate } from "ol-utilities" -import { linkifyText } from "@/common/utils" -import { ArticleBanner } from "./ArticleBanner" + articleView, + ARTICLES_LISTING, + websiteContentCreateView, +} from "@/common/urls" const PAGE_SIZE = 20 const MAX_PAGE = 50 +const ARTICLE_CREATE_URL = websiteContentCreateView("article") export const DEFAULT_BACKGROUND_IMAGE_URL = "/images/backgrounds/banner_background.webp" @@ -37,644 +37,146 @@ const getLastPage = (count: number): number => { return pages > MAX_PAGE ? MAX_PAGE : pages } -const Section = styled.section` - background: ${theme.custom.colors.white}; - padding: 80px 0; - ${theme.breakpoints.down("sm")} { - padding: 0; - } -` - -const FeaturedStorySection = styled.div` - max-width: 1000px; - margin: -290px auto 40px; - position: relative; - z-index: 10; - - ${theme.breakpoints.down("md")} { - margin: -250px auto 24px; - } - ${theme.breakpoints.down("sm")} { - margin: 24px auto; - max-width: 100%; - } -` - -const MainStoryCard = styled.div` +const PageHeader = styled.div` display: flex; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.darkGray2}; - border-top: 4px solid #a31f34; - border-radius: 10px; - - &:hover { - h2 { - text-decoration: underline; - } - } - - ${theme.breakpoints.down("sm")} { - flex-direction: column; - gap: 0; - } -` - -const MainStoryImage = styled.div` - width: 50%; - min-height: 400px; - background-color: ${theme.custom.colors.darkGray1}; - border-radius: 10px 0 0 10px; - overflow: hidden; - position: relative; - - ${theme.breakpoints.down("md")} { - min-height: 300px; - } - - ${theme.breakpoints.down("sm")} { - width: 100%; - aspect-ratio: 16 / 9; - min-height: auto; - border-radius: 10px 10px 0 0; - } -` - -const MainStoryContent = styled.div` - width: 50%; - display: flex; - flex-direction: column; - gap: 16px; - padding: 40px; - color: white; - justify-content: space-between; - - ${theme.breakpoints.down("md")} { - padding: 24px; - } - - ${theme.breakpoints.down("sm")} { - width: 100%; - padding: 24px; - } -` -const RegularStoryTitleWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 10px; - - ${theme.breakpoints.down("sm")} { - gap: 8px; - } -` - -const MainStoryContentContainer = styled.div` - display: flex; - flex-direction: column; - gap: 24px; - ${theme.breakpoints.down("md")} { - gap: 16px; - } -` - -const MainStoryTitle = styled.h2` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.h3 }} - margin: 0; - - a { - color: ${theme.custom.colors.white}; - text-decoration: none; - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - - &:hover { - color: ${theme.custom.colors.white}; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.h5 }} - } - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.h5 }} - } -` - -const MainStorySummary = styled.p` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.body1 }} - margin: 0; - line-height: 22px; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; - overflow-wrap: break-word; - - a { - color: ${theme.custom.colors.white}; - text-decoration: underline; - - &:hover { - opacity: 0.8; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.body1 }} - } - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.body2 }} - } -` - -const MainStoryDate = styled(Typography)` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.body3 }} - margin: 0; -` - -// Regular story card for grid -const StoryCard = styled.div` - display: flex; - flex-direction: row; - gap: 24px; - background: white; - border-radius: 8px; - padding: 16px 16px 16px 24px; - overflow: hidden; - border: 1px solid transparent; - - &:hover { - border-radius: 8px; - border: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.white}; - box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); - - h2 { - color: ${theme.custom.colors.red}; - } - } - - ${theme.breakpoints.down("sm")} { - flex-direction: row; - gap: 12px; - padding: 16px 0; - background: transparent; - border: none; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - border-radius: 0; - - &:hover { - border: none; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - box-shadow: none; - } - } -` - -const StoryImage = styled.div` - width: 280px; - min-width: 280px; - max-width: 280px; - height: 180px; - flex-shrink: 0; - background-color: ${theme.custom.colors.lightGray1}; - border-radius: 8px; - order: 2; - align-self: flex-end; - overflow: hidden; - position: relative; - - ${theme.breakpoints.down("sm")} { - width: 100px; - min-width: 100px; - max-width: 100px; - height: 80px; - order: 2; - align-self: flex-start; - border-radius: 4px; - } -` - -const StoryContent = styled.div` - display: flex; - flex-direction: column; + align-items: center; justify-content: space-between; - flex: 1; - order: 1; - min-height: 180px; - min-width: 0; - overflow: hidden; - - ${theme.breakpoints.down("sm")} { - order: 1; - min-height: auto; - min-width: 0; - justify-content: space-between; - gap: 8px; - } -` - -const StoryTitle = styled.h2` - color: ${theme.custom.colors.darkGray2}; - ${{ ...theme.typography.h5 }} - margin: 0; - margin-top: 16px; - - a { - color: ${theme.custom.colors.darkGray2}; - text-decoration: none; - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - - &:hover { - color: ${theme.custom.colors.red}; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.subtitle1 }} - } - - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.subtitle2 }} - margin-top: 0; - -webkit-line-clamp: 3; - } + margin-bottom: 32px; ` -const StorySummary = styled.p` - color: ${theme.custom.colors.darkGray2}; - ${{ ...theme.typography.body2 }} - margin: 0; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.5; - overflow-wrap: break-word; - - a { - color: ${theme.custom.colors.red}; - text-decoration: underline; - - &:hover { - opacity: 0.8; - } - } - - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.body3 }} - -webkit-line-clamp: 2; - margin-top: 0; - color: ${theme.custom.colors.black}; - } -` - -const StoryDate = styled(Typography)` - color: ${theme.custom.colors.silverGrayDark}; - ${{ ...theme.typography.body3 }} - margin-bottom: 16px; - - ${theme.breakpoints.down("sm")} { - margin-bottom: 0; - margin-top: 0; - } -` - -const StyledSection = styled(Section)` - background: ${theme.custom.colors.lightGray1}; - - ul { - list-style: none; - } -` - -const GridContainer = styled.div` - max-width: 800px; - margin: 0 auto; - +const Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; ${theme.breakpoints.down("sm")} { - max-width: 100%; + padding: 40px 0; } ` -const MobileContent = styled.div` +const ArticleCardWrapper = styled(Card)` display: flex; - align-items: center; flex-direction: column; - gap: 40px; - margin: 40px 0; - - ${theme.breakpoints.down("sm")} { - margin: 0; - } + height: 100%; ` -const MobileContainer = styled.section` - width: 100%; - margin: 0 -16px; - - h3 { - margin: 0 16px 12px; - } -` - -const AboveMdOnly = styled.div(({ theme }) => ({ - [theme.breakpoints.down("sm")]: { - display: "none", - }, -})) - -const BelowMdOnly = styled.div(({ theme }) => ({ - [theme.breakpoints.up("sm")]: { - display: "none", - }, -})) - const PaginationContainer = styled.div` display: flex; justify-content: center; - margin-top: 24px; - - ${({ theme }) => theme.breakpoints.down("md")} { - margin-top: 16px; - margin-bottom: 24px; - } - - ul li button.Mui-selected { - ${({ theme }) => css({ ...theme.typography.subtitle1 })} - background-color: inherit; - } - - ul li button svg { - background-color: ${({ theme }) => theme.custom.colors.lightGray2}; - border-radius: 4px; - width: 1.5em; - height: 1.5em; - padding: 0.25em; - } -` - -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - min-height: 400px; - width: 100%; + margin-top: 40px; ` const EmptyState = styled.div` display: flex; flex-direction: column; - justify-content: center; align-items: center; + justify-content: center; min-height: 400px; - width: 100%; gap: 16px; - text-align: center; - padding: 40px 20px; - - ${theme.breakpoints.down("sm")} { - min-height: 300px; - padding: 24px 16px; - } ` -const ArticleBannerStyled = styled(ArticleBanner)<{ page: number }>( - ({ page, theme }) => ({ - padding: "48px 0", - paddingBottom: page === 1 ? "250px" : undefined, - position: "relative", - backgroundSize: "150% !important", - backgroundPosition: "center !important", - - "&::before": { - content: '""', - position: "absolute", - inset: 0, - background: "rgb(0 0 0 / 85%)", - zIndex: 1, - }, - - "& > *": { - position: "relative", - zIndex: 2, - }, - - [theme.breakpoints.down("md")]: { - paddingBottom: page === 1 ? "198px" : undefined, - }, - - [theme.breakpoints.down("sm")]: { - padding: "32px 0", - marginBottom: 0, - }, - }), -) - -const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { - const [imageError, setImageError] = React.useState(false) - - return ( - - - {item.image?.url && !imageError && ( - - {item.image.alt setImageError(true)} - /> - - )} - - - - - {item.title} - - {item.summary && ( - - )} - - - - - - - ) -} +const ArticleCard: React.FC<{ article: WebsiteContent }> = ({ article }) => { + const articleUrl = article.is_published + ? articleView(article.slug || String(article.id)) + : `${ARTICLES_LISTING}${article.id}/draft` -const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { - const [imageError, setImageError] = React.useState(false) + const imageUrl = extractFirstImage(article.content) return ( - - - - - {item.title} - - {item.summary && ( - - )} - - - - - - - - {item.image?.url && !imageError && ( - {item.image.alt setImageError(true)} - /> - )} - - - + + + + {article.title} + + + + + ) } const ArticleListingPage: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams() - const page = parseInt(searchParams.get("page") ?? "1", 10) + const [page, setPage] = useState(1) + const scrollRef = useRef(null) + const canCreateArticle = useUserHasPermission(Permission.ArticleEditor) - const { data: news, isLoading } = useNewsEventsList({ - feed_type: [NewsEventsListFeedTypeEnum.News], + const { data: articles, isLoading } = useWebsiteContentList({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, - sortby: "-news_date", + content_type: "article", }) - const stories = news?.results ?? [] + useEffect(() => { + if (page > 1 && scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, [page]) - // On page 1, first story is featured, rest are grid stories - // On other pages, all stories are grid stories - const mainStory = - page === 1 && stories.length > 0 ? (stories[0] as NewsFeedItem) : null - const gridStories = page === 1 ? stories.slice(1) : stories + const results = articles?.results + const totalPages = articles?.count ? getLastPage(articles.count) : 0 return ( - <> - - - - - {isLoading ? ( - - - - ) : stories.length === 0 ? ( - - No News Available - - There are no news to display at this time. Please check back - later. - - - ) : ( - <> - - - - {page === 1 && mainStory && ( - - - - )} - - {gridStories.map((item) => ( -
  • - -
  • - ))} -
    -
    -
    -
    - - - {/* Main Story Section: Only visible on page 1 */} - {page === 1 && mainStory && ( - - - - )} - - {/* Grid Section: Other articles */} - {gridStories.length > 0 ? ( - - - {gridStories.map((item) => ( - - - - ))} - - - ) : null} - - - )} -
    - - {!isLoading && gridStories.length > 0 && ( - - - { - setSearchParams((current) => { - const copy = new URLSearchParams(current) - if (newPage === 1) { - copy.delete("page") - } else { - copy.set("page", newPage.toString()) - } - return copy - }) - }} - renderItem={(item) => ( - - )} - /> - - +
    + + + Articles + {canCreateArticle ? ( + + New Article + + ) : null} + + + {isLoading ? ( + + ) : results && results.length > 0 ? ( + <> + + {results.map((article) => ( + + + + ))} + + + {totalPages > 1 && ( + + setPage(newPage)} + renderItem={(item) => ( + + )} + /> + + )} + + ) : ( + + No Articles Yet + + Get started by creating your first article. + + {canCreateArticle ? ( + + New Article + + ) : null} + )} - - + +
    ) } diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx deleted file mode 100644 index 8cbdd98489..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"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/ArticleEditor" -import { articlesDraftView, articlesView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const ArticleNewPage: React.FC = () => { - const router = useRouter() - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(articlesView(article.slug)) - } else { - router.push(articlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { ArticleNewPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx b/frontends/main/src/app-pages/News/NewsBanner.tsx similarity index 94% rename from frontends/main/src/app-pages/Articles/ArticleBanner.tsx rename to frontends/main/src/app-pages/News/NewsBanner.tsx index 3d13a333b3..cf3acc519f 100644 --- a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx +++ b/frontends/main/src/app-pages/News/NewsBanner.tsx @@ -50,7 +50,7 @@ const BannerDescription = styled(Typography)` } ` -interface ArticleBannerProps { +interface NewsBannerProps { title: string description: string currentBreadcrumb?: string @@ -58,7 +58,7 @@ interface ArticleBannerProps { className?: string } -const ArticleBanner: React.FC = ({ +const NewsBanner: React.FC = ({ title, description, currentBreadcrumb = "MIT News", @@ -87,4 +87,4 @@ const ArticleBanner: React.FC = ({ ) } -export { ArticleBanner } +export { NewsBanner } diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx similarity index 87% rename from frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx rename to frontends/main/src/app-pages/News/NewsListingPage.test.tsx index b6f6d3cf4e..ef1140b62e 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx +++ b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ArticleListingPage } from "./ArticleListingPage" +import { NewsListingPage } from "./NewsListingPage" import { urls, setMockResponse } from "api/test-utils" import type { NewsFeedItem } from "api/v0" import { newsEvents } from "api/test-utils/factories" @@ -19,7 +19,7 @@ jest.mock("@/common/useFeatureFlagsLoaded") const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) -describe("ArticleListingPage", () => { +describe("NewsListingPage", () => { beforeEach(() => { mockedUseFeatureFlagEnabled.mockReturnValue(true) mockedUseFeatureFlagsLoaded.mockReturnValue(true) @@ -36,13 +36,13 @@ describe("ArticleListingPage", () => { test("displays loading spinner on initial load", () => { setupAPI(0) - renderWithProviders() + renderWithProviders() expect(screen.getByRole("progressbar")).toBeInTheDocument() }) test("displays empty state when no articles are available", async () => { setupAPI(0) - renderWithProviders() + renderWithProviders() await screen.findByText("No News Available") @@ -55,7 +55,7 @@ describe("ArticleListingPage", () => { test("displays main news and grid stories on desktop", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -73,18 +73,18 @@ describe("ArticleListingPage", () => { ) }) - test("displays article banner with correct title and description", async () => { + test("displays News banner with correct title and description", async () => { setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole("heading", { name: "News" })).toBeInTheDocument() }) }) - test("displays article images when available", async () => { + test("displays News images when available", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -95,38 +95,38 @@ describe("ArticleListingPage", () => { expect(images.length).toBeGreaterThan(0) }) - test("displays article publish dates", async () => { + test("displays News publish dates", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() }) // Verify that LocalDate component renders dates for articles - // Check that there are multiple date elements rendered (one per article) + // Check that there are multiple date elements rendered (one per news) const listItems = screen.getAllByRole("listitem") expect(listItems.length).toBeGreaterThan(0) // Verify that dates are present in the document - // The first article should have a publish date - const firstArticle = news.results[0] as NewsFeedItem - const firstArticleDate = firstArticle.news_details?.publish_date - if (firstArticleDate) { + // The first news should have a publish date + const firstNews = news.results[0] as NewsFeedItem + const firstNewsDate = firstNews.news_details?.publish_date + if (firstNewsDate) { // LocalDate component will format the date, so check for parts of the date const dateElements = screen.getAllByText((content, element) => { return ( element?.tagName.toLowerCase() === "time" || - content.includes(new Date(firstArticleDate).getFullYear().toString()) + content.includes(new Date(firstNewsDate).getFullYear().toString()) ) }) expect(dateElements.length).toBeGreaterThan(0) } }) - test("links to article URLs correctly", async () => { + test("links to News URLs correctly", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -141,7 +141,7 @@ describe("ArticleListingPage", () => { expect(firstArticleLink).toHaveAttribute("href", news.results[0].url) }) - test("displays article summaries with HTML stripped", async () => { + test("displays News summaries with HTML stripped", async () => { const newsWithHtml = newsEvents.newsItems({ count: 1 }) // Summaries are now cleaned by the backend, so they come without HTML newsWithHtml.results[0].summary = "This is a test summary" @@ -151,7 +151,7 @@ describe("ArticleListingPage", () => { newsWithHtml, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -195,7 +195,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -216,7 +216,7 @@ describe("ArticleListingPage", () => { const news = newsEvents.newsItems({ count: 100 }) setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -233,7 +233,7 @@ describe("ArticleListingPage", () => { news.count = 1500 // Override to simulate 1500 total items setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -274,7 +274,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -300,7 +300,7 @@ describe("ArticleListingPage", () => { test("hides pagination when no articles", async () => { setupAPI(0) - renderWithProviders() + renderWithProviders() await screen.findByText("No News Available") @@ -330,7 +330,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -344,7 +344,7 @@ describe("ArticleListingPage", () => { test("renders responsive mobile layout", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -358,7 +358,7 @@ describe("ArticleListingPage", () => { test("calculates grid stories correctly for page 1", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() diff --git a/frontends/main/src/app-pages/News/NewsListingPage.tsx b/frontends/main/src/app-pages/News/NewsListingPage.tsx new file mode 100644 index 0000000000..abd33ec626 --- /dev/null +++ b/frontends/main/src/app-pages/News/NewsListingPage.tsx @@ -0,0 +1,681 @@ +"use client" + +import React from "react" +import Image from "next/image" +import { useSearchParams } from "@mitodl/course-search-utils/next" +import { + Container, + styled, + theme, + Typography, + Grid2, + Pagination, + PaginationItem, + css, + LoadingSpinner, + PlainList, +} from "ol-components" +import Link from "next/link" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { + useNewsEventsList, + NewsEventsListFeedTypeEnum, +} from "api/hooks/newsEvents" +import type { NewsFeedItem } from "api/v0" +import { LocalDate } from "ol-utilities" +import { linkifyText } from "@/common/utils" +import { NewsBanner } from "./NewsBanner" + +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 Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; + ${theme.breakpoints.down("sm")} { + padding: 0; + } +` + +const FeaturedStorySection = styled.div` + max-width: 1000px; + margin: -290px auto 40px; + position: relative; + z-index: 10; + + ${theme.breakpoints.down("md")} { + margin: -250px auto 24px; + } + ${theme.breakpoints.down("sm")} { + margin: 24px auto; + max-width: 100%; + } +` + +const MainStoryCard = styled.div` + display: flex; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.darkGray2}; + border-top: 4px solid #a31f34; + border-radius: 10px; + + &:hover { + h2 { + text-decoration: underline; + } + } + + ${theme.breakpoints.down("sm")} { + flex-direction: column; + gap: 0; + } +` + +const MainStoryImage = styled.div` + width: 50%; + min-height: 400px; + background-color: ${theme.custom.colors.darkGray1}; + border-radius: 10px 0 0 10px; + overflow: hidden; + position: relative; + + ${theme.breakpoints.down("md")} { + min-height: 300px; + } + + ${theme.breakpoints.down("sm")} { + width: 100%; + aspect-ratio: 16 / 9; + min-height: auto; + border-radius: 10px 10px 0 0; + } +` + +const MainStoryContent = styled.div` + width: 50%; + display: flex; + flex-direction: column; + gap: 16px; + padding: 40px; + color: white; + justify-content: space-between; + + ${theme.breakpoints.down("md")} { + padding: 24px; + } + + ${theme.breakpoints.down("sm")} { + width: 100%; + padding: 24px; + } +` +const RegularStoryTitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + + ${theme.breakpoints.down("sm")} { + gap: 8px; + } +` + +const MainStoryContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + ${theme.breakpoints.down("md")} { + gap: 16px; + } +` + +const MainStoryTitle = styled.h2` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.h3 }} + margin: 0; + + a { + color: ${theme.custom.colors.white}; + text-decoration: none; + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + color: ${theme.custom.colors.white}; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.h5 }} + } + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.h5 }} + } +` + +const MainStorySummary = styled.p` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.body1 }} + margin: 0; + line-height: 22px; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: break-word; + + a { + color: ${theme.custom.colors.white}; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.body1 }} + } + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.body2 }} + } +` + +const MainStoryDate = styled(Typography)` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.body3 }} + margin: 0; +` + +// Regular story card for grid +const StoryCard = styled.div` + display: flex; + flex-direction: row; + gap: 24px; + background: white; + border-radius: 8px; + padding: 16px 16px 16px 24px; + overflow: hidden; + border: 1px solid transparent; + + &:hover { + border-radius: 8px; + border: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.white}; + box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); + + h2 { + color: ${theme.custom.colors.red}; + } + } + + ${theme.breakpoints.down("sm")} { + flex-direction: row; + gap: 12px; + padding: 16px 0; + background: transparent; + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + border-radius: 0; + + &:hover { + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + box-shadow: none; + } + } +` + +const StoryImage = styled.div` + width: 280px; + min-width: 280px; + max-width: 280px; + height: 180px; + flex-shrink: 0; + background-color: ${theme.custom.colors.lightGray1}; + border-radius: 8px; + order: 2; + align-self: flex-end; + overflow: hidden; + position: relative; + + ${theme.breakpoints.down("sm")} { + width: 100px; + min-width: 100px; + max-width: 100px; + height: 80px; + order: 2; + align-self: flex-start; + border-radius: 4px; + } +` + +const StoryContent = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + order: 1; + min-height: 180px; + min-width: 0; + overflow: hidden; + + ${theme.breakpoints.down("sm")} { + order: 1; + min-height: auto; + min-width: 0; + justify-content: space-between; + gap: 8px; + } +` + +const StoryTitle = styled.h2` + color: ${theme.custom.colors.darkGray2}; + ${{ ...theme.typography.h5 }} + margin: 0; + margin-top: 16px; + + a { + color: ${theme.custom.colors.darkGray2}; + text-decoration: none; + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + color: ${theme.custom.colors.red}; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.subtitle1 }} + } + + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.subtitle2 }} + margin-top: 0; + -webkit-line-clamp: 3; + } +` + +const StorySummary = styled.p` + color: ${theme.custom.colors.darkGray2}; + ${{ ...theme.typography.body2 }} + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.5; + overflow-wrap: break-word; + + a { + color: ${theme.custom.colors.red}; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } + + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.body3 }} + -webkit-line-clamp: 2; + margin-top: 0; + color: ${theme.custom.colors.black}; + } +` + +const StoryDate = styled(Typography)` + color: ${theme.custom.colors.silverGrayDark}; + ${{ ...theme.typography.body3 }} + margin-bottom: 16px; + + ${theme.breakpoints.down("sm")} { + margin-bottom: 0; + margin-top: 0; + } +` + +const StyledSection = styled(Section)` + background: ${theme.custom.colors.lightGray1}; + + ul { + list-style: none; + } +` + +const GridContainer = styled.div` + max-width: 800px; + margin: 0 auto; + + ${theme.breakpoints.down("sm")} { + max-width: 100%; + } +` + +const MobileContent = styled.div` + display: flex; + align-items: center; + flex-direction: column; + gap: 40px; + margin: 40px 0; + + ${theme.breakpoints.down("sm")} { + margin: 0; + } +` + +const MobileContainer = styled.section` + width: 100%; + margin: 0 -16px; + + h3 { + margin: 0 16px 12px; + } +` + +const AboveMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + display: "none", + }, +})) + +const BelowMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.up("sm")]: { + display: "none", + }, +})) + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 24px; + + ${({ theme }) => theme.breakpoints.down("md")} { + margin-top: 16px; + margin-bottom: 24px; + } + + ul li button.Mui-selected { + ${({ theme }) => css({ ...theme.typography.subtitle1 })} + background-color: inherit; + } + + ul li button svg { + background-color: ${({ theme }) => theme.custom.colors.lightGray2}; + border-radius: 4px; + width: 1.5em; + height: 1.5em; + padding: 0.25em; + } +` + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + width: 100%; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 400px; + width: 100%; + gap: 16px; + text-align: center; + padding: 40px 20px; + + ${theme.breakpoints.down("sm")} { + min-height: 300px; + padding: 24px 16px; + } +` +const NewsBannerStyled = styled(NewsBanner)<{ page: number }>( + ({ page, theme }) => ({ + padding: "48px 0", + paddingBottom: page === 1 ? "250px" : undefined, + position: "relative", + backgroundSize: "150% !important", + backgroundPosition: "center !important", + + "&::before": { + content: '""', + position: "absolute", + inset: 0, + background: "rgb(0 0 0 / 85%)", + zIndex: 1, + }, + + "& > *": { + position: "relative", + zIndex: 2, + }, + + [theme.breakpoints.down("md")]: { + paddingBottom: page === 1 ? "198px" : undefined, + }, + + [theme.breakpoints.down("sm")]: { + padding: "32px 0", + marginBottom: 0, + }, + }), +) + +const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { + const [imageError, setImageError] = React.useState(false) + + return ( + + + {item.image?.url && !imageError && ( + + {item.image.alt setImageError(true)} + /> + + )} + + + + + + {item.title} + + {item.summary && ( + + )} + + + + + + + ) +} + +const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { + const [imageError, setImageError] = React.useState(false) + + return ( + + + + + {item.title} + + {item.summary && ( + + )} + + + + + + + + {item.image?.url && !imageError && ( + {item.image.alt setImageError(true)} + /> + )} + + + + ) +} + +const NewsListingPage: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams() + const page = parseInt(searchParams.get("page") ?? "1", 10) + + const { data: news, isLoading } = useNewsEventsList({ + feed_type: [NewsEventsListFeedTypeEnum.News], + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + sortby: "-news_date", + }) + + const stories = news?.results ?? [] + + // On page 1, first story is featured, rest are grid stories + // On other pages, all stories are grid stories + const mainStory = + page === 1 && stories.length > 0 ? (stories[0] as NewsFeedItem) : null + const gridStories = page === 1 ? stories.slice(1) : stories + + return ( + <> + + + + + {isLoading ? ( + + + + ) : stories.length === 0 ? ( + + No News Available + + There are no news to display at this time. Please check back + later. + + + ) : ( + <> + + + + {page === 1 && mainStory && ( + + + + )} + + {gridStories.map((item) => ( +
  • + +
  • + ))} +
    +
    +
    +
    + + + {/* Main Story Section: Only visible on page 1 */} + {page === 1 && mainStory && ( + + + + )} + + {/* Grid Section: Other news */} + {gridStories.length > 0 ? ( + + + {gridStories.map((item) => ( + + + + ))} + + + ) : null} + + + )} +
    + + {!isLoading && gridStories.length > 0 && ( + + + { + setSearchParams((current) => { + const copy = new URLSearchParams(current) + if (newPage === 1) { + copy.delete("page") + } else { + copy.set("page", newPage.toString()) + } + return copy + }) + }} + renderItem={(item) => ( + + )} + /> + + + )} +
    + + ) +} + +export { NewsListingPage } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx new file mode 100644 index 0000000000..fea109d342 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx @@ -0,0 +1,71 @@ +"use client" + +import React from "react" +import type { WebsiteContent } from "api/v1" +import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" +import { LoadingSpinner, styled } from "ol-components" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +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 DETAIL_EDITORS: Record< + string, + React.ComponentType<{ contentItem: WebsiteContent }> +> = { + article: ({ contentItem }) => ( + + ), + news: ({ contentItem }) => , +} + +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 WebsiteContentDetail = ({ + contentId, + learningResourceIds = [], +}: { + contentId: string + learningResourceIds?: number[] +}) => { + const { data: contentItem, isLoading } = + useWebsiteContentDetailRetrieve(contentId) + + if (isLoading) { + return ( + + + + ) + } + if (!contentItem) { + return notFound() + } + + const contentType = contentItem.content_type ?? "" + const Editor = DETAIL_EDITORS[contentType] + if (!Editor) { + return notFound() + } + + return ( + + + + + + ) +} + +export { WebsiteContentDetail } diff --git a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx similarity index 51% rename from frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx rename to frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx index 82ed4141f3..5a08eb4841 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -13,17 +13,23 @@ import { Typography, } from "ol-components" import { Permission } from "api/hooks/user" -import { useArticleList } from "api/hooks/articles" -import type { WebsiteContent } from "api/v1" +import { useWebsiteContentList } from "api/hooks/website_content" +import type { + WebsiteContent, + WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest, +} from "api/v1" import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { ArticleBanner, DEFAULT_BACKGROUND_IMAGE_URL } from "./ArticleBanner" -import { extractFirstImageFromArticle } from "@/common/articleUtils" -import { articlesDraftView, articlesView } from "@/common/urls" +import { extractFirstImage } from "@/common/websiteContentUtils" +import { websiteContentEditView, websiteContentCreateView } from "@/common/urls" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { ButtonLink } from "@mitodl/smoot-design" 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); @@ -33,7 +39,14 @@ const PageWrapper = styled.div` } ` -const DraftArticleCard = styled(Card)` +const PageHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +` + +const DraftContentCard = styled(Card)` display: flex; flex-direction: column; height: 100%; @@ -66,48 +79,67 @@ const DraftBadge = styled.span` font-weight: ${theme.typography.fontWeightMedium}; ` -export const DraftArticle: React.FC<{ article: WebsiteContent }> = ({ - article, +const CONTENT_TYPE_LABELS: Record = { + article: "Article", + news: "News", +} + +const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({ + contentItem, + type, }) => { - const articleUrl = article.is_published - ? articlesView(article.slug || String(article.id)) - : articlesDraftView(String(article.id)) + const itemUrl = contentItem.is_published + ? `/${type === "article" ? "articles" : type}/${contentItem.slug || contentItem.id}` + : websiteContentEditView(type, contentItem.id) - const imageUrl = extractFirstImageFromArticle(article.content) + const imageUrl = extractFirstImage(contentItem.content) return ( - - { - - } - - {article.title} + + + + {contentItem.title} - - {!article.is_published && ( + + {!contentItem.is_published && ( <> {" • "} Draft )} - + ) } -const ArticleDraftPage: React.FC = () => { +interface WebsiteContentDraftListingPageProps { + /** + * Content type to show drafts for (e.g. 'article', 'news'). + */ + contentType?: string +} + +const WebsiteContentDraftListingPage: React.FC< + WebsiteContentDraftListingPageProps +> = ({ contentType }) => { const [page, setPage] = useState(1) const scrollRef = useRef(null) + const type = contentType || "news" + const label = CONTENT_TYPE_LABELS[type] ?? type - const { data: articles, isLoading: isLoadingArticles } = useArticleList({ + const listParams: WebsiteContentListRequest = { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, - draft: true, // Filter for drafts only on the backend - }) + draft: true, + content_type: type as WebsiteContentListRequest["content_type"], + } + + const { data: contentItems, isLoading: isLoadingContentItems } = + useWebsiteContentList(listParams) useEffect(() => { if (page > 1 && scrollRef.current) { @@ -115,34 +147,43 @@ const ArticleDraftPage: React.FC = () => { } }, [page]) - const draftArticles = articles?.results - const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 + const draftItems = contentItems?.results + const totalPages = contentItems?.count + ? Math.ceil(contentItems.count / PAGE_SIZE) + : 0 - if (isLoadingArticles) { - return + if (isLoadingContentItems) { + return } + return ( - - {isLoadingArticles ? ( + + {label} Drafts + + New {label} + + + + {isLoadingContentItems ? ( - ) : draftArticles && draftArticles.length > 0 ? ( + ) : draftItems && draftItems.length > 0 ? ( <> - {draftArticles.map((article) => ( + {draftItems.map((contentItem) => ( - + ))} @@ -168,10 +209,9 @@ const ArticleDraftPage: React.FC = () => { ) : ( - No Draft Articles + No Draft {label}s - You don't have any draft articles yet. Create a new article to - get started. + You don't have any draft {label.toLowerCase()}s yet. )} @@ -181,4 +221,4 @@ const ArticleDraftPage: React.FC = () => { ) } -export { ArticleDraftPage } +export { WebsiteContentDraftListingPage } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx new file mode 100644 index 0000000000..5d877a5a5c --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -0,0 +1,96 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled, LoadingSpinner } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { articleView, websiteContentEditView } from "@/common/urls" +import invariant from "tiny-invariant" +import type { WebsiteContent } from "api/v1" + +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 PUBLISHED_VIEW_URL: Record string> = { + article: (slug) => articleView(slug), + news: (slug) => `/news/${slug}`, +} + +const EDITORS: Record< + string, + React.ComponentType<{ + onSave?: (savedContent: WebsiteContent) => void + readOnly?: boolean + contentItem?: WebsiteContent + }> +> = { + article: ({ contentItem, ...props }) => ( + + ), + news: ({ contentItem, ...props }) => ( + + ), +} + +interface WebsiteContentEditPageProps { + type: string + idOrSlug: string +} + +const WebsiteContentEditPage = ({ + type, + idOrSlug, +}: WebsiteContentEditPageProps) => { + const { data: article, isLoading } = useWebsiteContentDetailRetrieve(idOrSlug) + const router = useRouter() + + const Editor = EDITORS[type] + const viewUrl = PUBLISHED_VIEW_URL[type] + + if (!Editor || !viewUrl) { + notFound() + } + + if (isLoading) { + return + } + if (!article) { + return notFound() + } + + return ( + + + { + if (saved.is_published) { + invariant(saved.slug, "Published content must have a slug") + return router.push(viewUrl(saved.slug)) + } else { + router.push(websiteContentEditView(type, saved.id)) + } + }} + /> + + + ) +} + +export { WebsiteContentEditPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx similarity index 85% rename from frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx rename to frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx index 38f8323e1f..9c1db13ff2 100644 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx @@ -6,7 +6,7 @@ import { } from "@/test-utils" import { waitFor } from "@testing-library/react" import { factories, urls } from "api/test-utils" -import { ArticleNewPage } from "./ArticleNewPage" +import { WebsiteContentNewPage } from "./WebsiteContentNewPage" const mockPush = jest.fn() jest.mock("next/navigation", () => ({ @@ -15,7 +15,7 @@ jest.mock("next/navigation", () => ({ }), })) -describe("ArticleNewPage", () => { +describe("WebsiteContentNewPage", () => { test("throws ForbiddenError when user lacks ArticleEditor permission", async () => { const user = factories.user.user({ is_authenticated: true, @@ -28,7 +28,7 @@ describe("ArticleNewPage", () => { renderWithProviders( - + , ) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx new file mode 100644 index 0000000000..ed648a5b94 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx @@ -0,0 +1,73 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +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 { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { articleView, websiteContentEditView } from "@/common/urls" +import invariant from "tiny-invariant" +import type { WebsiteContent } from "api/v1" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const PUBLISHED_VIEW_URL: Record string> = { + article: (slug) => articleView(slug), + news: (slug) => `/news/${slug}`, +} + +const EDITORS: Record< + string, + React.ComponentType<{ + onSave?: (savedContent: WebsiteContent) => void + readOnly?: boolean + contentItem?: WebsiteContent + }> +> = { + article: ({ contentItem, ...props }) => ( + + ), + news: ({ contentItem: _contentItem, ...props }) => , +} + +interface WebsiteContentNewPageProps { + type: string +} + +const WebsiteContentNewPage: React.FC = ({ + type, +}) => { + const router = useRouter() + const Editor = EDITORS[type] + const viewUrl = PUBLISHED_VIEW_URL[type] + + if (!Editor || !viewUrl) { + notFound() + } + + return ( + + + { + if (article.is_published) { + invariant(article.slug, "Published content must have a slug") + return router.push(viewUrl(article.slug)) + } else { + router.push(websiteContentEditView(type, article.id)) + } + }} + /> + + + ) +} + +export { WebsiteContentNewPage } diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx new file mode 100644 index 0000000000..64a79f3363 --- /dev/null +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx @@ -0,0 +1,25 @@ +import React from "react" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" +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/(site)/articles/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx new file mode 100644 index 0000000000..919d303442 --- /dev/null +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx @@ -0,0 +1,78 @@ +import React from "react" +import { HydrationBoundary, dehydrate } from "@tanstack/react-query" +import { websiteContentQueries } from "api/hooks/website_content/queries" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" +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 { + extractImageMetadata, + extractWebsiteContentDescription, +} from "@/common/website_content" +import { notFound } from "next/navigation" + +export const generateMetadata = async ( + props: PageProps<"/articles/[slugOrId]">, +) => { + const params = await props.params + const { slugOrId } = params + + const queryClient = getQueryClient() + + return safeGenerateMetadata(async () => { + const content = await queryClient.fetchQuery( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), + ) + if (content.content_type !== "article") { + return notFound() + } + + const description = extractWebsiteContentDescription(content) + const leadImage = extractImageMetadata(content) + + return standardizeMetadata({ + title: content.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( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), + ) + + const queryKey = + websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey + const content = queryClient.getQueryData(queryKey) + if (!content || content.content_type !== "article") { + return notFound() + } + + const learningResourceIds = extractLearningResourceIds(content.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/(site)/articles/page.tsx b/frontends/main/src/app/(site)/articles/page.tsx new file mode 100644 index 0000000000..498bb7c361 --- /dev/null +++ b/frontends/main/src/app/(site)/articles/page.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { ArticleListingPage } from "@/app-pages/Articles/ArticleListingPage" + +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/app/(site)/news/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx index 1f4dc0b464..5e67a9bb04 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx @@ -1,24 +1,24 @@ import React from "react" -import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import { standardizeMetadata } from "@/common/metadata" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { Permission } from "api/hooks/user" export const generateMetadata = async () => { return standardizeMetadata({ - title: "Draft Article", + title: "Draft News", }) } const Page: React.FC> = async (props) => { const { slugOrId } = await props.params - // No prefetching for draft articles - the client-side component + // No prefetching for draft News - the client-side component // will fetch with user authentication return ( - + ) } diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx index 3837282c8e..4064bd021f 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx @@ -1,9 +1,8 @@ -import React from "react" -import { ArticleEditPage } from "@/app-pages/Articles/ArticleEditPage" +import { redirect } from "next/navigation" -const Page: React.FC> = async (props) => { +const Page = async (props: { params: Promise<{ slugOrId: string }> }) => { const { slugOrId } = await props.params - - return + redirect(`/website_content/news/${slugOrId}/edit`) } + export default Page diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx index 460f3ea80f..b668bfbaf5 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx @@ -1,38 +1,16 @@ import React from "react" import { HydrationBoundary, dehydrate } from "@tanstack/react-query" -import { articleQueries } from "api/hooks/articles/queries" -import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage" +import { websiteContentQueries } from "api/hooks/website_content/queries" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" 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, - } -} +import { + extractImageMetadata, + extractWebsiteContentDescription, +} from "@/common/website_content" +import { notFound } from "next/navigation" export const generateMetadata = async ( props: PageProps<"/news/[slugOrId]">, @@ -44,15 +22,18 @@ export const generateMetadata = async ( const queryClient = getQueryClient() return safeGenerateMetadata(async () => { - const article = await queryClient.fetchQuery( - articleQueries.articlesDetailRetrieve(slugOrId), + const content = await queryClient.fetchQuery( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) + if (content.content_type !== "news") { + return notFound() + } - const description = extractArticleDescription(article) - const leadImage = extractImageMetadata(article) + const description = extractWebsiteContentDescription(content) + const leadImage = extractImageMetadata(content) return standardizeMetadata({ - title: article.title, + title: content.title, description, image: leadImage?.src, imageAlt: leadImage?.alt, @@ -66,15 +47,17 @@ const Page: React.FC> = async (props) => { const queryClient = getQueryClient() await queryClient.fetchQueryOr404( - articleQueries.articlesDetailRetrieve(slugOrId), + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) - const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey - const cacheData = queryClient.getQueryData(queryKey) + const queryKey = + websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey + const content = queryClient.getQueryData(queryKey) + if (!content || content.content_type !== "news") { + return notFound() + } - const learningResourceIds = cacheData?.content - ? extractLearningResourceIds(cacheData.content) - : [] + const learningResourceIds = extractLearningResourceIds(content.content) if (learningResourceIds.length > 0) { const bulkQuery = learningResourceQueries.list({ @@ -85,8 +68,8 @@ const Page: React.FC> = async (props) => { return ( - diff --git a/frontends/main/src/app/(site)/news/draft/page.tsx b/frontends/main/src/app/(site)/news/draft/page.tsx index 56ad03090b..63cea3be9f 100644 --- a/frontends/main/src/app/(site)/news/draft/page.tsx +++ b/frontends/main/src/app/(site)/news/draft/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { ArticleDraftPage } from "@/app-pages/Articles/ArticleDraftListingPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "Articles Draft", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/drafts?content_type=news") } export default Page diff --git a/frontends/main/src/app/(site)/news/new/page.tsx b/frontends/main/src/app/(site)/news/new/page.tsx index 7040db5e9d..2309ec5ee8 100644 --- a/frontends/main/src/app/(site)/news/new/page.tsx +++ b/frontends/main/src/app/(site)/news/new/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { ArticleNewPage } from "@/app-pages/Articles/ArticleNewPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "MIT Learn| New", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/news/new") } export default Page diff --git a/frontends/main/src/app/(site)/news/page.tsx b/frontends/main/src/app/(site)/news/page.tsx index 436c014d4d..942bd20a09 100644 --- a/frontends/main/src/app/(site)/news/page.tsx +++ b/frontends/main/src/app/(site)/news/page.tsx @@ -1,7 +1,7 @@ import React from "react" import { Metadata } from "next" import { standardizeMetadata } from "@/common/metadata" -import { ArticleListingPage } from "@/app-pages/Articles/ArticleListingPage" +import { NewsListingPage } from "@/app-pages/News/NewsListingPage" export const metadata: Metadata = standardizeMetadata({ title: "MIT Learn | News", @@ -9,7 +9,7 @@ export const metadata: Metadata = standardizeMetadata({ }) const Page: React.FC> = () => { - return + return } export default Page diff --git a/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx b/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx new file mode 100644 index 0000000000..1df7bc1811 --- /dev/null +++ b/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx @@ -0,0 +1,13 @@ +import React from "react" +import { WebsiteContentEditPage } from "@/app-pages/WebsiteContent/WebsiteContentEditPage" + +const Page = async ({ + params, +}: { + params: Promise<{ type: string; idOrSlug: string }> +}) => { + const { type, idOrSlug } = await params + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx b/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx new file mode 100644 index 0000000000..9318402af2 --- /dev/null +++ b/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx @@ -0,0 +1,16 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentNewPage } from "@/app-pages/WebsiteContent/WebsiteContentNewPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | New", + robots: "noindex, nofollow", +}) + +const Page = async ({ params }: { params: Promise<{ type: string }> }) => { + const { type } = await params + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/website_content/drafts/page.tsx b/frontends/main/src/app/(site)/website_content/drafts/page.tsx new file mode 100644 index 0000000000..5d78090e16 --- /dev/null +++ b/frontends/main/src/app/(site)/website_content/drafts/page.tsx @@ -0,0 +1,20 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentDraftListingPage } from "@/app-pages/WebsiteContent/WebsiteContentDraftListingPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | Drafts", + robots: "noindex, nofollow", +}) + +const Page = async ({ + searchParams, +}: { + searchParams: Promise<{ content_type?: string }> +}) => { + const { content_type: contentType } = await searchParams + return +} + +export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index f697fd5927..7bc48486a2 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -36,17 +36,41 @@ export const learningPathsView = (id: number) => export const PROGRAMLETTER_VIEW = "/program_letter/[id]/view/" export const programLetterView = (id: string) => generatePath(PROGRAMLETTER_VIEW, { id: String(id) }) -export const ARTICLES_LISTING = "/news/" -export const ARTICLES_VIEW = "/news/[id]" -export const ARTICLES_DRAFT_VIEW = "/news/[id]/draft" -export const ARTICLES_EDIT = "/news/[id]/edit" -export const ARTICLES_CREATE = "/news/new" -export const articlesView = (id: string) => +export const NEWS_LISTING = "/news/" +export const NEWS_VIEW = "/news/[id]" +export const NEWS_DRAFT_VIEW = "/news/[id]/draft" +export const NEWS_EDIT = "/news/[id]/edit" +export const NEWS_CREATE = "/news/new" +export const newsView = (id: string) => + generatePath(NEWS_VIEW, { id: String(id) }) +export const newsDraftView = (id: string) => + generatePath(NEWS_DRAFT_VIEW, { id: String(id) }) +export const newsEditView = (id: number) => + generatePath(NEWS_EDIT, { id: String(id) }) + +// Articles (served under /articles) +export const ARTICLES_LISTING = "/articles/" +export const ARTICLES_VIEW = "/articles/[id]" +export const ARTICLES_DRAFT_VIEW = "/articles/[id]/draft" +export const articleView = (id: string) => generatePath(ARTICLES_VIEW, { id: String(id) }) -export const articlesDraftView = (id: string) => +export const articleDraftView = (id: string) => generatePath(ARTICLES_DRAFT_VIEW, { id: String(id) }) -export const articlesEditView = (id: number) => - generatePath(ARTICLES_EDIT, { id: String(id) }) + +// Generic website content editing routes +export const WEBSITE_CONTENT_CREATE = "/website_content/[type]/new" +export const WEBSITE_CONTENT_EDIT = "/website_content/[type]/[idOrSlug]/edit" +export const WEBSITE_CONTENT_DRAFTS = "/website_content/drafts" +export const websiteContentCreateView = (type: string) => + `/website_content/${type}/new` +export const websiteContentEditView = ( + type: string, + idOrSlug: string | number, +) => `/website_content/${type}/${idOrSlug}/edit` +export const websiteContentDraftsView = (contentType?: string) => + contentType + ? `${WEBSITE_CONTENT_DRAFTS}?content_type=${contentType}` + : WEBSITE_CONTENT_DRAFTS export const DEPARTMENTS = "/departments/" export const TOPICS = "/topics/" diff --git a/frontends/main/src/common/articleUtils.ts b/frontends/main/src/common/websiteContentUtils.ts similarity index 77% rename from frontends/main/src/common/articleUtils.ts rename to frontends/main/src/common/websiteContentUtils.ts index c4af58b1e4..493021cf51 100644 --- a/frontends/main/src/common/articleUtils.ts +++ b/frontends/main/src/common/websiteContentUtils.ts @@ -1,10 +1,10 @@ /** * Recursively traverses a ProseMirror JSON content structure to find the first image. * - * @param content - The ProseMirror JSON content object from an article + * @param content - The ProseMirror JSON content object * @returns The URL of the first image found, or null if no image exists */ -export function extractFirstImageFromArticle(content: unknown): string | null { +export function extractFirstImage(content: unknown): string | null { if (!content || typeof content !== "object") return null const node = content as Record @@ -21,7 +21,7 @@ export function extractFirstImageFromArticle(content: unknown): string | null { // Recursively check content array if (Array.isArray(node.content)) { for (const childNode of node.content) { - const imageUrl = extractFirstImageFromArticle(childNode) + const imageUrl = extractFirstImage(childNode) if (imageUrl) { return imageUrl } diff --git a/frontends/main/src/common/website_content.ts b/frontends/main/src/common/website_content.ts new file mode 100644 index 0000000000..028e4de043 --- /dev/null +++ b/frontends/main/src/common/website_content.ts @@ -0,0 +1,32 @@ +import type { JSONContent } from "@tiptap/react" +import type { WebsiteContent } from "api/v1" + +export const extractWebsiteContentDescription = ( + content: WebsiteContent, +): string | undefined => { + const banner = content.content?.content?.[0] + const subheading = banner?.content?.[1] + const textNode = subheading?.content?.[0] + return textNode?.text +} + +export const extractImageMetadata = ( + content: WebsiteContent, +): { src: string; alt: string } | null => { + const imageWithCaption = content.content?.content?.find( + (node: JSONContent) => node.type === "imageWithCaption", + ) + + const attrs = imageWithCaption?.attrs as + | { src?: string; alt?: string; caption?: string } + | undefined + + if (!attrs?.src) { + return null + } + + return { + src: attrs.src, + alt: attrs.caption || attrs.alt || "", + } +} diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx deleted file mode 100644 index 2dc29b1b72..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext, useContext } from "react" -import type { WebsiteContent } from "api/v1" - -interface ArticleContextValue { - article?: WebsiteContent -} - -const ArticleContext = createContext({}) - -export const ArticleProvider = ArticleContext.Provider - -export function useArticle() { - return useContext(ArticleContext).article -} diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx deleted file mode 100644 index ec580b7aa5..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx +++ /dev/null @@ -1,395 +0,0 @@ -"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 } diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx similarity index 85% rename from frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx rename to frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx index 8ee3150d3b..abf5b69a07 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx @@ -1,17 +1,18 @@ import React from "react" import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { factories, urls } from "api/test-utils" -import { ArticleEditor } from "./ArticleEditor" +import { NewsEditor } from "./contentTypes/news/NewsEditor" +import { ArticleEditor } from "./contentTypes/article/ArticleEditor" -describe("ArticleViewer", () => { - test("renders article content", async () => { +describe("NewsViewer", () => { + test("renders content", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -64,7 +65,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { name: "Test Title", level: 1 }) await screen.findByText("Test subheading") @@ -78,14 +79,47 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) const authorName = `${user.first_name} ${user.last_name}` - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ user, author_name: authorName, }) + renderWithProviders() + + await screen.findByText(`By ${authorName}`) + }) + + test("article read-only renders the byline with a share button", async () => { + const user = factories.user.user({ + is_authenticated: true, + is_article_editor: true, + }) + setMockResponse.get(urls.userMe.get(), user) + const authorName = `${user.first_name} ${user.last_name}` + const article = factories.websiteContent.websiteContent({ + author_name: authorName, + content: { + type: "doc", + content: [ + { + type: "banner", + content: [ + { type: "heading", attrs: { level: 1 }, content: [] }, + { type: "paragraph", content: [] }, + ], + }, + { type: "byline" }, + { type: "paragraph", content: [] }, + ], + }, + }) + renderWithProviders() await screen.findByText(`By ${authorName}`) + expect( + screen.getByRole("button", { name: /share this article/i }), + ).toBeInTheDocument() }) test("renders headings levels 1-6", async () => { @@ -95,7 +129,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -202,7 +236,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { level: 1, name: "Heading Level 1" }) await screen.findByRole("heading", { level: 2, name: "Heading Level 2" }) @@ -219,7 +253,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -294,7 +328,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const firstUnordered = await screen.findByText("First unordered item") const secondUnordered = await screen.findByText("Second unordered item") @@ -321,7 +355,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -398,7 +432,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const boldText = await screen.findByText("bold text") expect(boldText).toBeInTheDocument() @@ -428,7 +462,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -486,7 +520,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const link = await screen.findByRole("link", { name: "example.com" }) expect(link).toBeInTheDocument() @@ -500,8 +534,8 @@ describe("ArticleViewer", () => { is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article() - renderWithProviders() + const newsItem = factories.websiteContent.websiteContent() + renderWithProviders() await screen.findByRole("link", { name: "Edit" }) }) diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index e86152ed18..47b7613a3e 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -49,7 +49,7 @@ import "./vendor/components/tiptap-templates/simple/simple-editor.scss" import "./TiptapEditor.styles.scss" import { BannerViewer } from "./extensions/node/Banner/BannerNode" -import { ArticleByLineInfoBarViewer } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer" +import { ByLineInfoBarViewer } from "./extensions/node/ByLineInfoBar/ByLineInfoBarViewer" import { ImageWithCaptionViewer } from "./extensions/node/Image/ImageWithCaption" import { DividerViewer } from "./extensions/node/Divider/DividerNode" import { LearningResourceButton } from "./extensions/ui/LearningResource/LearningResourceButton" @@ -61,7 +61,7 @@ const Container = styled.div<{ }>(({ theme, readOnly }) => ({ maxWidth: "890px", minHeight: "calc(100vh - 350px)", - backgroundColor: theme.custom.colors.white, + backgroundColor: "transparent", borderRadius: "10px", margin: "0 auto", @@ -71,6 +71,7 @@ const Container = styled.div<{ padding: "0 16px", }, }, + ...(readOnly ? { backgroundColor: "transparent", @@ -272,17 +273,12 @@ interface TiptapEditorProps { editor: Editor readOnly?: boolean fullWidth?: boolean - className?: string } -const TiptapEditor = ({ editor, className }: TiptapEditorProps) => { +const TiptapEditor = ({ editor }: TiptapEditorProps) => { return ( - - + + ) } @@ -290,9 +286,11 @@ const TiptapEditor = ({ editor, className }: TiptapEditorProps) => { const TipTapViewer = ({ content, extensions, + bannerViewer = BannerViewer, }: { content: JSONContent extensions: Array + bannerViewer?: typeof BannerViewer }) => { return ( @@ -310,8 +308,8 @@ const TipTapViewer = ({ * See https://tiptap.dev/docs/editor/api/utilities/static-renderer#react-nodeviews */ nodeMapping: { - banner: BannerViewer, - byline: ArticleByLineInfoBarViewer, + banner: bannerViewer, + byline: ByLineInfoBarViewer, divider: DividerViewer, imageWithCaption: ImageWithCaptionViewer, learningResource: LearningResourceCardViewer, diff --git a/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx new file mode 100644 index 0000000000..9c71fd4eb4 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react" +import type { WebsiteContent } from "api/v1" + +interface WebsiteContentContextValue { + contentItem?: WebsiteContent +} + +const WebsiteContentContext = createContext({}) + +export const WebsiteContentProvider = WebsiteContentContext.Provider + +export function useWebsiteContent() { + return useContext(WebsiteContentContext).contentItem +} diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx new file mode 100644 index 0000000000..d216f99b41 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx @@ -0,0 +1,56 @@ +/** + * @jest-environment @happy-dom/jest-environment + * + * Using the Happy DOM environment as the editor accesses DOM APIs and uses + * contenteditable elements not supported by JSDOM, the default Jest environment. + */ +import React from "react" +import { screen } from "@testing-library/react" +import { setMockResponse, factories, urls } from "api/test-utils" +import type { JSONContent } from "@tiptap/react" +import { ArticleEditor } from "./ArticleEditor" +import { renderWithProviders } from "@/test-utils" + +const content: JSONContent = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Article Title" }], + }, + { type: "paragraph", content: [] }, + ], + }, + { type: "byline" }, + { type: "paragraph", content: [] }, + ], +} + +const renderArticleEditor = () => { + const user = factories.user.user({ + is_authenticated: true, + is_article_editor: true, + }) + setMockResponse.get(urls.userMe.get(), user) + const article = factories.websiteContent.websiteContent({ content }) + renderWithProviders(, { user }) +} + +describe("ArticleEditor", () => { + test("mounts the live editor with an editable banner heading", async () => { + renderArticleEditor() + + await screen.findByTestId("editor") + await screen.findByRole("heading", { level: 1, name: "Article Title" }) + }) + + test("renders the article breadcrumb bar in edit mode", async () => { + renderArticleEditor() + + await screen.findByText("Articles") + }) +}) 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..c7ced70591 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -0,0 +1,91 @@ +"use client" + +import React from "react" +import styled from "@emotion/styled" +import { WebsiteContentContentTypeEnum, type WebsiteContent } from "api/v1" +import { + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useMediaUpload, +} from "api/hooks/website_content" +import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" +import { + createArticleExtensions, + newArticleDocument, +} from "./articleExtensions" +import { ArticleBannerViewer } from "../../extensions/node/Banner/ArticleBannerNode" + +/** + * Article-specific byline look: merged into the white banner (no bar chrome) with + * a "·" separator. Styling lives here (not in the byline node) by targeting the + * node's published hook classes, so the node stays content-type-agnostic. + */ +const StyledWebsiteContentEditor = styled(WebsiteContentEditor)( + ({ theme }) => ({ + // Article sits on a gray page; the banner + byline render as white islands. + backgroundColor: theme.custom.colors.lightGray1, + ".byline-info-bar": { + boxShadow: "none", + border: "none", + marginBottom: "40px", + paddingTop: 0, + }, + ".byline-info-bar__separator::before": { + content: '"·"', + }, + }), +) + +// 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 || "", + content_type: "article", + } +} + +interface ArticleEditorProps { + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + article?: WebsiteContent +} + +/** + * Editor shell configured for the article content type (served under /articles). + * Owns its own save mutations so WebsiteContentEditor stays API-agnostic. + * + * Currently uses the same websiteContent API as the news editor. If /articles + * later needs a dedicated endpoint, swap in the new hooks here: + * + * const createMutation = useArticleCreate() // future hook + * const updateMutation = useArticlePartialUpdate() + * + * WebsiteContentEditor does not need to change at all. + */ +const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { + // Swap these hooks when a dedicated article API exists. + const createMutation = useWebsiteContentCreate() + const updateMutation = useWebsiteContentPartialUpdate() + const uploadImage = useMediaUpload() + + 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..829468b6b5 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts @@ -0,0 +1,49 @@ +import type { Extension, Node, Mark } from "@tiptap/core" +import Document from "@tiptap/extension-document" +import { ArticleBannerNode } from "../../extensions/node/Banner/ArticleBannerNode" +import { ByLineInfoBarNode } from "../../extensions/node/ByLineInfoBar/ByLineInfoBarNode" +import { createBaseExtensions } from "../../extensions/baseExtensions" +import type { CreateExtensionsFn } from "../../core/WebsiteContentEditor" + +export const ArticleDocument = Document.extend({ + content: "banner byline block+", +}) + +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: [] }, + ], +} + +/** + * Factory function that builds the full extensions list for the article content type. + * Pass to WebsiteContentEditor as `createExtensions`. + */ +export const createArticleExtensions: CreateExtensionsFn = ( + uploadHandler, + setUploadError, +): (Extension | Node | Mark)[] => [ + ArticleDocument, + ...createBaseExtensions(uploadHandler, setUploadError), + ArticleBannerNode, + ByLineInfoBarNode, +] diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx similarity index 97% rename from frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx rename to frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx index 95f0ef7026..0730eec432 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx @@ -8,7 +8,7 @@ import React from "react" import { screen, waitFor, fireEvent } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { setMockResponse, factories, urls, makeRequest } from "api/test-utils" -import { ArticleEditor } from "./ArticleEditor" +import { NewsEditor } from "./NewsEditor" import type { JSONContent } from "@tiptap/react" import { renderWithProviders } from "@/test-utils" @@ -19,7 +19,7 @@ jest.mock("posthog-js/react", () => ({ const mockOnSave = jest.fn() -describe("ArticleEditor - Content Editing and Saving", () => { +describe("NewsEditor - Content Editing and Saving", () => { beforeEach(() => { mockOnSave.mockClear() jest.clearAllMocks() @@ -36,21 +36,23 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ id: articleId, title, content, is_published: false, }) - setMockResponse.get(urls.websiteContent.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), newsItem) renderWithProviders( - , - { user }, + , + { + user, + }, ) await screen.findByTestId("editor") - return article + return newsItem } describe("Editing title in banner heading", () => { @@ -377,7 +379,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) describe("Save as Draft functionality", () => { - test("can save article as draft", async () => { + test("can save news as draft", async () => { const initialContent: JSONContent = { type: "doc", content: [ @@ -405,23 +407,23 @@ describe("ArticleEditor - Content Editing and Saving", () => { ], } - const article = await setupEditor(initialContent, 208, "Title") + const newsItem = await setupEditor(initialContent, 208, "Title") const paragraph = screen.getByText("Content") await userEvent.click(paragraph) await userEvent.keyboard("{Control>}a{/Control}") await userEvent.type(paragraph, "Updated content") - const updatedArticle = { - ...article, + const updatedNewsItem = { + ...newsItem, content: expect.objectContaining({ type: "doc", }), is_published: false, } setMockResponse.patch( - urls.websiteContent.details(article.id), - updatedArticle, + urls.websiteContent.details(newsItem.id), + updatedNewsItem, ) const saveDraftButton = await screen.findByRole("button", { @@ -434,7 +436,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { expect(makeRequest).toHaveBeenCalledWith( "patch", - urls.websiteContent.details(article.id), + urls.websiteContent.details(newsItem.id), expect.objectContaining({ is_published: false, author_name: "", @@ -506,22 +508,22 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) }) - describe("Creating new articles", () => { - test("submits article successfully", async () => { + describe("Creating news", () => { + test("submits news successfully", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const createdArticle = factories.articles.article({ + const createdNewsItem = factories.websiteContent.websiteContent({ id: 101, title: "My Article", is_published: true, }) - setMockResponse.post(urls.websiteContent.list(), createdArticle) + setMockResponse.post(urls.websiteContent.list(), createdNewsItem) - renderWithProviders(, { user }) + renderWithProviders(, { user }) await screen.findByTestId("editor") @@ -599,7 +601,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { expect(savedData).toBeDefined() expect(savedData).toMatchObject({ - id: createdArticle.id, + id: createdNewsItem.id, title: "My Article", is_published: true, }) @@ -607,7 +609,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) }) -describe("ArticleEditor - Document Rendering", () => { +describe("NewsEditor - Document Rendering", () => { let consoleErrorSpy: ReturnType beforeEach(() => { @@ -639,20 +641,20 @@ describe("ArticleEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ id: 1, title: "Test Article", content, }) - setMockResponse.get(urls.websiteContent.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), newsItem) renderWithProviders( - , + , { user }, ) await screen.findByTestId("editor") - return article + return newsItem } test("renders editor when user has ArticleEditor permission", async () => { @@ -662,7 +664,7 @@ describe("ArticleEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - renderWithProviders(, { user }) + renderWithProviders(, { user }) await screen.findByTestId("editor") }) 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..8b215712f2 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -0,0 +1,58 @@ +"use client" + +import React from "react" +import { WebsiteContentContentTypeEnum, type WebsiteContent } from "api/v1" +import { + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useMediaUpload, +} from "api/hooks/website_content" +import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" +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 || "", + content_type: "news", + } +} + +interface NewsEditorProps { + onSave?: (savedContent: WebsiteContent) => void + readOnly?: boolean + newsItem?: WebsiteContent +} + +/** + * Editor shell configured for the news content type (served under /news). + * Owns its own save mutations (websiteContent API) and passes them to + * WebsiteContentEditor — keeping the generic shell decoupled from any specific API. + */ +const NewsEditor = ({ onSave, readOnly, newsItem }: NewsEditorProps) => { + // News content type uses the websiteContent API. + // A different content type would call different hooks here. + const createMutation = useWebsiteContentCreate() + const updateMutation = useWebsiteContentPartialUpdate() + const uploadImage = useMediaUpload() + + 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..05a650b379 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts @@ -0,0 +1,49 @@ +import type { Extension, Node, Mark } from "@tiptap/core" +import Document from "@tiptap/extension-document" +import { BannerNode } from "../../extensions/node/Banner/BannerNode" +import { ByLineInfoBarNode } from "../../extensions/node/ByLineInfoBar/ByLineInfoBarNode" +import { createBaseExtensions } from "../../extensions/baseExtensions" +import type { CreateExtensionsFn } from "../../core/WebsiteContentEditor" + +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 WebsiteContentEditor as `createExtensions`. + */ +export const createNewsExtensions: CreateExtensionsFn = ( + uploadHandler, + setUploadError, +): (Extension | Node | Mark)[] => [ + NewsDocument, + ...createBaseExtensions(uploadHandler, setUploadError), + BannerNode, + ByLineInfoBarNode, +] diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx new file mode 100644 index 0000000000..a6fc8a5ad8 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -0,0 +1,499 @@ +"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, WebsiteContentContentTypeEnum } from "api/v1" +import { + LoadingSpinner, + Typography, + HEADER_HEIGHT, + HEADER_HEIGHT_MD, +} from "ol-components" +import { Alert, Button, ButtonLink } from "@mitodl/smoot-design" +import { useUserHasPermission, Permission } from "api/hooks/user" +import dynamic from "next/dynamic" + +import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" +import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" +import { BannerViewer } from "../extensions/node/Banner/BannerNode" +import { Spacer } from "../vendor/components/tiptap-ui-primitive/spacer" +import { handleImageUpload } from "../vendor/lib/tiptap-utils" +import { useSchema } from "../useSchema" +import { WebsiteContentProvider } from "../WebsiteContentContext" +import { extractLearningResourceIds, contentsMatch } from "../extensions/utils" +import { LearningResourceProvider } from "../extensions/node/LearningResource/LearningResourceDataProvider" +import { websiteContentDraftsView, websiteContentEditView } from "@/common/urls" + +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 minimal interface expected from a media upload mutation. + * Matches the shape returned by `useMediaUpload` from `api/hooks/website_content`, + * but callers may supply any compatible implementation. + */ +export interface MediaUpload { + mutateAsync: (data: { file: File }) => Promise<{ url?: string }> + setNextProgressCallback: ( + callback: ((percent: number) => void) | undefined, + ) => void +} + +/** + * 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 WebsiteContentEditor never imports a + * specific API hook directly. + * + * Example — news type uses websiteContent API: + * const create = useWebsiteContentCreate() + * const update = useWebsiteContentPartialUpdate() + * + * + * A future content type could use a different API hook: + * const create = useSpecializedContentCreate() // future hook + * const update = useSpecializedContentPartialUpdate() + * + */ +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 WebsiteContentEditorProps { + /** + * 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 content item is provided. */ + initialDoc: JSONContent + /** Content type for route generation (Drafts/Edit links in read-only toolbar). */ + contentType: WebsiteContentContentTypeEnum + /** + * Optional CSS class applied to the editor root container (covers both edit + * and read-only). Used by content-type wrappers via `styled(WebsiteContentEditor)` + * to theme nodes through their hook classes. + */ + 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 + * WebsiteContentEditor stays decoupled from any specific API endpoint. + */ + saveMutations: SaveMutations + /** + * Upload mutation provided by the content-type wrapper. + * Pass the return value of `useMediaUpload()` (or a compatible implementation) + * so WebsiteContentEditor stays decoupled from any specific upload endpoint. + */ + uploadImage: MediaUpload + onSave?: (contentItem: WebsiteContent) => void + readOnly?: boolean + contentItem?: WebsiteContent + bannerViewer?: typeof BannerViewer +} + +const WebsiteContentEditor = ({ + createExtensions, + contentType, + initialDoc, + className, + extractExtraFields, + saveMutations, + uploadImage, + onSave, + readOnly, + contentItem, + bannerViewer, +}: WebsiteContentEditorProps) => { + const [isPublishing, setIsPublishing] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [resetAttempted, setResetAttempted] = useState(false) + const [content, setContent] = useState( + contentItem?.content || initialDoc, + ) + const [title, setTitle] = useState(contentItem?.title) + const [touched, setTouched] = useState(false) + + const { create: createMutation, update: updateMutation } = saveMutations + const isPending = createMutation.isPending || updateMutation.isPending + const saveError = createMutation.error || updateMutation.error + + // 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 (contentItem) { + updateMutation.mutate( + { + id: contentItem.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 content changes (e.g., after a refetch) + useEffect(() => { + if (!contentItem || !editor) return + + if (contentItem.content) { + const currentContent = editor.getJSON() + if (!contentsMatch(contentItem.content, currentContent)) { + setContent(contentItem.content) + setTouched(true) + editor.commands.setContent(contentItem.content) + } + } + + if (contentItem.title !== undefined) { + setTitle(contentItem.title) + } + }, [contentItem, 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) + const editIdOrSlug = contentItem?.is_published + ? (contentItem?.slug ?? contentItem.id) + : contentItem?.id + const readOnlyToolbarSlot = ( + <> + + + Drafts + + {editIdOrSlug !== undefined ? ( + + Edit + + ) : null} + + ) + + return ( + + + + + {isArticleEditor ? ( + readOnly ? ( + {readOnlyToolbarSlot} + ) : ( + + + {!contentItem?.is_published ? ( + + ) : null} + + + ) + ) : null} + + {error ? ( + + + {errorMessage} + + {schemaError && !readOnly ? ( + <> + + Reset to attempt to align the content to the template. + + {resetAttempted ? ( + + Reset attempt failed. + + ) : null} + + + ) : null} + + ) : null} + + {readOnly ? ( + <> + + + + ) : ( + + )} + + + + + ) +} + +export { WebsiteContentEditor } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts new file mode 100644 index 0000000000..3cf7de88a4 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts @@ -0,0 +1,121 @@ +/** + * Tier-1 base extensions: common to all website content types. + * Constructible from only (uploadHandler, setUploadError) — no type-specific args. + * + * Each content type's createExtensions factory collapses to: + * [TypeDocument, ...createBaseExtensions(deps), TypeBannerNode, ByLineInfoBarNode] + */ +import type { Extension, Node, Mark } from "@tiptap/core" +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 "./node/Image/ImageNode" +import { ImageWithCaptionNode } from "./node/Image/ImageWithCaptionNode" +import { DividerNode } from "./node/Divider/DividerNode" +import { LearningResourceNode } from "./node/LearningResource/LearningResourceNode" +import { LearningResourceInputNode } from "./node/LearningResource/LearningResourceInputNode" +import { LearningResourceURLHandler } from "./node/LearningResource/LearningResourcePaste" +import { MediaEmbedURLHandler } from "./node/MediaEmbed/MediaEmbedURLHandler" +import { MediaEmbedNode } from "./node/MediaEmbed/MediaEmbedNode" +import { MediaEmbedInputNode } from "./node/MediaEmbed/MediaEmbedInputNode" +import type { ExtendedNodeConfig } from "./node/types" +import { MAX_FILE_SIZE } from "../vendor/lib/tiptap-utils" +import type { UploadHandler } from "../core/WebsiteContentEditor" + +export const createBaseExtensions = ( + uploadHandler: UploadHandler, + setUploadError: (error: string | null) => void, +): (Extension | Node | Mark)[] => [ + 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, + ImageWithCaptionNode, + MediaEmbedURLHandler, + ImageNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: uploadHandler, + onError: (error) => setUploadError(error.message), + }), +] diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx new file mode 100644 index 0000000000..d5282d2fc0 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -0,0 +1,192 @@ +import React from "react" +import { + ReactNodeViewRenderer, + Node, + mergeAttributes, + NodeViewWrapper, + NodeViewContent, + ReactNodeViewContentProvider, +} from "@tiptap/react" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { Container, Breadcrumbs } from "ol-components" +import styled from "@emotion/styled" +import type { ExtendedNodeConfig } from "../types" +import { getTitle } from "../lib" + +const FullWidthContainer = styled.div({ + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", +}) + +const InnerContainer = styled(Container)({ + "&&": { + maxWidth: "890px", + }, +}) + +const StyledNodeViewWrapper = styled(NodeViewWrapper)({ + "&&": { + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", + }, +}) + +const BreadcrumContainer = styled(Container)(({ theme }) => ({ + maxWidth: "1080px !important", + padding: "0 !important", + [theme.breakpoints.down("lg")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("md")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "0 16px !important", + }, +})) + +const BreadcrumbBar = styled.div(({ theme }) => ({ + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", + padding: "18px 0 2px 0", + backgroundColor: theme.custom.colors.white, + borderBottom: `1px solid ${theme.custom.colors.red}`, + textDecoration: "none", + "&& .breadcrum span span a": { + textDecoration: "none !important", + }, + "&& .breadcrum span span a span": { + textDecoration: "none !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "12px 0 0px 0", + }, +})) + +const ArticleBannerSection = styled.div(({ theme }) => ({ + padding: "64px 0", + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("sm")]: { + padding: "32px 0", + }, +})) + +const StyledNodeViewContent = styled(NodeViewContent)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + "&&&&& h1": { + marginTop: 0, + marginBottom: "0px", + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.h3, + }, + }, + "&&&&& p": { + position: "relative", + marginBottom: 0, + marginTop: "16px", + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.body2, + marginTop: 0, + }, + }, + ".is-empty:not(.with-slash)[data-placeholder]:has(> .ProseMirror-trailingBreak:only-child)::before": + { + color: theme.custom.colors.silverGrayLight, + opacity: 0.4, + }, + '[contenteditable="true"] &': { + caretColor: theme.custom.colors.red, + }, +})) + +const ArticleBannerViewer = ({ + children, + node, +}: { + children?: React.ReactNode + node?: ProseMirrorNode +}) => { + return ( + + + + ) +} + +const ArticleBannerWrapper = (props?: { node?: ProseMirrorNode }) => { + return ( + + + + + + + + + + + + + + + ) +} + +const articleBannerNodeConfig: ExtendedNodeConfig = { + name: "banner", + + selectable: false, + + // Enforce that the node must contain exactly a title (heading) and subheading (paragraph) + content: "heading paragraph", + + isolating: false, + + parseHTML() { + return [{ tag: "banner" }] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ["banner", mergeAttributes(HTMLAttributes), 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(ArticleBannerWrapper) + }, + + getPlaceholders: (childNode: ProseMirrorNode) => { + if (childNode.type.name === "heading") { + return "Add a title" + } + if (childNode.type.name === "paragraph") { + return "Add a subheading" + } + return null + }, +} + +const ArticleBannerNode = Node.create(articleBannerNodeConfig) + +export { ArticleBannerNode, ArticleBannerViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx similarity index 86% rename from frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx rename to frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx index 92d32c516e..84c2b6f6a9 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx @@ -6,7 +6,7 @@ import { Container } from "ol-components" import { RiShareFill } from "@remixicon/react" import { ActionButton, TextField } from "@mitodl/smoot-design" import type { JSONContent } from "@tiptap/core" -import { useArticle } from "../../../ArticleContext" +import { useWebsiteContent } from "../../../WebsiteContentContext" import { calculateReadTime } from "../../utils" import SharePopover from "@/components/SharePopover/SharePopover" @@ -56,6 +56,12 @@ const InfoText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +// Separator between read time and date. Default "-"; content-type owners may +// override the glyph by targeting the "byline-info-bar__separator" hook class. +const Separator = styled(InfoText)({ + "&::before": { content: '"-"' }, +}) + const AuthorInput = styled(TextField)(({ theme }) => ({ "& .MuiInputBase-root": { ...theme.typography.body2, @@ -85,7 +91,7 @@ const AuthorInput = styled(TextField)(({ theme }) => ({ width: "300px", })) -interface ArticleByLineInfoBarContentProps { +interface ByLineInfoBarContentProps { publishedDate: string | null content: JSONContent | null | undefined isEditable?: boolean @@ -93,17 +99,17 @@ interface ArticleByLineInfoBarContentProps { onAuthorNameChange?: (name: string) => void } -export const ArticleByLineInfoBarContent = ({ +export const ByLineInfoBarContent = ({ publishedDate, content, isEditable = false, authorName, onAuthorNameChange, -}: ArticleByLineInfoBarContentProps) => { +}: ByLineInfoBarContentProps) => { const [shareOpen, setShareOpen] = useState(false) const shareButtonRef = useRef(null) - const article = useArticle() + const article = useWebsiteContent() const readTime = calculateReadTime(content) @@ -111,13 +117,13 @@ export const ArticleByLineInfoBarContent = ({ const displayAuthorName = authorName || "" return ( - + setShareOpen(false)} - pageUrl={`${NEXT_PUBLIC_ORIGIN}/news/${article?.slug}`} + pageUrl={`${NEXT_PUBLIC_ORIGIN}/${article?.content_type === "article" ? "articles" : "news"}/${article?.slug}`} /> {(displayAuthorName || isEditable) && ( @@ -135,7 +141,9 @@ export const ArticleByLineInfoBarContent = ({ By {displayAuthorName} )} {readTime ? {readTime} min read : null} - {readTime && publishedDate ? - : null} + {readTime && publishedDate ? ( + + ) : null} {publishedDate ? new Date(publishedDate).toLocaleDateString("en-US", { @@ -165,12 +173,12 @@ export const ArticleByLineInfoBarContent = ({ ) } -const ArticleByLineInfoBar = ({ +const ByLineInfoBar = ({ editor, node, updateAttributes, }: ReactNodeViewProps) => { - const article = useArticle() + const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null @@ -200,7 +208,7 @@ const ArticleByLineInfoBar = ({ return ( - { - const article = useArticle() +const ByLineInfoBarViewer = () => { + const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null const content = article?.content const authorName = article?.author_name ?? null return ( - { ) } -export { ArticleByLineInfoBarViewer } +export { ByLineInfoBarViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts index 60304b2ea2..36d8e0a99d 100644 --- a/frontends/main/src/page-components/TiptapEditor/index.ts +++ b/frontends/main/src/page-components/TiptapEditor/index.ts @@ -1 +1,6 @@ -export { ArticleEditor } from "./ArticleEditor" +export { NewsEditor } from "./contentTypes/news/NewsEditor" +export { WebsiteContentEditor } from "./core/WebsiteContentEditor" +export type { + WebsiteContentEditorProps, + CreateExtensionsFn, +} from "./core/WebsiteContentEditor" diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx deleted file mode 100644 index 5837cab756..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @jest-environment @happy-dom/jest-environment - */ -import React from "react" -import { render, screen, waitFor } from "@testing-library/react" -import { useArticleSchema } from "./useArticleSchema" -import type { JSONContent } from "@tiptap/react" - -// Mock console methods to avoid noise in test output -const originalError = console.error -let consoleErrorSpy: ReturnType - -beforeEach(() => { - // Suppress expected validation error messages in console.error - consoleErrorSpy = jest - .spyOn(console, "error") - .mockImplementation((message) => { - // Suppress expected validation errors - if ( - typeof message === "string" && - message.includes("Document schema check failed") - ) { - return - } - originalError(message) - }) -}) - -afterEach(() => { - consoleErrorSpy.mockRestore() -}) - -const TestComponent = ({ - content, - enabled, -}: { - content: JSONContent - enabled: boolean -}) => { - const mockUploadHandler = jest - .fn() - .mockResolvedValue("http://example.com/image.jpg") - const mockSetUploadError = jest.fn() - - const { schemaError } = useArticleSchema({ - uploadHandler: mockUploadHandler, - setUploadError: mockSetUploadError, - enabled, - content, - }) - - return ( -
    - {schemaError &&
    {schemaError}
    } - {!schemaError &&
    No error
    } -
    - ) -} - -describe("useArticleSchema", () => { - describe("schema validation", () => { - test("show schema error when document is not valid ProseMirror content", async () => { - const content: JSONContent = { - some: "random", - } - render() - - await screen.findByText( - "Document schema check failed: Invalid content for node doc: content specification not satisfied", - ) - }) - - test("show schema error when document is not valid ProseMirror JSON", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "invalid", - }, - ], - } - render() - - await screen.findByText( - 'Document schema check failed: Invalid content for node doc: node type "invalid" not found in schema', - ) - }) - - test("shows schema error when document does not confirm to content expression (missing banner and byline)", async () => { - // Content missing required banner and byline - const content: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Content paragraph", - }, - ], - }, - ], - } - - render() - - await screen.findByText( - "Document schema check failed: Invalid content for node doc: paragraph is not allowed in this position", - ) - }) - - test("shows no error when content is valid", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "banner", - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [{ type: "text", text: "Title" }], - }, - { - type: "paragraph", - content: [], - }, - ], - }, - { - type: "byline", - }, - { - type: "paragraph", - content: [{ type: "text", text: "Content paragraph" }], - }, - ], - } - - render() - - await waitFor(() => { - expect(screen.getByTestId("no-error")).toBeInTheDocument() - }) - }) - - test("shows no error when enabled is false", async () => { - // Invalid content, but validation should be skipped - const content: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Content paragraph", - }, - ], - }, - ], - } - - render() - - await waitFor(() => { - expect(screen.getByTestId("no-error")).toBeInTheDocument() - }) - }) - }) -}) diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts deleted file mode 100644 index 5e192b7265..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts +++ /dev/null @@ -1,185 +0,0 @@ -"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+", -}) - -interface UseArticleSchemaOptions { - uploadHandler: ( - file: File, - onProgress?: (e: { progress: number }) => void, - abortSignal?: AbortSignal, - ) => Promise - setUploadError: (error: string | null) => void - enabled: boolean - 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: [] }, - ], -} - -export const useArticleSchema = ({ - uploadHandler, - setUploadError, - enabled, - 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, - ], - [uploadHandler, setUploadError], - ) - - const schema = useMemo(() => getSchema(extensions), [extensions]) - - const schemaError = useSchema({ - schema, - content, - enabled, - }) - - return { - extensions, - schema, - schemaError, - } -} diff --git a/news_events/etl/articles_news.py b/news_events/etl/articles_news.py index 19a7c9a131..219947cb31 100644 --- a/news_events/etl/articles_news.py +++ b/news_events/etl/articles_news.py @@ -43,9 +43,11 @@ def sync_single_website_content_news_to_news(article: WebsiteContent): """ if not article.is_published: return + if article.content_type != WebsiteContentType.news.name: return article_data = extract_single_website_content(article) + item_data = transform_single_article(article_data) if not item_data: return diff --git a/news_events/tasks.py b/news_events/tasks.py index 7e285aad7c..1168fc7493 100644 --- a/news_events/tasks.py +++ b/news_events/tasks.py @@ -50,6 +50,7 @@ def get_mitpe_events(): @app.task def get_website_content_news(): """Run the website content news ETL pipeline""" + pipelines.articles_news_etl() clear_views_cache() @@ -86,7 +87,9 @@ def sync_website_content_to_news(self, content_id: int): try: content = WebsiteContent.objects.get(id=content_id, is_published=True) + sync_single_website_content_news_to_news(content) + clear_views_cache() logger.info( "Successfully synced content %s to news feed", diff --git a/website_content/api.py b/website_content/api.py index fa39d244d1..0fdfe4f0a9 100644 --- a/website_content/api.py +++ b/website_content/api.py @@ -12,6 +12,7 @@ log = logging.getLogger(__name__) + _CONTENT_TYPE_LISTING_URL = { WebsiteContentType.news.name: "/news", WebsiteContentType.article.name: "/articles", @@ -53,6 +54,7 @@ def purge_content_on_save(content): fastly_purge_website_content_list.delay( _CONTENT_TYPE_LISTING_URL.get(content.content_type, "/news") ) + else: log.debug( "WebsiteContent %s is not published or has no slug, skipping CDN purge.", diff --git a/website_content/migrations/0003_add_editors_group.py b/website_content/migrations/0003_add_editors_group.py index 1d49627b8d..44366a99ec 100644 --- a/website_content/migrations/0003_add_editors_group.py +++ b/website_content/migrations/0003_add_editors_group.py @@ -1,4 +1,5 @@ """ + Ensure the website_content_editors Group exists in the database. If the legacy article_editors Group exists, copy its members into the new group diff --git a/website_content/views.py b/website_content/views.py index 8fa4123cb9..fb8bce5e86 100644 --- a/website_content/views.py +++ b/website_content/views.py @@ -55,12 +55,12 @@ class WebsiteContentViewSet(viewsets.ModelViewSet): queryset = WebsiteContent.objects.all() permission_classes = [CanViewWebsiteContent, CanEditWebsiteContent] http_method_names = VALID_HTTP_METHODS + filter_backends = [DjangoFilterBackend] filterset_class = WebsiteContentFilter def get_queryset(self): qs = WebsiteContent.objects.all() - if not (is_admin_user(self.request) or is_website_content_editor(self.request)): qs = qs.filter(is_published=True)