Skip to content

Commit f54a776

Browse files
Ahtesham QuraishAhtesham Quraish
authored andcommitted
add changes related to editor refactoring
1 parent 366c5cb commit f54a776

27 files changed

Lines changed: 1040 additions & 222 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"use client"
2+
3+
import React, { useState, useRef, useEffect } from "react"
4+
import {
5+
Container,
6+
styled,
7+
theme,
8+
Grid2,
9+
Card,
10+
Pagination,
11+
PaginationItem,
12+
LoadingSpinner,
13+
Typography,
14+
} from "ol-components"
15+
import { Permission } from "api/hooks/user"
16+
import { useArticleList } from "api/hooks/articles"
17+
import type { WebsiteContent } from "api/v1"
18+
import { LocalDate } from "ol-utilities"
19+
import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
20+
import { extractFirstImageFromArticle } from "@/common/articleUtils"
21+
import { websiteContentEditView, websiteContentCreateView } from "@/common/urls"
22+
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
23+
import { ButtonLink } from "@mitodl/smoot-design"
24+
25+
const PAGE_SIZE = 20
26+
27+
export const DEFAULT_BACKGROUND_IMAGE_URL =
28+
"/images/backgrounds/banner_background.webp"
29+
30+
const PageWrapper = styled.div`
31+
background: ${theme.custom.colors.white};
32+
min-height: calc(100vh - 200px);
33+
padding: 80px 0;
34+
${theme.breakpoints.down("md")} {
35+
padding: 40px 0;
36+
}
37+
`
38+
39+
const PageHeader = styled.div`
40+
display: flex;
41+
justify-content: space-between;
42+
align-items: center;
43+
margin-bottom: 40px;
44+
`
45+
46+
const DraftArticleCard = styled(Card)`
47+
display: flex;
48+
flex-direction: column;
49+
height: 100%;
50+
`
51+
52+
const PaginationContainer = styled.div`
53+
display: flex;
54+
justify-content: center;
55+
margin-top: 40px;
56+
`
57+
58+
const LoadingContainer = styled.div`
59+
display: flex;
60+
justify-content: center;
61+
align-items: center;
62+
min-height: 400px;
63+
`
64+
65+
const EmptyState = styled.div`
66+
display: flex;
67+
flex-direction: column;
68+
align-items: center;
69+
justify-content: center;
70+
min-height: 400px;
71+
gap: 16px;
72+
`
73+
74+
const DraftBadge = styled.span`
75+
color: ${theme.custom.colors.silverGrayDark};
76+
font-weight: ${theme.typography.fontWeightMedium};
77+
`
78+
79+
const CONTENT_TYPE_LABELS: Record<string, string> = {
80+
article: "Article",
81+
news: "News",
82+
}
83+
84+
const DraftItem: React.FC<{ article: WebsiteContent; type: string }> = ({
85+
article,
86+
type,
87+
}) => {
88+
const itemUrl = article.is_published
89+
? `/${type === "article" ? "articles" : type}/${article.slug || article.id}`
90+
: websiteContentEditView(type, article.id)
91+
92+
const imageUrl = extractFirstImageFromArticle(article.content)
93+
94+
return (
95+
<DraftArticleCard forwardClicksToLink>
96+
<Card.Image
97+
src={imageUrl || DEFAULT_BACKGROUND_IMAGE_URL}
98+
alt={article.title}
99+
/>
100+
<Card.Title href={itemUrl} lines={2} style={{ marginBottom: "-13px" }}>
101+
{article.title}
102+
</Card.Title>
103+
<Card.Footer>
104+
<LocalDate date={article.created_on} />
105+
{!article.is_published && (
106+
<>
107+
{" • "}
108+
<DraftBadge>Draft</DraftBadge>
109+
</>
110+
)}
111+
</Card.Footer>
112+
</DraftArticleCard>
113+
)
114+
}
115+
116+
interface WebsiteContentDraftListingPageProps {
117+
/**
118+
* Content type to show drafts for (e.g. 'article', 'news').
119+
* Filtering by content_type requires the OpenAPI client to be regenerated
120+
* after adding WebsiteContentFilter to the Django viewset.
121+
*/
122+
contentType?: string
123+
}
124+
125+
const WebsiteContentDraftListingPage: React.FC<
126+
WebsiteContentDraftListingPageProps
127+
> = ({ contentType }) => {
128+
const [page, setPage] = useState(1)
129+
const scrollRef = useRef<HTMLDivElement>(null)
130+
const type = contentType || "article"
131+
const label = CONTENT_TYPE_LABELS[type] ?? type
132+
133+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
134+
const listParams: any = {
135+
limit: PAGE_SIZE,
136+
offset: (page - 1) * PAGE_SIZE,
137+
draft: true,
138+
...(contentType ? { content_type: contentType } : {}),
139+
}
140+
141+
const { data: articles, isLoading: isLoadingArticles } =
142+
useArticleList(listParams)
143+
144+
useEffect(() => {
145+
if (page > 1 && scrollRef.current) {
146+
scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" })
147+
}
148+
}, [page])
149+
150+
const draftArticles = articles?.results
151+
const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0
152+
153+
if (isLoadingArticles) {
154+
return <LoadingSpinner loading={isLoadingArticles} />
155+
}
156+
157+
return (
158+
<RestrictedRoute requires={Permission.ArticleEditor}>
159+
<PageWrapper ref={scrollRef}>
160+
<Container>
161+
<PageHeader>
162+
<Typography variant="h3">{label} Drafts</Typography>
163+
<ButtonLink
164+
variant="primary"
165+
href={websiteContentCreateView(type)}
166+
size="small"
167+
>
168+
New {label}
169+
</ButtonLink>
170+
</PageHeader>
171+
172+
{isLoadingArticles ? (
173+
<LoadingContainer>
174+
<LoadingSpinner loading size={48} />
175+
</LoadingContainer>
176+
) : draftArticles && draftArticles.length > 0 ? (
177+
<>
178+
<Grid2 container columnSpacing="24px" rowSpacing="28px">
179+
{draftArticles.map((article) => (
180+
<Grid2
181+
key={article.id}
182+
size={{ xs: 12, sm: 6, md: 4, lg: 3, xl: 3 }}
183+
>
184+
<DraftItem article={article} type={type} />
185+
</Grid2>
186+
))}
187+
</Grid2>
188+
189+
{totalPages > 1 && (
190+
<PaginationContainer>
191+
<Pagination
192+
count={totalPages}
193+
page={page}
194+
onChange={(_, newPage) => setPage(newPage)}
195+
renderItem={(item) => (
196+
<PaginationItem
197+
slots={{
198+
previous: RiArrowLeftLine,
199+
next: RiArrowRightLine,
200+
}}
201+
{...item}
202+
/>
203+
)}
204+
/>
205+
</PaginationContainer>
206+
)}
207+
</>
208+
) : (
209+
<EmptyState>
210+
<Typography variant="h4">No Draft {label}s</Typography>
211+
<Typography variant="body1" color="textSecondary">
212+
You don&apos;t have any draft {label.toLowerCase()}s yet.
213+
</Typography>
214+
</EmptyState>
215+
)}
216+
</Container>
217+
</PageWrapper>
218+
</RestrictedRoute>
219+
)
220+
}
221+
222+
export { WebsiteContentDraftListingPage }
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"use client"
2+
3+
import React from "react"
4+
import { useRouter } from "next-nprogress-bar"
5+
import { notFound } from "next/navigation"
6+
import { Permission } from "api/hooks/user"
7+
import { useArticleDetailRetrieve } from "api/hooks/articles"
8+
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
9+
import { styled, LoadingSpinner } from "ol-components"
10+
import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor"
11+
import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor"
12+
import { userArticlesView, websiteContentEditView } from "@/common/urls"
13+
import invariant from "tiny-invariant"
14+
import type { WebsiteContent } from "api/v1"
15+
16+
const PageContainer = styled.div(({ theme }) => ({
17+
color: theme.custom.colors.darkGray2,
18+
display: "flex",
19+
height: "100%",
20+
}))
21+
22+
const Spinner = styled(LoadingSpinner)({
23+
margin: "auto",
24+
position: "absolute",
25+
top: "40%",
26+
left: "50%",
27+
transform: "translate(-50%, -50%)",
28+
})
29+
30+
const PUBLISHED_VIEW_URL: Record<string, (slug: string) => string> = {
31+
article: (slug) => userArticlesView(slug),
32+
news: (slug) => `/news/${slug}`,
33+
}
34+
35+
const EDITORS: Record<
36+
string,
37+
React.ComponentType<{
38+
onSave?: (article: WebsiteContent) => void
39+
readOnly?: boolean
40+
article?: WebsiteContent
41+
}>
42+
> = {
43+
article: ArticleEditor,
44+
news: NewsEditor,
45+
}
46+
47+
interface WebsiteContentEditPageProps {
48+
type: string
49+
idOrSlug: string
50+
}
51+
52+
const WebsiteContentEditPage = ({
53+
type,
54+
idOrSlug,
55+
}: WebsiteContentEditPageProps) => {
56+
const {
57+
data: article,
58+
isLoading,
59+
isFetching,
60+
} = useArticleDetailRetrieve(idOrSlug)
61+
const router = useRouter()
62+
63+
const Editor = EDITORS[type]
64+
const viewUrl = PUBLISHED_VIEW_URL[type]
65+
66+
if (!Editor || !viewUrl) {
67+
notFound()
68+
}
69+
70+
if (isLoading || isFetching) {
71+
return <Spinner color="inherit" loading={isLoading} size={32} />
72+
}
73+
if (!article) {
74+
return notFound()
75+
}
76+
77+
return (
78+
<RestrictedRoute requires={Permission.ArticleEditor}>
79+
<PageContainer>
80+
<Editor
81+
article={article}
82+
onSave={(saved) => {
83+
if (saved.is_published) {
84+
invariant(saved.slug, "Published content must have a slug")
85+
return router.push(viewUrl(saved.slug))
86+
} else {
87+
router.push(websiteContentEditView(type, saved.id))
88+
}
89+
}}
90+
/>
91+
</PageContainer>
92+
</RestrictedRoute>
93+
)
94+
}
95+
96+
export { WebsiteContentEditPage }
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client"
2+
3+
import React from "react"
4+
import { useRouter } from "next-nprogress-bar"
5+
import { notFound } from "next/navigation"
6+
import { Permission } from "api/hooks/user"
7+
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
8+
import { styled } from "ol-components"
9+
import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor"
10+
import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor"
11+
import { userArticlesView, websiteContentEditView } from "@/common/urls"
12+
import invariant from "tiny-invariant"
13+
import type { WebsiteContent } from "api/v1"
14+
15+
const PageContainer = styled.div(({ theme }) => ({
16+
color: theme.custom.colors.darkGray2,
17+
display: "flex",
18+
height: "100%",
19+
}))
20+
21+
const PUBLISHED_VIEW_URL: Record<string, (slug: string) => string> = {
22+
article: (slug) => userArticlesView(slug),
23+
news: (slug) => `/news/${slug}`,
24+
}
25+
26+
const EDITORS: Record<
27+
string,
28+
React.ComponentType<{
29+
onSave?: (article: WebsiteContent) => void
30+
readOnly?: boolean
31+
article?: WebsiteContent
32+
}>
33+
> = {
34+
article: ArticleEditor,
35+
news: NewsEditor,
36+
}
37+
38+
interface WebsiteContentNewPageProps {
39+
type: string
40+
}
41+
42+
const WebsiteContentNewPage: React.FC<WebsiteContentNewPageProps> = ({
43+
type,
44+
}) => {
45+
const router = useRouter()
46+
const Editor = EDITORS[type]
47+
const viewUrl = PUBLISHED_VIEW_URL[type]
48+
49+
if (!Editor || !viewUrl) {
50+
notFound()
51+
}
52+
53+
return (
54+
<RestrictedRoute requires={Permission.ArticleEditor}>
55+
<PageContainer>
56+
<Editor
57+
onSave={(article) => {
58+
if (article.is_published) {
59+
invariant(article.slug, "Published content must have a slug")
60+
return router.push(viewUrl(article.slug))
61+
} else {
62+
router.push(websiteContentEditView(type, article.id))
63+
}
64+
}}
65+
/>
66+
</PageContainer>
67+
</RestrictedRoute>
68+
)
69+
}
70+
71+
export { WebsiteContentNewPage }

0 commit comments

Comments
 (0)