Skip to content

Commit c97f380

Browse files
chore: changelog modal improvements
1 parent a02b2e1 commit c97f380

File tree

2 files changed

+320
-22
lines changed

2 files changed

+320
-22
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"use client";
2+
import { useEffect, useRef } from "react";
3+
import type { SerializedInlineBlockNode } from "@payloadcms/richtext-lexical";
4+
import { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical";
5+
import { RichText, JSXConvertersFunction, JSXConverters } from "@payloadcms/richtext-lexical/react";
6+
import { Info, Lightbulb, Megaphone, Search, Zap } from "lucide-react";
7+
8+
type ChangelogCtaFields = {
9+
title: string;
10+
icon: "Zap" | "Lens" | "Info" | "Idea" | "Announce" | null;
11+
color: "Red" | "Yellow" | "Blue" | "Green";
12+
description: any;
13+
};
14+
type ColoredTextFields = {
15+
text?: string;
16+
tag?: string;
17+
id?: string;
18+
blockType?: "colored-text";
19+
};
20+
21+
type InlineBlockRendererProps = {
22+
node: SerializedInlineBlockNode<ColoredTextFields>;
23+
};
24+
25+
type InlineBlockRendererFn = (props: InlineBlockRendererProps) => React.ReactElement | null;
26+
27+
type InlineBlockRendererMap = {
28+
[key: string]: InlineBlockRendererFn;
29+
};
30+
31+
type InLineBlockConverterType = {
32+
inlineBlocks: InlineBlockRendererMap;
33+
};
34+
35+
const inLineBlockConverter: InLineBlockConverterType = {
36+
inlineBlocks: {
37+
["colored-text"]: ({ node }) => {
38+
const text = node.fields.text;
39+
40+
if (!text) {
41+
console.warn("Node for 'colored-text' inlineBlock is missing 'text' field:", node);
42+
return null;
43+
}
44+
45+
return <span className="text-blue-500 font-bold font-mono break-all">{text}</span>;
46+
},
47+
},
48+
};
49+
50+
const UploadJSXConverter: JSXConverters<any> = {
51+
upload: ({ node }) => {
52+
if (node.value && node.value.url) {
53+
return (
54+
<img src={node.value.url} alt={node.value.alt || "Uploaded image"} className="w-[100%] h-auto object-cover" />
55+
);
56+
}
57+
return null;
58+
},
59+
};
60+
61+
const CalloutJSXConverter: any = {
62+
blocks: {
63+
Callout: ({ node }: { node: SerializedInlineBlockNode<ChangelogCtaFields> }) => {
64+
const { fields } = node;
65+
66+
if (!fields) return null;
67+
68+
const iconMap = {
69+
Zap: <Zap />,
70+
Lens: <Search />,
71+
Info: <Info />,
72+
Idea: <Lightbulb />,
73+
Announce: <Megaphone />,
74+
};
75+
76+
const colorClasses = {
77+
Red: "border border-[#DD4167] dark:border-[#4C182C] dark:bg-[#4C182C]/40 bg-[#DD4167]/40",
78+
Yellow: "border border-[#D4A72C66] dark:border-[#BF8700] bg-[#FFF8C5] dark:bg-[#332E1B]",
79+
Blue: "border border-[#3f76ff] dark:border-[#224f6a] bg-[#d9efff] dark:bg-[#1e2934]",
80+
Green: "border border-[#5CD3B5] dark:border-[#235645] bg-[#D3F9E7] dark:bg-[#1E2B2A]",
81+
};
82+
const iconColorClasses = {
83+
Red: "text-[#DD4167]",
84+
Yellow: "text-[#9A6700]",
85+
Blue: "text-[#3f76ff] dark:text-[#4d9ed0]",
86+
Green: "text-[#208779] dark:text-[#A8F3D0]",
87+
};
88+
89+
return (
90+
<div className="py-4 pb-2 h-full w-full">
91+
<div className={`p-4 rounded-lg flex flex-row gap-3 ${colorClasses[fields.color] ?? colorClasses.Yellow}`}>
92+
<div className={`${iconColorClasses[fields.color] ?? iconColorClasses.Yellow}`}>
93+
{fields.icon && iconMap[fields.icon]}
94+
</div>
95+
<div>
96+
{fields.title && <h4 className="font-semibold mb-1 -mt-0">{fields.title}</h4>}
97+
{fields.description && (
98+
<div className="text-sm">
99+
<RichText
100+
converters={jsxConverters}
101+
data={fields.description}
102+
className="[&>ul]:list-disc [&>ol]:list-decimal [&_a]:underline"
103+
/>
104+
</div>
105+
)}
106+
</div>
107+
</div>
108+
</div>
109+
);
110+
},
111+
video: ({ node }: { node: any }) => {
112+
const { fields } = node;
113+
const { video } = fields;
114+
return (
115+
<div className="h-full relative">
116+
<video controls={false} autoPlay loop muted playsInline>
117+
<source src={video.url} type={video.mimeType || "video/mp4"} />
118+
Your browser does not support the video tag.
119+
</video>
120+
</div>
121+
);
122+
},
123+
},
124+
};
125+
126+
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
127+
...defaultConverters,
128+
...UploadJSXConverter,
129+
...CalloutJSXConverter,
130+
...inLineBlockConverter,
131+
// ...videoJSXConverter,
132+
});
133+
134+
export const RichTextNode = ({ description, id }: { description: SerializedEditorState; id: number }) => {
135+
const containerRef = useRef<HTMLDivElement>(null);
136+
137+
useEffect(() => {
138+
if (!containerRef.current) return;
139+
140+
const container = containerRef.current;
141+
142+
const walker = document.createTreeWalker(
143+
container,
144+
NodeFilter.SHOW_TEXT, // Only process text nodes
145+
null // No custom filter function needed
146+
// false // deprecated argument
147+
);
148+
149+
let node;
150+
while ((node = walker.nextNode())) {
151+
if (node.nodeValue && node.nodeValue.includes("\u00A0")) {
152+
// \u00A0 is the Unicode char for &nbsp;
153+
node.nodeValue = node.nodeValue.replace(/\u00A0/g, " "); // Replace all occurrences with a regular space
154+
}
155+
}
156+
157+
const createId = (text: string): string =>
158+
text
159+
.toLowerCase()
160+
.replace(/[^a-z0-9-\s]/g, "")
161+
.replace(/\s+/g, "-");
162+
163+
const headings = container.querySelectorAll("h1, h2, h3, h4, h5, h6");
164+
headings.forEach((heading) => {
165+
const text = heading.textContent?.trim() || "";
166+
const id = createId(text);
167+
heading.classList.add("text-neutral-text-primary");
168+
if (!heading.id) {
169+
heading.id = id;
170+
}
171+
const htmlHeading = heading as HTMLElement;
172+
switch (htmlHeading.tagName) {
173+
case "H1":
174+
htmlHeading.style.marginTop = "35px";
175+
htmlHeading.style.marginBottom = "15px";
176+
break;
177+
case "H2":
178+
htmlHeading.style.marginTop = "35px";
179+
htmlHeading.style.marginBottom = "20px";
180+
break;
181+
case "H3":
182+
htmlHeading.style.marginTop = "20px";
183+
htmlHeading.style.marginBottom = "18px";
184+
break;
185+
}
186+
});
187+
188+
// Fix list styling to ensure bullet points are visible
189+
const ulElements = container.querySelectorAll("ul");
190+
ulElements.forEach((ul) => {
191+
const htmlUl = ul as HTMLElement;
192+
htmlUl.style.listStyleType = "disc";
193+
htmlUl.style.listStylePosition = "outside";
194+
htmlUl.style.paddingLeft = "20px";
195+
htmlUl.style.marginTop = "10px";
196+
htmlUl.style.marginBottom = "10px";
197+
});
198+
199+
const olElements = container.querySelectorAll("ol");
200+
olElements.forEach((ol) => {
201+
const htmlOl = ol as HTMLElement;
202+
htmlOl.style.listStyleType = "decimal";
203+
htmlOl.style.listStylePosition = "outside";
204+
htmlOl.style.paddingLeft = "20px";
205+
htmlOl.style.marginTop = "10px";
206+
htmlOl.style.marginBottom = "10px";
207+
});
208+
209+
const listItems = container.querySelectorAll("li");
210+
listItems.forEach((listItem) => {
211+
const htmlLi = listItem as HTMLElement;
212+
htmlLi.classList.add("text-custom-text-300");
213+
htmlLi.style.marginTop = "8px";
214+
htmlLi.style.marginBottom = "4px";
215+
htmlLi.style.lineHeight = "1.5";
216+
htmlLi.style.display = "list-item";
217+
// Ensure the list item has proper spacing and bullet point visibility
218+
htmlLi.style.paddingLeft = "4px";
219+
});
220+
221+
const paragraphs = container.querySelectorAll("p");
222+
paragraphs.forEach((paragraph) => {
223+
paragraph.classList.add("text-neutral-text-primary");
224+
paragraph.style.fontSize = "16px";
225+
paragraph.style.marginBottom = "1px";
226+
paragraph.style.marginTop = "10px";
227+
paragraph.style.lineHeight = "24px";
228+
// paragraph.style.overflowWrap = "break-word";
229+
// paragraph.style.whiteSpace = "break-spaces";
230+
});
231+
232+
const links = container.querySelectorAll("a");
233+
links.forEach((link) => {
234+
const htmlLink = link as HTMLAnchorElement;
235+
htmlLink.style.color = "#3191ff";
236+
htmlLink.style.textDecoration = "underline";
237+
htmlLink.target = "_blank";
238+
});
239+
240+
const strongElements = container.querySelectorAll("strong");
241+
strongElements.forEach((strongElement) => {
242+
// strongElement.classList.add("font-mono", "font-bold", "text-blue-500");
243+
strongElement.classList.add("font-bold", "text-neutral-text-primary");
244+
});
245+
246+
const codeElements = container.querySelectorAll("code");
247+
codeElements.forEach((codeElement) => {
248+
const htmlCode = codeElement as HTMLElement;
249+
htmlCode.classList.add("font-mono", "break-all", "font-bold", "text-blue-500");
250+
htmlCode.style.wordBreak = "break-all";
251+
});
252+
253+
const blockquotes = container.querySelectorAll("blockquote");
254+
blockquotes.forEach((blockquote) => {
255+
const htmlBlockquote = blockquote as HTMLElement;
256+
htmlBlockquote.style.padding = "0 16px";
257+
});
258+
}, []);
259+
260+
return (
261+
<div
262+
ref={containerRef}
263+
// style={{letterSpacing: "-0.020em" }}
264+
>
265+
<div key={id}>
266+
<RichText
267+
data={description}
268+
className="[&>ul]:list-disc [&>ul]:ml-5 [&>ol]:list-decimal [&>ol]:ml-5 [&>ul]:pl-5 [&>ol]:pl-5"
269+
converters={jsxConverters} // Pass the custom converters here
270+
/>
271+
</div>
272+
</div>
273+
);
274+
};

web/core/components/global/product-updates/modal.tsx

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,70 @@
11
import { FC } from "react";
22
import { observer } from "mobx-react-lite";
3-
import { useTranslation } from "@plane/i18n";
4-
// ui
3+
import useSWR from "swr";
4+
import { Megaphone } from "lucide-react";
5+
// plane imports
6+
import { ChangelogConfig } from "@plane/constants";
57
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
68
// components
9+
import { LogoSpinner } from "@/components/common";
710
import { ProductUpdatesFooter } from "@/components/global";
8-
// hooks
9-
import { useInstance } from "@/hooks/store";
1011
// plane web components
1112
import { ProductUpdatesHeader } from "@/plane-web/components/global";
13+
// services
14+
import { ChangelogService } from "@/services/changelog.service";
15+
// local components
16+
import { RichTextNode } from "./jsxConverter";
1217

1318
export type ProductUpdatesModalProps = {
1419
isOpen: boolean;
1520
handleClose: () => void;
1621
};
1722

23+
const changelogService = new ChangelogService();
24+
1825
export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props) => {
1926
const { isOpen, handleClose } = props;
20-
const { t } = useTranslation();
21-
const { config } = useInstance();
27+
28+
// useSWR
29+
const { data, isLoading } = useSWR(
30+
isOpen ? `CHANGELOG_DATA_${ChangelogConfig.limit}_${ChangelogConfig.page}` : null,
31+
() => changelogService.fetchChangelog(ChangelogConfig.slug, ChangelogConfig.limit, ChangelogConfig.page)
32+
);
2233

2334
return (
2435
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXXXL}>
2536
<ProductUpdatesHeader />
2637
<div className="flex flex-col h-[60vh] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5">
27-
{config?.instance_changelog_url && config?.instance_changelog_url !== "" ? (
28-
<iframe src={config?.instance_changelog_url} className="w-full h-full" />
29-
) : (
30-
<div className="flex flex-col items-center justify-center w-full h-full mb-8">
31-
<div className="text-lg font-medium">{t("we_are_having_trouble_fetching_the_updates")}</div>
32-
<div className="text-sm text-custom-text-200">
33-
{t("please_visit")}
34-
<a
35-
href="https://go.plane.so/p-changelog"
36-
target="_blank"
37-
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
38-
>
39-
{t("our_changelogs")}
40-
</a>{" "}
41-
{t("for_the_latest_updates")}.
42-
</div>
38+
{isLoading ? (
39+
<div className="flex justify-center items-center h-full">
40+
<LogoSpinner />
4341
</div>
42+
) : (
43+
<>
44+
{data && data?.docs?.length > 0 ? (
45+
<div className="relative h-full mx-auto px-4 container">
46+
<div>
47+
{data.docs.map((contentItem) => {
48+
if (!contentItem.published) return null;
49+
50+
return (
51+
<div key={contentItem.id} className="relative mb-20 scroll-mt-[50px] lg:scroll-mt-[64px]">
52+
<div className="flex items-center gap-2 py-2 sticky top-0 z-10 bg-custom-background-100">
53+
<span className="size-8 rounded-full border flex items-center justify-center">
54+
<Megaphone className="size-6" />
55+
</span>
56+
<span className="text-neutral-text-primary text-xl font-bold">{contentItem.title}</span>
57+
</div>
58+
<RichTextNode id={Number(contentItem.id)} description={contentItem.description} />
59+
</div>
60+
);
61+
})}
62+
</div>
63+
</div>
64+
) : (
65+
<div className="text-center container my-[30vh]">No data available</div>
66+
)}
67+
</>
4468
)}
4569
</div>
4670
<ProductUpdatesFooter />

0 commit comments

Comments
 (0)